diff --git a/.agents/notes/implemented/feature/2026-10-09-forge-panel.i18n.yaml b/.agents/notes/implemented/feature/2026-10-09-forge-panel.i18n.yaml new file mode 100644 index 0000000000..7d202e9987 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-10-09-forge-panel.i18n.yaml @@ -0,0 +1,22 @@ +# Bilingual-pair consistency record for 2026-10-09-forge-panel.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-10-09-forge-panel.md +/agent-note-change-request-panel-and-worktree-sessions: + en: a524673f0d9cc9d8 + zh: db8a2c1ad8f9c8a3 +/agent-note-change-request-panel-and-worktree-sessions/problem: + en: 344bcc7e9825bbc8 + zh: c1d0f4b54f5b3786 +/agent-note-change-request-panel-and-worktree-sessions/decision: + en: 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: 91b3338f958ad0e9 + zh: b64f30d333280084 +/agent-note-change-request-panel-and-worktree-sessions/consequences: + en: 97976f073b9f65e0 + zh: 95be3f7f375b0876 diff --git a/.agents/notes/implemented/feature/2026-10-09-forge-panel.md b/.agents/notes/implemented/feature/2026-10-09-forge-panel.md new file mode 100644 index 0000000000..20b9231fce --- /dev/null +++ b/.agents/notes/implemented/feature/2026-10-09-forge-panel.md @@ -0,0 +1,84 @@ +# Agent Note: Change request panel and worktree Sessions + +Status: implemented + +English | [中文](2026-10-09-forge-panel.zh.md) + +## Problem + +[Session change request bindings](2026-10-09-session-change-request-bindings.md) give each Session a list of change requests, but nothing shows it. A user who asks the agent to open change requests needs their checks, reviews, and mergeability without leaving the conversation, and wants a new Session's edits and commits kept out of the current checkout. Git must run where the Session's commands run, which can be a remote or confined execution world; inspection must not change the repository, and a worktree must exist before the model's first action. + +## Decision + +[`dsh-experimental-client-ui-forge`](../../../../packages/experimental/client-ui-forge/README.md) is a two-face package. Its Host half, the `forgePanel` Remote, assembles everything the panel shows for one Session into one streamed state; the forge registry and the binding service keep only their domain operations. Its Client half is a right-sidebar tab, a Session header button that shows or hides it, a New Session option, a creation card above the composer, 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. + +Status polling follows panel subscriptions: a Session's bindings are watched only while one of its surfaces is mounted, and the registry stops polling change requests nobody watches. Each card keeps the last snapshot a poll returned, so a transient failure keeps the title and number visible. A Session's only open change request starts expanded, and with several every card starts collapsed; the user's first toggle replaces this default. + +An expanded card marks a draft ready for review and merges a change request the host would merge now. The forge seam carries both as provider actions that return a typed failure, and every snapshot says whether the host would merge the change request now for the signed-in user and which methods the repository allows, so the panel offers only actions that can succeed. A merge carries the head commit the card showed, and the host refuses it once new commits arrived. The panel acts only on change requests bound to the Session. + +A new Session starts in a worktree through a request, not an immediate creation. Checking the option in the New Session row, a `conversation.hero.options` entry, appends a `plugin:client-ui-forge/worktree` record with the base branch; changing the base or clearing the option appends another, and nothing touches the repository until the user sends. The option is offered only where creation can succeed: under `workspace-write`, the checkout and its common Git directory must lie inside the writable root, which excludes a project that is itself a linked worktree. The worktree must exist before the first message's turn starts, so the turn runs there and its first request already names the new directory. When the message enters the Session's inbox, the panel's `agent/inbox/inserted` listener starts maintenance of the Agent before the delivery wakes it; the Agent latches the wake until the maintenance ends, and the panel creates and enters the worktree inside it. The creation's stage streams to a card above the composer, where Cancel stops the attempt. After a stopped or failed attempt the message keeps waiting in the inbox, and the card keeps the failure and offers another attempt with a new branch name or Continue without a worktree, which withdraws the request and lets the message go on. An `agent/pre-step` listener still creates the worktree at the first step proposal of a Session whose request is open, because a message that enters while the Agent runs a turn or other maintenance gets no hold; it moves the working-directory notice into the proposed messages, and a failure ends the turn. The `forgeWorktree` projection ignores requests after the first `turn/start` and creations after the first `step/start`, so a running Session never moves. The tab's first row names the branch the Session works on, 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. + +The panel does not move a Session between existing worktrees. Removing a change request appends a `dismiss` record, and its toast offers Undo through `track`. + +The panel also shows the changes behind a Session's work in a changes view, a resource tab type of this package. The Remote parses every diff on the Host into files and hunks, so the browser draws them without a patch parser, and bounds each read with `maxDiffBytes` and `maxUntrackedFiles`. Local changes come from confined `git diff` in the Session's execution world, in a scope the user picks: the branch against its fork point by default, or the uncommitted, unstaged, or staged changes; untracked files are read through the Session's filesystem and shown as new files. A change request's changes come from its code host at the head of the binding's latest status, through two provider reads the forge seam gained, `diff` and `fileText`. The Host keeps the patch it read last for each change request, keyed by the head commit and base branch and bounded by `maxCachedDiffBytes`, so reopening, refreshing, and navigating at the same head download nothing again; a host or provider that refuses an answer over its size limit reports the forge failure `too-large`, and the view then offers the change request's web page instead of a retry. Unchanged lines between hunks expand from the file's new side, which is the working tree, the index, or the head commit, and only while that text still matches the hunks. Each inspection also sizes the branch's changes for a local changes card, shown while no open change request carries the checked-out branch; its Create change request sends the Session a user message, so the Agent creates the change request with its own tools and the binding follows from `gh pr create` as for any other. + +## Deferred + +A left-navigation review page next to Plugins and Scheduled tasks, listing the user's pull requests or merge requests on the selected provider, is deferred. It attaches without changing this package's Session surfaces: + +- **Provider capability** — an optional `ForgeProvider` method such as `listForViewer(host, filter, signal)` returns change request references; GitHub implements it with `gh search prs --author @me` or the GraphQL `viewer`, and the registry aggregates enabled providers. +- **Remote** — a stream like `follow` that is keyed by provider and filter instead of a Session and polls through the same `ctx.forge.watch`. +- **Client** — a `sidebar.panellist` entry with a `main` page in this package, as `ui-schedule` does, rendering `ChangeRequestCard`, which takes a change request, its snapshot, and optional actions. +- **Settings** — provider choice and hosts on the bundle's configuration page. + +The changes view offers no scope for one commit or for the last turn yet; both would be further `ForgeLocalScope` kinds read through `localDiff`. + +## Alternatives considered + +**Per-host nouns.** Calling items pull requests on GitHub and merge requests on GitLab would need each provider to declare its noun and every string to take it; GitHub is the only provider, and one term keeps the copy fixed. + +**Merging through `gh pr merge`.** It enables auto-merge while required checks are pending and joins a merge queue when the base requires one, so a click could schedule a later merge instead of merging. The REST merge endpoint merges at once or refuses. + +**Deleting the head branch after a merge.** The Session's worktree can be on that branch. + +**A Host service package for Git inspection.** No second consumer needs checkout facts yet; a model tool can move them into a shared service when it exists. + +**Running Git as a Host process.** Remote and confined execution worlds would show the Host's files instead of the Session's, and the Host would read directories the Session cannot. + +**Creating the worktree when the option is checked.** Every toggle or base change would create and abandon a checkout, and the Session would move before the user decided to send. + +**Carrying the option in the Session creation request.** The Session controller owns a closed request; a plugin record keeps the option inside this package and survives a reload of the blank Session. + +**Switching between existing worktrees from the panel.** A switched Session keeps the sandbox root of its project, so a worktree outside the project stays read-only, and moving mid-conversation splits the model's view of the files. Starting a Session in its worktree avoids both. + +**The option in the composer tool row.** The tool row stays after the first message, when the option no longer applies; the New Session row disappears once the Session starts. The row offered only single slots, so `ui-conversation` gained the list slot `conversation.hero.options`. + +**Creating the worktree within the first turn.** The turn has started when the first step proposal runs: its context was assembled for the old directory, a failure can only end the turn and drop the claimed message, and the working-directory service has already moved a Session out of a removed worktree. It remains the fallback for a message no hold took. + +**Holding the message at Agent admission.** An `agent-controller/admission` stage would hold a message before it enters the inbox, so the composer keeps it until the worktree exists and a failure returns it for editing. It needs a controller service in front of every sender's delivery, a core addition that one panel does not justify; a hold at inbox insertion uses the existing inbox event and maintenance, and covers every sender. + +**Returning a waiting message to the composer.** Once the Host accepted a message the composer cannot take it back without a new public API in the conversation package for one plugin; the message keeps waiting with the card's actions instead. + +**A submit gate in the Web composer.** The browser held the message before the Host saw it, so every check needed a Remote call, the stage of a refused message lived only in the browser, and other senders bypassed it. A hold at inbox insertion keeps the message where it enters the Agent and the failure in the panel state. + +**The creation card in the transcript.** Transcript nodes accept no plugin kinds; the input dock above the composer is the nearest list slot, and it renders on the New Session screen. + +**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. + +**Reusing the review tab's file comparison.** It belongs to another plugin, which this package may not import at run time, and moving it into the shared primitives would change a shared package for one consumer; the view is built from the shared highlighter, menu, tooltip, and diff color tokens instead. + +**Reading a change request's diff from the local repository.** The checked-out branch can be ahead of or behind the host, and the base may not be fetched; the host's diff is what reviewers see. The view says when the checked-out branch has commits beyond the head instead. + +**Recognizing a too-large diff from the failure message in the panel.** The message belongs to the provider and its host and changes with them; a failure code lets each provider report its own limits, and the panel branches on the code. + +**Creating the change request from the card.** The panel would need to push the branch and compose the title and description; a message lets the Agent do both with its tools and the user's instructions, and keeps one way to bind change requests. + +## Consequences + +The panel shows current status only while it is open; checks that finish while nothing is mounted produce no notification. Changes made outside the Session appear after Refresh or the next tool result. Every inspection spawns up to eleven short Git processes in the Session's execution world with one remote, and two more for each further remote it tries for a fork point. A worktree exists only for a Session that requested it before its first step, and a failed or cancelled attempt can leave its branch or checkout behind. A message that enters while the Agent runs a turn or other maintenance gets no hold, so its worktree is created within the first turn and a removed worktree is not checked. While a message waits, the Agent stays idle in maintenance: the composer offers no stop, and callers waiting for the Agent to become idle wait as well. Restoring loses changes that were never committed. A base branch that requires a merge queue cannot merge from the panel. With Git Worktrees also enabled, two independent worktree services exist; the model's tool never sees the panel's service. A local or change request diff over `maxDiffBytes`, or one the host refuses, shows no files. Expanding unchanged lines of a change request reads each file from the code host once. diff --git a/.agents/notes/implemented/feature/2026-10-09-forge-panel.zh.md b/.agents/notes/implemented/feature/2026-10-09-forge-panel.zh.md new file mode 100644 index 0000000000..9ea88f5ec6 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-10-09-forge-panel.zh.md @@ -0,0 +1,84 @@ +# Agent Note: 变更请求面板与 worktree Session + +Status: implemented + +[English](2026-10-09-forge-panel.md) | 中文 + +## Problem + +[Session 变更请求绑定](2026-10-09-session-change-request-bindings.zh.md)为每个 Session 提供了变更请求列表,但没有地方展示。让 agent 开变更请求的用户需要在不离开对话的情况下看到它们的检查、评审与能否合并,也希望新 Session 的修改和提交不影响当前检出。Git 必须在 Session 命令运行的地方执行,这可能是远程或受限的执行环境;检查不能改动仓库,而且 worktree 必须在模型第一次动作之前就存在。 + +## Decision + +[`dsh-experimental-client-ui-forge`](../../../../packages/experimental/client-ui-forge/README.zh.md) 是一个双端包。宿主端的 `forgePanel` Remote 把面板为一个 Session 展示的全部内容组装成一条流式状态;forge 注册表与绑定服务只保留各自的领域操作。客户端包括一个右侧边栏标签页、一个显示或隐藏它的 Session 标题栏按钮、一个新会话选项、一张位于输入框上方的创建卡片、一个 toast,以及[会话列表中的图标与悬浮卡片部分](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` 事件之后以及刷新时运行,一次只运行一个,最多再追加一次。 + +状态轮询跟随面板订阅:只有该 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 所在的分支,会话列表标出在所创建 worktree 中工作的 Session。 + +Session 所在的 worktree 可能在 Session 之外被删除。每次检查都会确认所创建 worktree 的目录是否还在;Agent 空闲时进入的每条消息都会启动一次暂留并再确认一次,目录缺失期间消息等待、不开始这一轮。这次确认必须发生在这一轮之前:组装一个步骤的上下文时,工作目录服务会把目录已不存在的 Session 移回其项目,而这发生在任何 pre-step 监听器运行之前。“恢复”在记录的目录重新检出分支;分支也已删除时,在其基准提交重新创建分支。“在项目目录继续”把 Session 移回源检出。两者都在暂留内运行,之后放行等待中的消息。 + +面板不在现有 worktree 之间移动 Session。移除变更请求会追加一条 `dismiss` 记录,它的 toast 通过 `track` 提供“撤销”。 + +面板还通过变更视图展示 Session 工作背后的变更;变更视图是本包的一个资源标签页类型。Remote 在宿主上把每个 diff 解析成文件与 hunk,因此浏览器无需补丁解析器即可绘制,每次读取都受 `maxDiffBytes` 与 `maxUntrackedFiles` 约束。本地变更来自 Session 执行环境中受限的 `git diff`,范围由用户选择:默认是分支相对于分叉点的变更,也可以是未提交、未暂存或已暂存的变更;未跟踪文件经 Session 的文件系统读取,显示为新文件。变更请求的变更来自代码托管平台,取自绑定最新状态中的 head,经 forge seam 新增的两个提供方读取操作 `diff` 与 `fileText` 获得。Host 为每个变更请求保留最近读到的补丁,按 head 提交与目标分支区分,总量受 `maxCachedDiffBytes` 约束,因此在同一 head 下重新打开、刷新与导航都不会再次下载;托管平台或提供方因结果超过其大小上限而拒绝时报告 forge 失败 `too-large`,视图随之提供变更请求的网页而不是重试。hunk 之间未变更的行从文件的新版本展开,即工作树、暂存区或 head 提交,并且只在该文本仍与 hunk 吻合时展开。每次检查还会为变更卡片统计分支的变更规模;没有打开中的变更请求以当前检出分支为源分支时才显示该卡片。卡片上的“创建变更请求”向 Session 发送一条用户消息,由 Agent 用自己的工具创建变更请求,绑定像其他变更请求一样来自 `gh pr create`。 + +## Deferred + +在左侧导航“插件”“自动化任务”旁边新增代码审查页、列出用户在所选提供方上的 pull request 或 merge request,这项工作推迟。它的接入不需要改动本包面向 Session 的界面: + +- **提供方能力**——`ForgeProvider` 增加一个可选方法,例如 `listForViewer(host, filter, signal)`,返回变更请求引用;GitHub 用 `gh search prs --author @me` 或 GraphQL `viewer` 实现,注册表汇总已启用的提供方。 +- **Remote**——一条类似 `follow` 的流,以提供方与筛选条件而不是 Session 为键,通过同一个 `ctx.forge.watch` 轮询。 +- **客户端**——在本包中以 `sidebar.panellist` 条目加一个 `main` 页面实现(与 `ui-schedule` 相同),渲染接收变更请求、其快照与可选操作的 `ChangeRequestCard`。 +- **设置**——在套件的配置页中选择提供方与主机。 + +变更视图目前还没有单个提交与上一轮的范围;两者将是经 `localDiff` 读取的更多 `ForgeLocalScope` 种类。 + +## Alternatives considered + +**按平台使用不同名称。** 在 GitHub 上称 pull request、在 GitLab 上称 merge request,需要每个提供方声明自己的名称,所有文案也要随之变化;而 GitHub 是唯一的提供方,统一的术语让文案保持不变。 + +**通过 `gh pr merge` 合并。** 必需检查尚未完成时它会启用自动合并,基准分支要求合并队列时它会加入队列,因此一次点击可能安排了一次以后的合并而不是立即合并。REST 合并接口要么立即合并,要么拒绝。 + +**合并后删除 head 分支。** Session 的 worktree 可能就在这个分支上。 + +**为 Git 检查单独建一个宿主服务包。** 目前没有第二个使用方需要检出信息;出现模型工具时,可以再把它们移入共享服务。 + +**以宿主进程运行 Git。** 远程与受限的执行环境会显示宿主的文件而不是 Session 的文件,宿主还会读取 Session 无法读取的目录。 + +**勾选时立即创建 worktree。** 每次切换勾选或更换基准都会创建并丢弃一个检出,而且用户决定发送之前 Session 就已经移动。 + +**在创建 Session 的请求中携带该选项。** 这个请求由 Session 控制器拥有,字段是封闭的;插件记录让选项留在本包之内,并且在空白 Session 重新加载后仍然保留。 + +**在面板中切换现有 worktree。** 切换后的 Session 仍保留其项目的沙箱根,因此项目之外的 worktree 依然只读;对话中途移动还会让模型对文件的认识前后割裂。让 Session 从一开始就在自己的 worktree 中运行可以同时避免这两点。 + +**把选项放在输入框工具栏。** 发出第一条消息后工具栏依然存在,而那时该选项已不再适用;新会话那一行在 Session 开始后就会消失。那一行原先只提供单值 slot,因此 `ui-conversation` 增加了列表型 slot `conversation.hero.options`。 + +**在第一轮之内创建 worktree。** 第一个步骤提议运行时这一轮已经开始:上下文按旧目录组装,失败只能结束这一轮并丢弃已认领的消息,而工作目录服务已经把 Session 移出被删除的 worktree。它只作为没有被暂留的消息的兜底。 + +**在 Agent 接纳阶段暂留消息。** `agent-controller/admission` 阶段会在消息进入收件箱之前暂留它,因此 worktree 建好之前消息一直留在输入框里,失败时退回以便编辑。它需要在每个发送方的投递之前加一个控制器服务,单个面板不足以支撑这项核心改动;在收件箱插入时暂留只用现有的收件箱事件与维护任务,并覆盖所有发送方。 + +**把等待中的消息退回输入框。** 宿主接收消息之后,输入框没有办法把它收回,除非为一个插件在会话包里新增公共 API;消息改为在卡片的操作下继续等待。 + +**在 Web 输入框里设发送闸门。** 浏览器在宿主看到消息之前就拦住它,因此每次检查都要一次 Remote 调用,被拒绝消息的阶段只存在于浏览器中,其他发送方也会绕过它。在收件箱插入时暂留,让消息停在它进入 Agent 的位置,并把失败保留在面板状态中。 + +**把创建卡片放进对话记录。** 对话记录的节点不接受插件定义的类型;输入框上方的停靠区是最近的列表型 slot,而且它在新会话页也会渲染。 + +**手动粘贴 URL 跟踪。** 它与 Session 自己的命令记录下的绑定重复。绑定来自 `gh pr create`、操作单个变更请求的 `gh pr` 命令、推送以及检出所跟踪的分支,`track` 保留给“撤销”使用。 + +**在浏览器中解析补丁。** Remote 将传输不受限的补丁文本,浏览器也需要补丁解析器;宿主约束读取并只解析一次。 + +**复用代码审查标签页的文件对比。** 它属于另一个插件,本包在运行时不能导入;把它移入共享基础组件又会为一个使用方改动共享包。变更视图改用共享的高亮器、菜单、提示与 diff 颜色 token 构建。 + +**从本地仓库读取变更请求的 diff。** 当前检出分支可能领先或落后于托管平台,基准分支也可能没有拉取;评审者看到的是托管平台的 diff。视图改为在当前检出分支有超出 head 的提交时给出说明。 + +**在面板里根据失败信息识别 diff 过大。** 失败信息属于提供方及其托管平台,会随它们变化;失败代码让每个提供方报告自己的上限,面板只按代码分支。 + +**直接在卡片上创建变更请求。** 面板需要推送分支并撰写标题与描述;发送消息让 Agent 用自己的工具和用户的指示完成这两件事,绑定变更请求的方式也只保留一种。 + +## Consequences + +面板只在打开时显示当前状态;没有任何界面挂载时检查结束不会有通知。Session 之外的改动要等到刷新或下一个工具结果才会出现。只有一个远端时,每次检查最多在 Session 的执行环境中启动十一个短暂的 Git 进程;为寻找分叉点每多尝试一个远端,就多启动两个。只有在第一步之前提出请求的 Session 才会有 worktree,失败或被取消的尝试可能留下它的分支或检出。Agent 正在运行一轮或其他维护任务时进入的消息不会被暂留,因此它的 worktree 在第一轮之内创建,已删除的 worktree 也不会被检查。消息等待期间,Agent 以空闲状态处于维护任务中:输入框不提供停止,等待 Agent 空闲的调用方也会一并等待。恢复会丢失从未提交的修改。要求合并队列的基准分支无法从面板合并。同时启用 Git Worktrees 时会存在两个互不影响的 worktree 服务;模型的工具看不到面板的服务。本地或变更请求的 diff 超过 `maxDiffBytes`,或被托管平台拒绝时,不显示任何文件。展开变更请求中未变更的行时,每个文件只从托管平台读取一次。 diff --git a/.agents/notes/implemented/feature/2026-10-09-session-change-request-bindings.i18n.yaml b/.agents/notes/implemented/feature/2026-10-09-session-change-request-bindings.i18n.yaml new file mode 100644 index 0000000000..5f8577a2b1 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-10-09-session-change-request-bindings.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-10-09-session-change-request-bindings.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-10-09-session-change-request-bindings.md +/agent-note-session-change-request-bindings: + en: 89ec58adddf53fec + zh: 919e8557d9923fdb +/agent-note-session-change-request-bindings/problem: + en: e736740546fb8219 + zh: 46a16a7a633716ec +/agent-note-session-change-request-bindings/decision: + en: 2bbd058f4edc8bb5 + zh: 5cc12bb0aa0864e2 +/agent-note-session-change-request-bindings/alternatives-considered: + en: 15fdaef4e432fc72 + zh: 8d36c859bd89f403 +/agent-note-session-change-request-bindings/consequences: + en: a2960e50ec52c9b6 + zh: fd23f4fe1137787d diff --git a/.agents/notes/implemented/feature/2026-10-09-session-change-request-bindings.md b/.agents/notes/implemented/feature/2026-10-09-session-change-request-bindings.md new file mode 100644 index 0000000000..3c767fde01 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-10-09-session-change-request-bindings.md @@ -0,0 +1,31 @@ +# Agent Note: Session change request bindings + +Status: implemented + +English | [中文](2026-10-09-session-change-request-bindings.zh.md) + +## Problem + +A Session that opens or updates pull requests leaves them only in its shell output. Neither the user nor later consumers have a list of the change requests the Session works on or a shared view of their status. Code hosts differ in URLs, command-line tools, and status models, and any association must survive fork and resume without changing what the model sees. + +## Decision + +The experimental forge capability seam has three roles. [`dsh-experimental-forge`](../../../../packages/experimental/forge/README.md) defines provider-neutral repository and change request references, a provider registry, and shared polling: watchers of one change request share one loop, each forge has one batched fetch in flight, and the cadence follows check, mergeability, and lifecycle state. [`dsh-experimental-forge-github`](../../../../packages/experimental/forge-github/README.md) is the first provider; it reads GitHub through the GitHub CLI on the host, so credentials stay in that tool's own store and never enter Session execution worlds. [`dsh-experimental-forge-session`](../../../../packages/experimental/forge-session/README.md) binds change requests to Sessions. + +Bindings derive from the Session log instead of a separate store. A Session projection reads `bash` and `pwsh` calls: `gh pr create` results bind the URLs they print, `gh pr view`, `gh pr edit`, and `gh pr checkout` calls bind the change request they act on, and `git push` results record the branches they updated. Those three commands act on one change request, so only the URL given as their first argument and the URL their output prints for that change request count; links in a description name other change requests. Caller actions and discovery results are `plugin:forge-session/binding` records. Because every input is a log event, fork and resume carry bindings without new persistence, and the projection rebuilds them from any checkpoint. The printed URL or ref line is the evidence, not the exit status: an existing pull request reported by a failing `gh pr create` still binds, and a partially rejected push still records the branches it updated. + +After each push result, and when a caller such as the forge panel reports the branch a Session's checkout tracks, the service looks up open change requests for that branch through the provider and records the unbound ones. A dismissed binding stays hidden from discovery and returns only through `track`. Polled status is not logged; it is process-local state that exists while a watcher displays the Session. The projection's client view therefore carries bindings without status. + +## Alternatives considered + +**A host-side binding database.** A separate store would need its own fork, resume, and deletion rules; plugin records and projections already provide them. + +**A model tool that registers each change request.** The association would depend on the model calling an extra tool, and every request would carry its schema. Parsing the commands the model already runs costs no tokens. + +**Logging polled status in the Session.** Status changes independently of the conversation. Logging it would grow the log with every poll and expose stale snapshots to later readers. + +**Running `gh` through the Session's subprocess provider.** Remote and sandboxed execution worlds would need GitHub credentials, and status would depend on the world a Session uses. The host CLI keeps one credential owner. + +## Consequences + +Bindings appear without model cooperation for the common `gh pr create`, `gh pr view`, `gh pr edit`, `gh pr checkout`, and `git push` flows and add no model tokens. Commands run through scripts, aliases, or other command-line tools are not recognized, and a change request opened elsewhere for a branch appears only after another push, another report of that branch, or a `track`. Another provider adds its own access and URL forms behind the same provider contract without changing the binding rules or the polling. The host needs `gh` signed in for each GitHub host. diff --git a/.agents/notes/implemented/feature/2026-10-09-session-change-request-bindings.zh.md b/.agents/notes/implemented/feature/2026-10-09-session-change-request-bindings.zh.md new file mode 100644 index 0000000000..caa153fe79 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-10-09-session-change-request-bindings.zh.md @@ -0,0 +1,31 @@ +# Agent Note: Session 变更请求绑定 + +Status: implemented + +[English](2026-10-09-session-change-request-bindings.md) | 中文 + +## Problem + +打开或更新 pull request 的 Session 只在 shell 输出里留下它们。用户和后续消费方都无法得到该 Session 正在处理的变更请求(change request)列表,也无法共享查看其状态。各代码托管平台的 URL、命令行工具与状态模型各不相同,而这种关联必须在 fork 与恢复后保留,且不能改变模型看到的内容。 + +## Decision + +实验性的 forge 能力 seam 由三个角色组成。[`dsh-experimental-forge`](../../../../packages/experimental/forge/README.zh.md) 定义提供方无关的仓库与变更请求引用、提供方注册表与共享轮询:同一变更请求的监听方共享一个循环,每个 forge 同时只有一次批量读取,轮询节奏随检查、可合并性与生命周期状态变化。[`dsh-experimental-forge-github`](../../../../packages/experimental/forge-github/README.zh.md) 是第一个提供方;它通过宿主上的 GitHub CLI 读取 GitHub,因此凭据保留在该工具自己的存储中,从不进入 Session 的执行环境。[`dsh-experimental-forge-session`](../../../../packages/experimental/forge-session/README.zh.md) 把变更请求绑定到 Session。 + +绑定从 Session 日志派生,而不是存放在单独的存储中。一个 Session 投影读取 `bash` 与 `pwsh` 调用:`gh pr create` 的结果绑定其输出的 URL,`gh pr view`、`gh pr edit` 与 `gh pr checkout` 调用绑定它们所操作的变更请求,`git push` 的结果记录其更新的分支。这三个命令只操作一个变更请求,因此只计入作为第一个参数给出的 URL 和输出为该变更请求打印的 URL;描述正文里的链接指向的是其他变更请求。调用方操作与发现结果是 `plugin:forge-session/binding` 插件记录。由于所有输入都是日志事件,fork 与恢复无需新的持久化即可带上绑定,投影也能从任意检查点重建它们。证据是输出中的 URL 或 ref 行,而不是退出状态:失败的 `gh pr create` 报告的已有 pull request 仍会绑定,部分被拒绝的推送仍会记录它已更新的分支。 + +每次推送结果之后,以及 forge 面板等调用方报告 Session 检出所跟踪的分支时,服务通过提供方为该分支查找打开状态的变更请求,并记录尚未绑定的那些。被隐藏的绑定不会被发现逻辑恢复,只能通过 `track` 恢复。轮询到的状态不写入日志;它是进程内状态,仅在有监听方展示该 Session 时存在。因此投影的客户端视图只带绑定,不带状态。 + +## Alternatives considered + +**宿主侧绑定数据库。** 单独的存储需要自己的 fork、恢复与删除规则;插件记录与投影已经提供了这些规则。 + +**由模型工具登记每个变更请求。** 关联将取决于模型是否调用额外的工具,而且每个请求都要携带该工具的 schema。解析模型本来就会运行的命令不消耗 token。 + +**把轮询状态写入 Session 日志。** 状态的变化独立于对话。写入日志会让日志随每次轮询增长,并把过期快照暴露给后续读取方。 + +**通过 Session 的子进程提供方运行 `gh`。** 远端与沙箱执行环境将需要 GitHub 凭据,状态也会取决于 Session 使用的执行环境。宿主 CLI 让凭据只有一个所有者。 + +## Consequences + +在常见的 `gh pr create`、`gh pr view`、`gh pr edit`、`gh pr checkout` 与 `git push` 流程中,绑定无需模型配合即可出现,且不增加模型 token。通过脚本、别名或其他命令行工具运行的命令无法识别;在别处为某个分支打开的变更请求,要等到下一次推送、再次报告该分支或 `track` 之后才会出现。新的提供方在同一提供方契约之后加入自己的访问方式与 URL 形式,无需改变绑定规则或轮询。宿主需要为每个 GitHub 主机登录 `gh`。 diff --git a/.agents/notes/implemented/feature/2026-10-20-forge-session-list.i18n.yaml b/.agents/notes/implemented/feature/2026-10-20-forge-session-list.i18n.yaml new file mode 100644 index 0000000000..2f6f65eb10 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-10-20-forge-session-list.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/feature/2026-10-20-forge-session-list.md b/.agents/notes/implemented/feature/2026-10-20-forge-session-list.md new file mode 100644 index 0000000000..cec6faa265 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-10-20-forge-session-list.md @@ -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. diff --git a/.agents/notes/implemented/feature/2026-10-20-forge-session-list.zh.md b/.agents/notes/implemented/feature/2026-10-20-forge-session-list.zh.md new file mode 100644 index 0000000000..73bfee90a7 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-10-20-forge-session-list.zh.md @@ -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` 的状态已变化,旧的缓存行会被丢弃。没有会话行显示期间结束的检查不会有通知。 diff --git a/.agents/notes/implemented/process/2026-09-28-dual-release-install-layout-work-budget.i18n.yaml b/.agents/notes/implemented/process/2026-09-28-dual-release-install-layout-work-budget.i18n.yaml index c8ae9617c4..7005f647dc 100644 --- a/.agents/notes/implemented/process/2026-09-28-dual-release-install-layout-work-budget.i18n.yaml +++ b/.agents/notes/implemented/process/2026-09-28-dual-release-install-layout-work-budget.i18n.yaml @@ -9,14 +9,14 @@ en: 46916488210ba493 zh: 98107c3805a22b2c /agent-note-dual-release-install-layout-work-budget/decision: - en: c432b68c4ad1b3c1 - zh: a11e958fa68dc2f3 + en: b900469750b27f81 + zh: d5380d7751332e9c /agent-note-dual-release-install-layout-work-budget/what-the-resolution-costs: - en: 36d3e3ef39cbba2f - zh: 2480acf6260817b3 + en: 463df1fae15ae2fc + zh: 733c9ddb4900e3fc /agent-note-dual-release-install-layout-work-budget/alternatives-considered: en: 29987b7634c84d28 zh: a1469e4b6aaa46e2 /agent-note-dual-release-install-layout-work-budget/consequences: - en: 8dffc0c3fa699980 - zh: 99ec25001e5aa716 + en: 4226e2216abe042d + zh: db8f308ef40f47e9 diff --git a/.agents/notes/implemented/process/2026-09-28-dual-release-install-layout-work-budget.md b/.agents/notes/implemented/process/2026-09-28-dual-release-install-layout-work-budget.md index 24e2ae8fe7..43dd69c9df 100644 --- a/.agents/notes/implemented/process/2026-09-28-dual-release-install-layout-work-budget.md +++ b/.agents/notes/implemented/process/2026-09-28-dual-release-install-layout-work-budget.md @@ -10,14 +10,16 @@ The `Dependency layout` lane packs two incompatible synthetic DSH releases into ## Decision -`scripts/verify-npm-install-layout.ts` decides growth with `assertResolutionWorkBudget`, which bounds `dshPackagesPerVersion * checkedDshEdges` against `MAX_RESOLUTION_WORK_UNITS = 875_000`. Both counts come from the summary `assertDualDshInstallLayout` returns, so the budget applies to the graph npm actually produced rather than to a prediction of it. A graph above the budget fails with its own counts, the budget, and the instruction to measure the new resolution cost and raise the constant. +`scripts/verify-npm-install-layout.ts` decides growth with `assertResolutionWorkBudget`, which bounds `dshPackagesPerVersion * checkedDshEdges` against `MAX_RESOLUTION_WORK_UNITS = 1_134_400`. Both counts come from the summary `assertDualDshInstallLayout` returns, so the budget applies to the graph npm actually produced rather than to a prediction of it. A graph above the budget fails with its own counts, the budget, and the instruction to measure the new resolution cost and raise the constant. -The npm child keeps a wall-clock limit, `NPM_HANG_GUARD_MS`, derived as `4 * MAX_RESOLUTION_WORK_UNITS * SECONDS_PER_WORK_UNIT` (910 s). It is a hang guard, not the growth criterion: a runner up to four times slower than the measured host cannot decide the gate's outcome, and deriving it from the budget keeps the two values from drifting apart. +The npm child keeps a wall-clock limit, `NPM_HANG_GUARD_MS`, derived as `4 * MAX_RESOLUTION_WORK_UNITS * SECONDS_PER_WORK_UNIT` (about 1,180 s). It is a hang guard, not the growth criterion: a runner up to four times slower than the measured host cannot decide the gate's outcome, and deriving it from the budget keeps the two values from drifting apart. This restores the [published dependency faces](2026-08-26-published-dependency-faces.md) decision that `verify-npm-install-layout` does not enforce resolver duration; the 300 s deadline had contradicted it. ## What the resolution costs +Measured 2026-10-26 on an idle Apple M5 Pro with Node 24.17.0 and npm 11.13.0, using `buildDualDshRegistry`, `resolveNpmPackageLock`, and `assertDualDshInstallLayout` sequentially with fresh temporary consumers: the #5960 graph (315 packages per release, 2782 internal edges, 876,330 work units) resolved in 75.27 s; the #5961 graph (320 packages, 2836 edges, 907,520 units) resolved in 71.03 s, with a second verification at 97.73 s. All placements passed, shared one Cordis installation, and requested no archives. The measurements calibrate graph admission and do not establish a runtime improvement. The current budget is 1.25 times its 907,520 units; the earlier, slower per-unit timing remains the conservative hang-guard calibration. + Measured 2026-09-28 on an idle M-series host, against `origin/master` (277 DSH packages per release, 2524 internal edges), by running the lane's own npm invocation with `--timing` and `--cpu-prof`: | Phase | Cost | @@ -47,4 +49,4 @@ Repeated runs of the same graph on one host span 137.8 s to 204.8 s of npm time, ## Consequences -The lane's failure now depends on the graph, so a pull request that adds a few DSH packages passes on any runner, and a graph that grows the packages-times-edges product by more than about a quarter — about 12% in packages and internal edges together — fails deterministically with its measured counts. The budget is a ratchet: raising it is a deliberate edit that must come with a new resolution measurement, and the constant's comment records the counts it was derived from. A graph inside the budget still spends its full resolution time, because the change moves the decision and not the work; nothing here reduces the 153 s npm needs to place the dual release. A genuine npm hang now consumes up to 910 s of the lane before failing, where the old deadline capped it at 300 s. Growth that changes the shape of the graph without changing packages times edges stays inside the budget and is caught only if the hang guard expires. +The lane's failure now depends on the graph, so a pull request that adds a few DSH packages passes on any runner, and a graph that grows the packages-times-edges product by more than about a quarter — about 12% in packages and internal edges together — fails deterministically with its measured counts. The budget is a ratchet: raising it is a deliberate edit that must come with a new resolution measurement, and the constant's comment records the counts it was derived from. A graph inside the budget still spends its full resolution time, because the change moves the decision and not the work; nothing here reduces the 153 s npm needs to place the dual release. A genuine npm hang now consumes up to about 1,180 s of the lane before failing, where the old deadline capped it at 300 s. Growth that changes the shape of the graph without changing packages times edges stays inside the budget and is caught only if the hang guard expires. diff --git a/.agents/notes/implemented/process/2026-09-28-dual-release-install-layout-work-budget.zh.md b/.agents/notes/implemented/process/2026-09-28-dual-release-install-layout-work-budget.zh.md index 03380bca7a..07c58d75e1 100644 --- a/.agents/notes/implemented/process/2026-09-28-dual-release-install-layout-work-budget.zh.md +++ b/.agents/notes/implemented/process/2026-09-28-dual-release-install-layout-work-budget.zh.md @@ -10,14 +10,16 @@ Status: implemented ## Decision -`scripts/verify-npm-install-layout.ts` 用 `assertResolutionWorkBudget` 判定规模增长,它以 `dshPackagesPerVersion * checkedDshEdges` 为工作量单位,与 `MAX_RESOLUTION_WORK_UNITS = 875_000` 比较。两个计数都来自 `assertDualDshInstallLayout` 返回的汇总,因此预算作用于 npm 实际产出的图,而不是对图的预测。超过预算的图会带着自己的计数、预算值,以及「测量新的解析开销并调高该常量」的指示失败。 +`scripts/verify-npm-install-layout.ts` 用 `assertResolutionWorkBudget` 判定规模增长,它以 `dshPackagesPerVersion * checkedDshEdges` 为工作量单位,与 `MAX_RESOLUTION_WORK_UNITS = 1_134_400` 比较。两个计数都来自 `assertDualDshInstallLayout` 返回的汇总,因此预算作用于 npm 实际产出的图,而不是对图的预测。超过预算的图会带着自己的计数、预算值,以及「测量新的解析开销并调高该常量」的指示失败。 -npm 子进程仍保留墙钟上限 `NPM_HANG_GUARD_MS`,由 `4 * MAX_RESOLUTION_WORK_UNITS * SECONDS_PER_WORK_UNIT` 推导(910 秒)。它是挂起保护,不是增长判据:比实测主机慢四倍以内的 runner 无法决定该门禁的结果,而由预算推导可避免两个数值彼此漂移。 +npm 子进程仍保留墙钟上限 `NPM_HANG_GUARD_MS`,由 `4 * MAX_RESOLUTION_WORK_UNITS * SECONDS_PER_WORK_UNIT` 推导(约 1,180 秒)。它是挂起保护,不是增长判据:比实测主机慢四倍以内的 runner 无法决定该门禁的结果,而由预算推导可避免两个数值彼此漂移。 这恢复了[发布依赖门面](2026-08-26-published-dependency-faces.zh.md)中「`verify-npm-install-layout` 不限制 resolver 耗时」的决定;300 秒期限与该决定相矛盾。 ## What the resolution costs +2026-10-26 在空闲 Apple M5 Pro 上使用 Node 24.17.0 与 npm 11.13.0,通过 `buildDualDshRegistry`、`resolveNpmPackageLock` 和 `assertDualDshInstallLayout`,对独立的新建临时消费者目录依次测量:#5960 的依赖图(每个发布 315 个包、2782 条内部边、876,330 个工作量单位)用 75.27 秒解析;#5961 的依赖图(320 个包、2836 条边、907,520 个工作量单位)用 71.03 秒解析,第二次验证耗时 97.73 秒。所有布局校验均通过,共享一个 Cordis 安装,未请求包归档。这些测量用于校准依赖图的准入规模,不代表运行时性能提升。当前预算为其 907,520 个工作量单位的 1.25 倍;挂起保护仍采用此前较慢的单位工作量耗时作为保守校准。 + 2026-09-28 在空闲的 M 系列主机上,针对 `origin/master`(每个发布 277 个 DSH 包、2524 条内部边),用 `--timing` 和 `--cpu-prof` 运行 lane 自身的 npm 调用测得: | 阶段 | 开销 | @@ -47,4 +49,4 @@ npm 子进程仍保留墙钟上限 `NPM_HANG_GUARD_MS`,由 `4 * MAX_RESOLUTION ## Consequences -lane 的失败判定如今取决于依赖图,因此新增少量 DSH 包的 pull request 在任何 runner 上都能通过,而「包数乘边数」增长超过约四分之一的图——即包数与内部边数同步增长约 12%——会带着实测计数确定性地失败。预算是棘轮:调高它是一次刻意修改,必须附带新的解析实测,该常量注释里记录了推导所用的计数。预算内的图仍会花掉完整的解析时间,因为这次改动移动的是判定位置而不是工作本身;这里没有任何东西能减少 npm 摆放双发布所需的 153 s。真正挂起的 npm 现在会消耗该 lane 最多 910 秒才失败,而旧期限把它限制在 300 秒。若图的增长改变了形状却没有改变「包数乘边数」,它仍留在预算内,只有在挂起保护到期时才被发现。 +lane 的失败判定如今取决于依赖图,因此新增少量 DSH 包的 pull request 在任何 runner 上都能通过,而「包数乘边数」增长超过约四分之一的图——即包数与内部边数同步增长约 12%——会带着实测计数确定性地失败。预算是棘轮:调高它是一次刻意修改,必须附带新的解析实测,该常量注释里记录了推导所用的计数。预算内的图仍会花掉完整的解析时间,因为这次改动移动的是判定位置而不是工作本身;这里没有任何东西能减少 npm 摆放双发布所需的 153 s。真正挂起的 npm 现在会消耗该 lane 最多约 1,180 秒才失败,而旧期限把它限制在 300 秒。若图的增长改变了形状却没有改变「包数乘边数」,它仍留在预算内,只有在挂起保护到期时才被发现。 diff --git a/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.i18n.yaml b/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.i18n.yaml index 4d929aec54..0fa00ec1bf 100644 --- a/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.i18n.yaml @@ -21,11 +21,11 @@ en: f07bb6fbbdd01fe4 zh: 9313a0ef2a22af12 /agent-note-linear-credential-matching-and-performance-gates-for-user-text/decision/performance-gate: - en: fcc52be9892b2bfc - zh: ac259aba407efcbc + en: 37faaffb4662fd54 + zh: df0a4bf016393be9 /agent-note-linear-credential-matching-and-performance-gates-for-user-text/alternatives-considered: - en: 168bda1a4e9e0325 - zh: af9cf7b804db84b7 + en: 559a8037caa9e3aa + zh: 4f3719ed09445cc2 /agent-note-linear-credential-matching-and-performance-gates-for-user-text/consequences: en: 8f9d25b72d3e7660 zh: e5c6ae0194905876 diff --git a/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.md b/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.md index e4373eb7dc..c3c361047f 100644 --- a/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.md +++ b/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.md @@ -43,7 +43,7 @@ English | [中文](2026-10-11-user-text-credential-matching.zh.md) | Item | Content | |---|---| | Workloads | Without credentials: ordinary messages, and long prose, code, base64, Markdown, log, JSON, and shell text. With credentials: messages each carrying one kind (model key, GitHub token, cloud key ID, JWT, address password, private-key block), and batches of `.env` keys. Adversarial: repeated input shaped against each format's prefix, length, and separator rules, at two sizes. All generated from constants; credential samples are assembled at run time | -| Timing | Ten unmeasured pairs warm each workload with the same evaluation count as measured pairs. Five measured pairs alternate baseline-first and matcher-first order; each evaluation builds a fresh registry, document, and matcher instance and times evaluation only. A single-document sample averages twenty evaluations, or forty for the half-sized input of a scaling pair, so both sizes process the same total keys or characters per sample; a 5,000-message sample evaluates the batch once. Garbage collection during evaluation remains included. Each side selects its minimum sample mean, with the mean process CPU time from that sample. The worker JSON in CI logs includes every measured pair and averaging count; the HTML report shows the selected with-credential mean. Registry disposal completes outside the timer before the next evaluation, preventing cleanup and retained registries from accumulating. Warm-up retains no matcher memo | +| Timing | Ten unmeasured pairs warm each workload with the same evaluation count as measured pairs. Scaling inputs share one group: every warm-up and measured round evaluates both sizes adjacently and alternates which size goes first, limiting drift between sizes. Five measured pairs alternate baseline-first and matcher-first order; each evaluation builds a fresh registry, document, and matcher instance and times evaluation only. A single-document sample averages twenty evaluations, or forty for the half-sized input of a scaling pair, so both sizes process the same total keys or characters per sample; a 5,000-message sample evaluates the batch once. Garbage collection during evaluation remains included. Each side selects its minimum sample mean, with the mean process CPU time from that sample. The worker JSON in CI logs includes every measured pair and averaging count; the HTML report shows the selected with-credential mean. Registry disposal completes outside the timer before the next evaluation, preventing cleanup and retained registries from accumulating. Warm-up retains no matcher memo | | Allocation | A separate process with `--expose-gc` and a fixed semi-space larger than any evaluation; after warm-up and GC, the matcher alone answers every query once and the young-generation growth is taken; GC event timestamps confirm no GC ran inside the interval | | Assertions | Without credentials: absolute budgets and a cap on the with/without time ratio; with credentials: absolute budgets and the time ratio when the key count doubles; adversarial: absolute budgets and doubling ratios; allocation: a floor plus caps per credential, per line break inside masks, and per masked character | | Excluded | Cold code compilation, React rendering, browser paint and layout; allocation results are asserted and printed to the console, not added to the HTML report | @@ -54,6 +54,7 @@ The reference constants are calibrated for minima of five single evaluations. Mi ## Alternatives considered +- **Finishing one scaling size before measuring the other**: separate phases compare different load and V8 tiering conditions. Identical code measured JWT doubling at 1.91× in one CI run and 2.70× in another; a local Node 24 run also exceeded the unchanged 2.5× limit on a different input. Adjacent, alternating size rounds retain the existing sample counts, minimum-of-five aggregation, GC inclusion, workloads, and limits. A controlled extra evaluation cost on only the 128 KiB input must still fail the doubling assertion. - **Equal evaluation counts at both scaling sizes**: the larger input allocates twice as much per sample, so its fastest sample can include a collection while the smaller input's fastest sample includes none. Equal total input per sample makes the per-evaluation means compare similar allocation volumes without excluding GC or changing the doubling limit. - **Unwarmed, separate baseline and matcher batches**: sub-millisecond baseline measurements can reach optimized code before the matcher’s newly exercised paths do, and separate batches amplify clock-speed drift. Per-workload warm-up, adjacent pairs, and fixed batches compare warmed code while limiting noise in short intervals and preserving fresh documents and matcher state. - **One quantified pattern per format, run from every query**: patterns without a hit scan to the end of the text each time, so cost grows quadratically with credentials, and length quantifiers backtrack on failure. diff --git a/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.zh.md b/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.zh.md index f081970b1c..7541efa2de 100644 --- a/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.zh.md +++ b/.agents/notes/implemented/testing/2026-10-11-user-text-credential-matching.zh.md @@ -43,7 +43,7 @@ Status: implemented | 项 | 内容 | |---|---| | 负载 | 无凭据:普通消息,以及散文、代码、base64、Markdown、日志、JSON、shell 长文本。含凭据:每条消息含一种凭据(模型密钥、GitHub 令牌、云密钥 ID、JWT、地址密码、私钥块),以及成批的 `.env` 密钥。对抗:针对每种格式的前缀、长度与分隔规则构造的重复输入,两种长度。全部由常量生成,凭据样本运行时拼接 | -| 计时 | 每个负载先用十组未计入结果的配对预热,每组的求值次数与测量配对相同。五组测量交替以基线或凭据匹配器开头;每次求值新建注册表、文档和匹配器实例,只计求值。单文档样本取二十次求值的平均值;翻倍比较中的小输入取四十次,使两种大小的每个样本处理相同总量的密钥或字符;5,000 条消息的样本则整批求值一次。求值期间的垃圾回收仍计入耗时。两侧各取样本均值的最小值,并保留对应样本的平均进程 CPU 时间。CI 日志中的 worker JSON 包含全部测量配对和求平均值的次数,HTML 报告展示选中的带凭据匹配器均值。每次求值在计时结束后等待注册表清理完成,再开始下一次,避免清理工作和未释放注册表积累。预热不保留匹配器记忆 | +| 计时 | 每个负载先用十组未计入结果的配对预热,每组的求值次数与测量配对相同。翻倍比较的两个输入属于同一组:每轮预热和测量相邻求值两个尺寸,并交替先测的尺寸,减少尺寸之间的漂移。五组测量交替以基线或凭据匹配器开头;每次求值新建注册表、文档和匹配器实例,只计求值。单文档样本取二十次求值的平均值;翻倍比较中的小输入取四十次,使两种大小的每个样本处理相同总量的密钥或字符;5,000 条消息的样本则整批求值一次。求值期间的垃圾回收仍计入耗时。两侧各取样本均值的最小值,并保留对应样本的平均进程 CPU 时间。CI 日志中的 worker JSON 包含全部测量配对和求平均值的次数,HTML 报告展示选中的带凭据匹配器均值。每次求值在计时结束后等待注册表清理完成,再开始下一次,避免清理工作和未释放注册表积累。预热不保留匹配器记忆 | | 分配 | 单独进程,`--expose-gc`,semi-space 固定为大于任何一次求值的大小;预热后 GC,再单独驱动匹配器回答全部查询一遍,取新生代增量;用 GC 事件的时间戳确认区间内没有 GC | | 断言 | 无凭据:绝对预算,以及"有/无凭据匹配器"的耗时比上限;含凭据:绝对预算,密钥数翻倍时的耗时比;对抗:绝对预算与翻倍耗时比;分配:底噪加每个凭据、每个遮挡内换行和每个遮挡字符的上限 | | 排除 | 冷代码编译、React 渲染、浏览器绘制与布局;分配结果只用于断言并输出到控制台,不进 HTML 报告 | @@ -54,6 +54,7 @@ Status: implemented ## 考虑过的替代方案 +- **测完一个翻倍尺寸后再测另一个**:两个阶段比较的负载和 V8 编译层级状态不同。同一份代码的 JWT 翻倍比在两轮 CI 中分别为 1.91 倍和 2.70 倍;本地 Node 24 也在另一个输入上超过既有 2.5 倍上限。相邻、交替的尺寸轮次保留既有样本数、五组取最小值的汇总方式、GC 计入规则、负载与上限。仅给 128 KiB 输入增加受控求值开销时,翻倍断言仍须失败。 - **两种大小使用相同求值次数**:大输入每个样本的分配量翻倍,因而其最快样本可能包含一次垃圾回收,而小输入的最快样本没有。让每个样本的输入总量相等,使单次求值均值比较相近的分配量,无需排除垃圾回收或修改翻倍上限。 - **不预热、分批测量基线和匹配器**:亚毫秒基线测量可能先进入优化后的代码,而匹配器新触及的路径还未完成优化;分批测量也会放大时钟频率漂移。逐负载预热、相邻配对和固定批次比较预热后的代码,降低短区间测量的噪声,同时保持文档和匹配器状态全新。 - **每次查询对每个格式各跑一个带量词的正则**:没有命中的正则每次都扫到文末,凭据多时平方增长;长度量词在失败时回溯。 diff --git a/.github/AGENTS.md b/.github/AGENTS.md index 9b891d3a6e..bde3142e27 100644 --- a/.github/AGENTS.md +++ b/.github/AGENTS.md @@ -4,6 +4,6 @@ Run jobs on Windows runners (`windows-*` labels) under native `pwsh`. Native Win The manual [Windows packaging workflow](workflows/windows-package.yml) uses the dedicated self-hosted Windows x64 label `dsh-win-package-trial` under native `pwsh`; preserve this routing separately from the automatic CI lanes. -The merge checks run from three workflows named `Policy`, which run on `pull_request_target` so their definitions come from master, and call the `deepseek-harness-check` action directly: `pr-check-on-head-branch.yml` on pull-request updates gives `Policy / Master Change` and `Policy / Pre-Merge Check`, `pr-check-after-approve.yml` on pull-request updates and submitted or dismissed reviews gives `Policy / Approve Check` and `Policy / Reviewer Evaluate`, and `pr-check-on-base-branch.yml` on master pushes re-runs the head-branch checks as `Policy / Recheck`. Branch rules require those check names, so keep the job names and the workflow name. Each check reads its mode from a repository variable: `MERGE_POLICY_MASTER_DIFF`, `MERGE_POLICY_PRE_MERGE`, `MERGE_POLICY_HEAD_DIFF`, and `MERGE_POLICY_REVIEW`, each `off` (skip), `report` (run without failing the job), or `block` (fail the job); a missing value or an unknown one counts as `report`. `Policy / Pre-Merge Check` merges the commit `Policy / Master Change` reports, so it runs only after that job and stays off while `MERGE_POLICY_MASTER_DIFF` is `off`; a merge it cannot do and a build that fails both follow its own mode, so `report` warns and skips the build while `block` fails the job. +The merge checks run from three workflows named `Policy`, which run on `pull_request_target` so their definitions come from master, and call the `deepseek-harness-check` action directly: `pr-check-on-head-branch.yml` on pull-request updates gives `Policy / Master Change` and `Policy / Pre-Merge Check`, `pr-check-after-approve.yml` on pull-request updates and submitted or dismissed reviews gives `Policy / Approve Check` and `Policy / Reviewer Evaluate`, and `pr-check-on-base-branch.yml` on master pushes re-runs the head-branch checks as `Policy / Recheck`. Branch rules require those check names, so keep the job names and the workflow name. Each check reads its mode from a repository variable: `MERGE_POLICY_MASTER_DIFF`, `MERGE_POLICY_PRE_MERGE`, `MERGE_POLICY_HEAD_DIFF`, and `MERGE_POLICY_REVIEW`, each `off` (skip), `report` (run without failing the job), or `block` (fail the job); a missing value or an unknown one counts as `report`. `Policy / Pre-Merge Check` merges the commit `Policy / Master Change` reports, so it runs only after that job and stays off while `MERGE_POLICY_MASTER_DIFF` is `off`; a merge it cannot do and a build that fails both follow its own mode, so `report` warns and skips the build while `block` fails the job. `Policy / Pre-Merge Check` runs `scripts/pre-merge-check.ts` under plain Node and knows nothing else about the merged tree: that script checks the released manifests against the root version, installs, and builds the Host and Client contracts, so change it in one pull request together with the build it orchestrates. The manual Desktop publication workflow selects a successful package run and attempt, then routes each retained build through `DESKTOP_PUBLISH_RUNNERS` to its original machine's unique label. Keep Windows run steps on native PowerShell and Mac run steps on the default POSIX shell; shared entrypoints are Node scripts. Test and production jobs use separate `desktop-test` and `desktop-production` approval Environments after a credential-free preparation job. diff --git a/.github/workflows/macos-intel-package.yml b/.github/workflows/macos-intel-package.yml new file mode 100644 index 0000000000..cc835b8933 --- /dev/null +++ b/.github/workflows/macos-intel-package.yml @@ -0,0 +1,161 @@ +name: Package Desktop · macOS · Intel + +on: + workflow_dispatch: + inputs: + source_ref: + description: '打包源码:test 支持仓库内分支或 dsh-v* tag;production 仅支持 master 或 dsh-v* tag;源码须包含 CI 适配脚本' + type: string + default: master + required: true + preset: + description: '打包环境:选择机器上的 test 或 production 配置;同时决定后续发布环境' + type: choice + options: [test, production] + default: test + required: true + update_channel: + description: '升级通道:nightly 或 latest(须为稳定版本);自动更新不会切换通道' + type: choice + options: [nightly, latest] + default: nightly + required: true + version_mode: + description: '版本模式:default 为 test 自动编号、production 跟随产品;product 保留原版本,auto 自动编号,manual 手填' + type: choice + options: [default, product, auto, manual] + default: default + required: true + build_version: + description: '手动版本号:仅 manual 时填写完整版本;须为产品原版本或受支持的预发布后缀' + type: string + default: '' + required: false + test_release_id: + description: '测试 release ID:32 位小写十六进制随机前缀(非鉴权);留空使用机器默认值,连续升级测试须保持相同值' + type: string + default: '' + required: false + compression_level: + description: 'ZIP 压缩等级:只影响 ZIP 和自动升级下载,不影响 DMG;3 打包较快,7 压缩更强' + type: choice + options: ['3', '7'] + default: '3' + required: true + skip_notarization: + description: '跳过公证和 stapling:仅限内部调试,禁止发布到 test / production 或用于自动更新;需要发布请勿勾选' + type: boolean + default: false + required: true + +permissions: + contents: read + +concurrency: + group: macos-desktop-package + cancel-in-progress: false + +jobs: + package: + runs-on: [self-hosted, macOS, ARM64, dsh-mac-package-trial] + timeout-minutes: 180 + defaults: + run: + shell: bash + env: + DSH_DESKTOP_TARGET_PLATFORM: darwin + DSH_DESKTOP_TARGET_ARCH: x64 + PACKAGE_MACOS_ZIP_COMPRESSION_LEVEL: ${{ inputs.compression_level }} + PACKAGE_SKIP_NOTARIZATION: ${{ inputs.skip_notarization }} + PACKAGE_SOURCE_REF: ${{ inputs.source_ref }} + PACKAGE_VERSION_MODE: ${{ inputs.version_mode }} + DSH_DESKTOP_UPDATE_CHANNEL: ${{ inputs.update_channel }} + DSH_TELEMETRY_DISABLED: '1' + DSH_PACKAGING_PRESET: ${{ inputs.preset }} + PACKAGE_TEST_RELEASE_ID: ${{ inputs.test_release_id }} + steps: + - uses: actions/checkout@v7.0.1 + id: checkout + with: + persist-credentials: false + clean: true + ref: ${{ inputs.source_ref }} + - name: Validate selected source and adapter support + run: >- + node -e "const fs=require('node:fs'); const ref=process.env.PACKAGE_SOURCE_REF; + const cp=require('node:child_process'); const preset=process.env.DSH_PACKAGING_PRESET; + if(!['test','production'].includes(preset)) throw Error('Unknown packaging preset'); + const tag=/^dsh-v[0-9][0-9A-Za-z.-]*$/.test(ref); + if(preset==='production' && ref!=='master' && !tag) throw Error('Production source must be master or dsh-v*'); + if(tag) { + const v=JSON.parse(fs.readFileSync('package.json','utf8')).version; + if(ref!=='dsh-v'+v) throw Error('Source tag must match product version'); + const tagged=cp.execFileSync('git',['rev-parse','--verify','refs/tags/'+ref+'^{commit}'],{encoding:'utf8'}).trim(); + const head=cp.execFileSync('git',['rev-parse','HEAD'],{encoding:'utf8'}).trim(); + if(tagged!==head) throw Error('Checkout does not match source tag'); + } else { + const branch=cp.execFileSync('git',['rev-parse','--abbrev-ref','HEAD'],{encoding:'utf8'}).trim(); + if(branch==='HEAD' || branch!==ref || ref.startsWith('refs/')) throw Error('Select a branch in this repository, not a SHA or PR ref'); + } + if(!fs.readFileSync('apps/desktop/scripts/prepare-macos-ci.mjs','utf8').includes('resolveDesktopBuildTarget')) throw Error('Source lacks Intel macOS CI support; select a newer source'); + if(!fs.readFileSync('apps/desktop/scripts/desktop-ci-version.mjs','utf8').includes('PACKAGE_VERSION_MODE')) throw Error('Source lacks Desktop channel/version CI support; select a newer release');" + - uses: pnpm/action-setup@v5.0.0 + with: + dest: ${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.run_attempt }} + - uses: actions/setup-node@v7.0.0 + with: + node-version: '24' + - name: Configure persistent package caches + env: + DSH_DESKTOP_PACKAGE_CACHE_DIR: ${{ runner.tool_cache }}/dsh-desktop-package-cache + run: | + echo "DSH_DESKTOP_PACKAGE_CACHE_DIR=$DSH_DESKTOP_PACKAGE_CACHE_DIR" >> "$GITHUB_ENV" + echo "PNPM_CONFIG_STORE_DIR=$DSH_DESKTOP_PACKAGE_CACHE_DIR/workspace-pnpm-store" >> "$GITHUB_ENV" + - name: Check Rosetta execution + run: arch -x86_64 /usr/bin/true + - name: Install locked dependencies + run: pnpm install --frozen-lockfile + - name: Validate preset and resolve build version + id: settings + env: + PACKAGE_BUILD_VERSION: ${{ inputs.build_version }} + run: node apps/desktop/scripts/prepare-macos-ci.mjs + - name: Check packaging configuration and toolchain + env: + PACKAGE_VERSION: ${{ steps.settings.outputs.version }} + run: | + args=() + if [[ "$PACKAGE_SKIP_NOTARIZATION" = true ]]; then args+=(--skip-notarization); fi + pnpm run package:desktop:mac:x64 --check --build-version "$PACKAGE_VERSION" "${args[@]}" + - name: Build signed installers + env: + PACKAGE_VERSION: ${{ steps.settings.outputs.version }} + run: | + args=() + if [[ "$PACKAGE_SKIP_NOTARIZATION" = true ]]; then args+=(--skip-notarization); fi + pnpm run package:desktop:mac:x64 --build-version "$PACKAGE_VERSION" "${args[@]}" + - name: Retain complete package outside the checkout + id: retained + env: + PACKAGE_SOURCE_SHA: ${{ steps.checkout.outputs.commit }} + PACKAGE_VERSION: ${{ steps.settings.outputs.version }} + run: node --import tsx apps/desktop/scripts/retain-desktop-ci.ts + - name: Retain packaging diagnostics + if: always() && steps.checkout.outcome == 'success' + uses: actions/upload-artifact@v7.0.1 + with: + name: macos-x64-package-logs-${{ github.run_id }}-${{ github.run_attempt }} + include-hidden-files: true + path: apps/desktop/.desktop-build/packaging-runs/ + if-no-files-found: warn + retention-days: 14 + - name: Retain installers for the selected mode + if: success() + uses: actions/upload-artifact@v7.0.1 + with: + name: macos-x64-${{ steps.settings.outputs.mode }}-${{ steps.settings.outputs.version }}-${{ github.run_id }}-${{ github.run_attempt }} + include-hidden-files: true + path: ${{ steps.retained.outputs.directory }}/* + compression-level: 0 + if-no-files-found: error + retention-days: 14 diff --git a/.github/workflows/macos-package.yml b/.github/workflows/macos-package.yml index eeb451266e..073f1e9541 100644 --- a/.github/workflows/macos-package.yml +++ b/.github/workflows/macos-package.yml @@ -1,35 +1,19 @@ -name: Package Desktop · macOS +name: Package Desktop · macOS · Apple Silicon on: workflow_dispatch: inputs: + source_ref: + description: '打包源码:test 支持仓库内分支或 dsh-v* tag;production 仅支持 master 或 dsh-v* tag;源码须包含 CI 适配脚本' + type: string + default: master + required: true preset: description: '打包环境:选择机器上的 test 或 production 配置;同时决定后续发布环境' type: choice options: [test, production] default: test required: true - zip_compression_level: - description: 'ZIP 压缩等级:只影响 ZIP 和自动升级下载,不影响 DMG;3 打包较快,7 压缩更强' - type: choice - options: ['3', '7'] - default: '3' - required: true - skip_notarization: - description: '跳过公证和 stapling:仅限内部调试,禁止发布到 test / production 或用于自动更新;需要发布请勿勾选' - type: boolean - default: false - required: true - test_release_id: - description: '测试 release ID:32 位小写十六进制随机前缀(非鉴权);留空使用机器默认值,连续升级测试须保持相同值' - type: string - default: '' - required: false - source_ref: - description: '打包源码:test 支持仓库内分支或 dsh-v* tag;production 仅支持 master 或 dsh-v* tag;源码须包含 CI 适配脚本' - type: string - default: master - required: true update_channel: description: '升级通道:nightly 或 latest(须为稳定版本);自动更新不会切换通道' type: choice @@ -47,6 +31,22 @@ on: type: string default: '' required: false + test_release_id: + description: '测试 release ID:32 位小写十六进制随机前缀(非鉴权);留空使用机器默认值,连续升级测试须保持相同值' + type: string + default: '' + required: false + compression_level: + description: 'ZIP 压缩等级:只影响 ZIP 和自动升级下载,不影响 DMG;3 打包较快,7 压缩更强' + type: choice + options: ['3', '7'] + default: '3' + required: true + skip_notarization: + description: '跳过公证和 stapling:仅限内部调试,禁止发布到 test / production 或用于自动更新;需要发布请勿勾选' + type: boolean + default: false + required: true permissions: contents: read @@ -63,7 +63,9 @@ jobs: run: shell: bash env: - PACKAGE_MACOS_ZIP_COMPRESSION_LEVEL: ${{ inputs.zip_compression_level }} + DSH_DESKTOP_TARGET_PLATFORM: darwin + DSH_DESKTOP_TARGET_ARCH: arm64 + PACKAGE_MACOS_ZIP_COMPRESSION_LEVEL: ${{ inputs.compression_level }} PACKAGE_SKIP_NOTARIZATION: ${{ inputs.skip_notarization }} PACKAGE_SOURCE_REF: ${{ inputs.source_ref }} PACKAGE_VERSION_MODE: ${{ inputs.version_mode }} diff --git a/.github/workflows/pr-check-on-head-branch.yml b/.github/workflows/pr-check-on-head-branch.yml index 5ab02a6a94..62d762d72f 100644 --- a/.github/workflows/pr-check-on-head-branch.yml +++ b/.github/workflows/pr-check-on-head-branch.yml @@ -93,15 +93,26 @@ jobs: node-version: '24' package-manager-cache: false - - name: Run the pre-merge command + - name: Run the tree's pre-merge check if: steps.merge.outputs.merged == 'true' env: CHECK_MODE: ${{ vars.MERGE_POLICY_PRE_MERGE }} run: | - if ! bash -euxo pipefail -c 'pnpm install --frozen-lockfile && pnpm exec tsx scripts/compile-referenced-projects.ts host-contracts && pnpm exec tsx scripts/compile-referenced-projects.ts client'; then + # The check names one file and knows nothing else about how the merged tree builds itself. + if [ ! -f scripts/pre-merge-check.ts ]; then if [ "$CHECK_MODE" = block ]; then - echo "::error title=Pre-Merge Check::the merged tree does not install or compile" + echo "::error title=Pre-Merge Check::this tree has no scripts/pre-merge-check.ts, so nothing was installed, compiled, or checked" exit 1 fi - echo "::warning title=Pre-Merge Check::the merged tree does not install or compile; this check does not block" + echo "::warning title=Pre-Merge Check::this tree has no scripts/pre-merge-check.ts, so nothing was installed, compiled, or checked" + exit 0 fi + if node scripts/pre-merge-check.ts; then + exit 0 + fi + # A check that crashes reports nothing, so the step reports its exit the way the mode asks. + if [ "$CHECK_MODE" = block ]; then + echo "::error title=Pre-Merge Check::scripts/pre-merge-check.ts exited without reporting, so the merged tree is unchecked" + exit 1 + fi + echo "::warning title=Pre-Merge Check::scripts/pre-merge-check.ts exited without reporting, so the merged tree is unchecked" diff --git a/.github/workflows/windows-package.yml b/.github/workflows/windows-package.yml index c38fd7d5a0..cbb4fd2642 100644 --- a/.github/workflows/windows-package.yml +++ b/.github/workflows/windows-package.yml @@ -1,30 +1,19 @@ -name: Package Desktop · Windows +name: Package Desktop · Windows · x64 on: workflow_dispatch: inputs: + source_ref: + description: '打包源码:test 支持仓库内分支或 dsh-v* tag;production 仅支持 master 或 dsh-v* tag;源码须包含 CI 适配脚本' + type: string + default: master + required: true preset: description: '打包环境:选择机器上的 test 或 production 配置;同时决定后续发布环境' type: choice options: [test, production] default: test required: true - signing: - description: '签名方式:unsigned 仅限内部调试,禁止发布到 test / production 或用于自动更新;需要发布请选择 signed(EV 硬件令牌)' - type: choice - options: [signed, unsigned] - default: signed - required: true - test_release_id: - description: '测试 release ID:32 位小写十六进制随机前缀(非鉴权);留空使用机器默认值,连续升级测试须保持相同值' - type: string - default: '' - required: false - source_ref: - description: '打包源码:test 支持仓库内分支或 dsh-v* tag;production 仅支持 master 或 dsh-v* tag;源码须包含 CI 适配脚本' - type: string - default: master - required: true update_channel: description: '升级通道:nightly 或 latest(须为稳定版本);自动更新不会切换通道' type: choice @@ -42,6 +31,23 @@ on: type: string default: '' required: false + test_release_id: + description: '测试 release ID:32 位小写十六进制随机前缀(非鉴权);留空使用机器默认值,连续升级测试须保持相同值' + type: string + default: '' + required: false + compression_level: + description: '安装器压缩等级:3 打包较快,7 压缩更强;覆盖本轮机器配置' + type: choice + options: ['3', '7'] + default: '3' + required: true + signing: + description: '签名方式:unsigned 仅限内部调试,禁止发布到 test / production 或用于自动更新;需要发布请选择 signed(EV 硬件令牌)' + type: choice + options: [signed, unsigned] + default: signed + required: true cache_namespace: description: '缓存测量命名空间:留空使用共享缓存;新名称用于冷缓存,再次使用同名测热缓存,不清除现有缓存' type: string @@ -52,12 +58,6 @@ on: type: string default: '' required: false - compression_level: - description: '安装器压缩等级:3 打包较快,7 压缩更强;覆盖本轮机器配置' - type: choice - options: ['3', '7'] - default: '3' - required: true permissions: contents: read diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 1295efff31..a1a13b2200 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -134,6 +134,7 @@ External packages installed for runtime use or embedded in distributed Host or b | [`semver`](https://github.com/npm/node-semver) | ISC | | [`serve-static`](https://github.com/expressjs/serve-static) | MIT | | [`sharp`](https://github.com/lovell/sharp) | Apache-2.0 | +| [`shell-quote`](http://github.com/ljharb/shell-quote) | MIT | | [`sherpa-onnx-node`](https://github.com/csukuangfj/sherpa-onnx) | Apache-2.0 | | [`shiki`](https://github.com/shikijs/shiki) | MIT | | [`simple-icons`](https://github.com/simple-icons/simple-icons) | CC0-1.0 | @@ -163,6 +164,8 @@ pnpm applies local patches to the following packages at install time, so shipped - `app-builder-lib@26.17.0` — [`patches/app-builder-lib@26.17.0.patch`](patches/app-builder-lib@26.17.0.patch) - `exceljs@4.4.0` — [`patches/exceljs@4.4.0.patch`](patches/exceljs@4.4.0.patch) - `lexical@0.49.0` — [`patches/lexical@0.49.0.patch`](patches/lexical@0.49.0.patch) +- `mdast-util-gfm-autolink-literal@2.0.1` — [`patches/mdast-util-gfm-autolink-literal@2.0.1.patch`](patches/mdast-util-gfm-autolink-literal@2.0.1.patch) +- `micromark-extension-gfm-autolink-literal@2.1.0` — [`patches/micromark-extension-gfm-autolink-literal@2.1.0.patch`](patches/micromark-extension-gfm-autolink-literal@2.1.0.patch) - `node-pty@1.2.0-beta.15` — [`patches/node-pty@1.2.0-beta.15.patch`](patches/node-pty@1.2.0-beta.15.patch) ## Chrome DevTools frontend @@ -267,6 +270,7 @@ External packages **directly declared** for development, tests, types, or toolin | [`selfsigned`](https://github.com/jfromaniello/selfsigned) | MIT | | [`smol-toml`](https://github.com/squirrelchat/smol-toml) | BSD-3-Clause | | [`spdx-expression-parse`](https://github.com/jslicense/spdx-expression-parse.js) | MIT | +| [`tencentcloud-sdk-nodejs-teo`](https://github.com/tencentcloud/tencentcloud-sdk-nodejs) | Apache-2.0 | | [`tsdown`](https://github.com/rolldown/tsdown) | MIT | | [`typescript-language-server`](https://github.com/typescript-language-server/typescript-language-server) | Apache-2.0 | | [`vite`](https://github.com/vitejs/vite) | MIT | diff --git a/apps/cli/package.json b/apps/cli/package.json index 38bfaee46a..5d3533dab6 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -108,7 +108,8 @@ "@deepseek-ai/dsh-experimental-tool-worktree": "workspace:*", "@deepseek-ai/dsh-cua-bundle": "workspace:*", "@deepseek-ai/dsh-experimental-plugin-market-bundle": "workspace:*", - "@deepseek-ai/dsh-personalized-recommendations": "workspace:*" + "@deepseek-ai/dsh-personalized-recommendations": "workspace:*", + "@deepseek-ai/dsh-experimental-forge-bundle": "workspace:*" }, "devDependencies": { "@deepseek-ai/dsh-skill-badge": "workspace:*", diff --git a/apps/desktop/.env.macos.example b/apps/desktop/.env.macos.example index cc3d21f3e8..7d2336d546 100644 --- a/apps/desktop/.env.macos.example +++ b/apps/desktop/.env.macos.example @@ -56,3 +56,7 @@ APPLE_API_ISSUER= # DOWNLOAD_PROD_COS_BUCKET= # DOWNLOAD_PROD_COS_SECRET_ID= # DOWNLOAD_PROD_COS_SECRET_KEY= + +# Production fixed downloads require teo:CreatePurgeTask permission on the COS identity. +# Required for upload:latest:* and purge:latest; configure the download domain's EO zone. +# DOWNLOAD_PROD_EO_ZONE_ID= diff --git a/apps/desktop/.env.windows.example b/apps/desktop/.env.windows.example index 9d1a976d5d..fd45f0cb32 100644 --- a/apps/desktop/.env.windows.example +++ b/apps/desktop/.env.windows.example @@ -41,3 +41,7 @@ DSH_DESKTOP_WINDOWS_COMPRESSION_LEVEL=3 # DOWNLOAD_PROD_COS_BUCKET= # DOWNLOAD_PROD_COS_SECRET_ID= # DOWNLOAD_PROD_COS_SECRET_KEY= + +# Production fixed downloads require teo:CreatePurgeTask permission on the COS identity. +# Required for upload:latest:* and purge:latest; configure the download domain's EO zone. +# DOWNLOAD_PROD_EO_ZONE_ID= diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 271ba5a7b8..bf39dde903 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -43,7 +43,8 @@ "upload:win:x64": "tsx scripts/upload-target.ts win-x64", "upload:latest:mac:arm64": "tsx scripts/upload-target.ts mac-arm64 --latest", "upload:latest:mac:x64": "tsx scripts/upload-target.ts mac-x64 --latest", - "upload:latest:win:x64": "tsx scripts/upload-target.ts win-x64 --latest" + "upload:latest:win:x64": "tsx scripts/upload-target.ts win-x64 --latest", + "purge:latest": "tsx scripts/upload-target.ts --latest --purge-only" }, "dependencies": { "electron-updater": "^6.8.9", @@ -86,6 +87,7 @@ "@deepseek-ai/dsh-client-product-analytics": "workspace:*", "@deepseek-ai/dsh-plugin-manager": "workspace:*", "zod": "4.4.3", - "@deepseek-ai/dsh-util-file-drag": "workspace:*" + "@deepseek-ai/dsh-util-file-drag": "workspace:*", + "tencentcloud-sdk-nodejs-teo": "4.1.330" } } diff --git a/apps/desktop/packaging/README.i18n.yaml b/apps/desktop/packaging/README.i18n.yaml index 7a9ff55492..b98c3d075f 100644 --- a/apps/desktop/packaging/README.i18n.yaml +++ b/apps/desktop/packaging/README.i18n.yaml @@ -24,11 +24,11 @@ en: 5b4f0f80a117f202 zh: ff05be196c181087 /desktop-packaging/windows-actions-packaging: - en: 364223725374d9ac - zh: c4d4cc58d7cbafbd + en: 9dd2227f2878519e + zh: cd95dd6efd48298f /desktop-packaging/macos-self-hosted-packaging: - en: bb1dcdfef3f3a113 - zh: 9c151800a61e2505 + en: ee628e830d849ae9 + zh: 44313f5fb842f042 /desktop-packaging/unsigned-windows-test-installer: en: f429bc0c280e2dfa zh: 4adbcc9e906bdf80 diff --git a/apps/desktop/packaging/README.md b/apps/desktop/packaging/README.md index 14c1eac90b..83a0e5a77b 100644 --- a/apps/desktop/packaging/README.md +++ b/apps/desktop/packaging/README.md @@ -109,7 +109,7 @@ Each target owns its packed package inputs, prepared runtime, package set, dsh t For supervised runner startup and recovery, follow [Windows runner machine setup](../windows-signing/README.md#machine-setup). -The `Package Desktop · Windows` [manual workflow](../../../.github/workflows/windows-package.yml) selects the dedicated Windows x64 runner label `dsh-win-package-trial` and the machine preset `test` or `production`. Choose `signed` (default) or `unsigned`; signed mode uses the existing EV token preflight, signature cache and failure interlock. It does not publish or upload a release. `build_version` defaults to `auto`, using the CI identifier in [Release versions](#release-versions); an explicit value may be the checkout's product version or an extended build version such as `0.2.1-alpha.1.20261009.1` for product `0.2.1-alpha.1`. The version prefix must match the selected checkout; validation errors show its current example. Manifests remain unchanged. +The `Package Desktop · Windows · x64` [manual workflow](../../../.github/workflows/windows-package.yml) selects the dedicated Windows x64 runner label `dsh-win-package-trial` and the machine preset `test` or `production`. Choose `signed` (default) or `unsigned`; signed mode uses the existing EV token preflight, signature cache and failure interlock. It does not publish or upload a release. `build_version` defaults to `auto`, using the CI identifier in [Release versions](#release-versions); an explicit value may be the checkout's product version or an extended build version such as `0.2.1-alpha.1.20261009.1` for product `0.2.1-alpha.1`. The version prefix must match the selected checkout; validation errors show its current example. Manifests remain unchanged. Select the workflow in Actions, choose the source branch, preset, signing mode and version, then click Run workflow. Before merging input changes, test the pushed branch with `gh workflow run windows-package.yml --ref -f preset=test -f signing=unsigned -f build_version=auto`; verify the web form after merging. There is no push or PR trigger. Logs and successful EXEs are retained for 14 days. Inspect the retained logs and installer before relying on packaging; installation is separate. Before checkout, Node removes the runner-owned source directory without following dependency junctions; the workflow validates its location and rejects linked ancestors. Never point it at a development checkout. Runs share one concurrency group without cancelling an active build; only one run remains pending, and a newer dispatch replaces it. Diagnostics upload requires a successful clean checkout. Machine settings stay outside the checkout and are never uploaded as artifacts. @@ -127,7 +127,9 @@ Installer uploads store the already-compressed release files without ZIP recompr ## macOS self-hosted packaging -The `Package Desktop · macOS` [manual workflow](../../../.github/workflows/macos-package.yml) targets an Apple Silicon runner labeled `dsh-mac-package-trial`. It selects `test` or `production`, resolves `build_version=auto` from the UTC date, run number and attempt, and uses the existing signing, notarization and packaged-runtime checks. Installer upload disables redundant ZIP compression; diagnostic logs retain normal compression. Successful DMG/ZIP files and diagnostic logs are retained for 14 days; this workflow does not publish a release. The operator must register the runner and verify a complete Actions run before relying on it. The `zip_compression_level` dropdown defaults to `3` and also offers `7`; it overrides the machine preset for this run and affects only the ZIP and automatic-update downloads, not the DMG. +The [Apple Silicon workflow](../../../.github/workflows/macos-package.yml) and [Intel workflow](../../../.github/workflows/macos-intel-package.yml) produce separate arm64 and x64 packages on the Apple Silicon runner labeled `dsh-mac-package-trial`. Both share one concurrency group. The Intel workflow checks Rosetta execution before dependency installation; its selected source must include the target-aware macOS CI adapter. Both select `test` or `production` and use the existing signing, notarization and packaged-runtime checks. Successful DMG/ZIP files and diagnostic logs are retained for 14 days without publishing a release. Installer uploads disable redundant ZIP compression. The operator must verify a complete Actions run and installation on the target architecture before release qualification. The `compression_level` dropdown defaults to `3` and also offers `7`; it overrides the machine preset for this run and affects only ZIP and automatic-update downloads, not DMG. + +All three platform forms put source, environment, update channel, version mode, manual version, test release ID and compression level first, with matching defaults. macOS notarization and Windows signing, cache measurement and isolated-workspace options follow these common fields. Run `node apps/desktop/scripts/setup-macos-packaging.mjs "$HOME/dsh-ci"` to prepare private `runner/`, `packaging/{presets,secrets,certificates}` and `cache/macos-signatures` directories without overwriting existing files; append `--check` to inspect them. Fill the [preset](../config/macos-packaging/test.env.example) and [signing](../config/macos-packaging/macos-signing.env.example) templates outside the checkout. Set secret and certificate files to mode `0600`. `DSH_PACKAGING_CONFIG_DIR` selects the absolute `packaging` directory and `DSH_PACKAGING_PRESET` selects its preset; missing files fail without falling back to `.env.macos`. Signed and preparation-only Mac builds read `secrets/macos-signing.env`; relative credential paths resolve beside that file. Public presets reject signing credentials, and signing files cannot override public settings. Local unsigned Mac builds retain their configuration-free behavior. diff --git a/apps/desktop/packaging/README.zh.md b/apps/desktop/packaging/README.zh.md index 0f059c74a4..3868508c9e 100644 --- a/apps/desktop/packaging/README.zh.md +++ b/apps/desktop/packaging/README.zh.md @@ -109,7 +109,7 @@ macOS 正式发布和本地未签名 DMG 都通过 [electron-builder 配置](../ runner 的受监督启动和恢复见 [Windows runner 机器准备](../windows-signing/README.zh.md#machine-setup)。 -`Package Desktop · Windows` [手动工作流](../../../.github/workflows/windows-package.yml) 选择专用 Windows x64 runner 标签 `dsh-win-package-trial`,并使用机器 preset `test` 或 `production`。选择 `signed`(默认)或 `unsigned`;签名模式复用现有 EV token 预检、签名缓存与失败 interlock,不发布或上传 release。`build_version` 默认为 `auto`,使用[发布版本](#release-versions)中的 CI 标识;显式值可以是源码产品版本,或扩展构建版本,例如产品 `0.2.1-alpha.1` 对应 `0.2.1-alpha.1.20261009.1`。版本前缀必须匹配所选源码;校验错误显示其当前示例。manifest 保持不变。 +`Package Desktop · Windows · x64` [手动工作流](../../../.github/workflows/windows-package.yml) 选择专用 Windows x64 runner 标签 `dsh-win-package-trial`,并使用机器 preset `test` 或 `production`。选择 `signed`(默认)或 `unsigned`;签名模式复用现有 EV token 预检、签名缓存与失败 interlock,不发布或上传 release。`build_version` 默认为 `auto`,使用[发布版本](#release-versions)中的 CI 标识;显式值可以是源码产品版本,或扩展构建版本,例如产品 `0.2.1-alpha.1` 对应 `0.2.1-alpha.1.20261009.1`。版本前缀必须匹配所选源码;校验错误显示其当前示例。manifest 保持不变。 在 Actions 选择工作流、源码分支、preset、签名模式和版本,再点击 Run workflow。输入变更合并前,用 `gh workflow run windows-package.yml --ref -f preset=test -f signing=unsigned -f build_version=auto` 测试已推送分支;合并后验证网页表单。它不监听 push 或 PR。日志和成功生成的 EXE 保留 14 天。依赖打包流程前检查日志和安装包;安装是独立操作。检出前,Node 删除 runner 专用源码目录,不跟随依赖 junction;工作流校验目标位置并拒绝链接祖先目录。不得指向日常开发目录。运行共享一个并发组,不取消正在进行的构建;最多保留一个待运行任务,新触发会替换它。仅在成功完成干净检出后上传诊断。机器配置位于 checkout 之外,不作为产物上传。 @@ -127,7 +127,9 @@ runner 的受监督启动和恢复见 [Windows runner 机器准备](../windows-s ## macOS 自托管打包 -`Package Desktop · macOS` [手动工作流](../../../.github/workflows/macos-package.yml) 使用带有 `dsh-mac-package-trial` 标签的 Apple Silicon runner。它选择 `test` 或 `production`,以 UTC 日期、运行编号和重试次数解析 `build_version=auto`,并复用现有签名、公证和打包运行时检查。安装包上传关闭重复 ZIP 压缩;诊断日志保留普通压缩。成功的 DMG/ZIP 与诊断日志保留 14 天;工作流不发布 release。操作人员须注册 runner,并验收完整 Actions 运行后再依赖该流程。 `zip_compression_level` 下拉选项默认为 `3`,也可选择 `7`;本次运行以该选项覆盖机器预设,只影响 ZIP 和自动升级下载,不影响 DMG。 +[Apple Silicon 工作流](../../../.github/workflows/macos-package.yml)与 [Intel 工作流](../../../.github/workflows/macos-intel-package.yml)在带有 `dsh-mac-package-trial` 标签的 Apple Silicon runner 上分别生成 arm64 和 x64 安装包,两者共用一个并发组。Intel 工作流在安装依赖前检查 Rosetta 能否执行;所选源码必须包含按目标架构校验的 macOS CI 适配脚本。两者均选择 `test` 或 `production`,并复用现有签名、公证和打包运行时检查。成功的 DMG/ZIP 与诊断日志保留 14 天,不发布 release;安装包上传关闭重复 ZIP 压缩。操作人员须验收完整 Actions 运行及目标架构上的安装,才能确认可交付。`compression_level` 下拉选项默认为 `3`,也可选择 `7`;本次运行以该选项覆盖机器预设,只影响 ZIP 和自动升级下载,不影响 DMG。 + +三个平台的表单均依次展示源码、环境、升级通道、版本模式、手动版本号、测试 release ID 和压缩等级,默认值一致。macOS 公证选项和 Windows 签名、缓存测量、独立构建目录选项位于公共字段之后。 运行 `node apps/desktop/scripts/setup-macos-packaging.mjs "$HOME/dsh-ci"` 创建私有 `runner/`、`packaging/{presets,secrets,certificates}` 和 `cache/macos-signatures` 目录,不覆盖已有文件;追加 `--check` 仅检查。在 checkout 外填写 [preset](../config/macos-packaging/test.env.example) 和[签名](../config/macos-packaging/macos-signing.env.example)模板,将秘密和证书文件权限设为 `0600`。`DSH_PACKAGING_CONFIG_DIR` 指定 `packaging` 的绝对目录,`DSH_PACKAGING_PRESET` 选择其中的 preset;缺少文件立即失败,不回退到 `.env.macos`。Mac 签名和仅准备构建读取 `secrets/macos-signing.env`,相对凭据路径以该文件所在目录为基准。公开 preset 拒绝签名凭据,签名文件不能覆盖公开配置。本地未签名 Mac 构建保留无需配置的行为。 diff --git a/apps/desktop/publishing/README.i18n.yaml b/apps/desktop/publishing/README.i18n.yaml index 9483eaff54..99b4b44aef 100644 --- a/apps/desktop/publishing/README.i18n.yaml +++ b/apps/desktop/publishing/README.i18n.yaml @@ -18,17 +18,17 @@ en: b407de63118c9bf9 zh: de19f1ba9feacbbf /desktop-publication/deployment-and-test-release-identity: - en: 405588e021bcfb60 - zh: d9f9700b0b6db6f8 + en: eed7c4ed0d4e2758 + zh: 026fe6076e11018e /desktop-publication/publish-installers-and-update-feeds: en: 3c3e3a09573c4386 zh: c851ae6a94f2284a /desktop-publication/fixed-installer-downloads: - en: 38054a5f89590cfa - zh: 9e115f9bdeeab213 + en: ce8296f8f1ee881e + zh: 749ab63bd6ed4248 /desktop-publication/upload-credentials: - en: 1c5689525753b08d - zh: 93089de765806733 + en: 47442fb912fa4d5d + zh: a21a0d67cd01724b /desktop-publication/upload-audit-and-recovery: en: ec85f43ece09a5e3 zh: 2de5bf801c9c9e39 diff --git a/apps/desktop/publishing/README.md b/apps/desktop/publishing/README.md index 71c52c1d75..b9bb98af2b 100644 --- a/apps/desktop/publishing/README.md +++ b/apps/desktop/publishing/README.md @@ -33,7 +33,7 @@ Publication requires completed release artifacts and explicit operator authoriza `DSH_DESKTOP_AUTO_UPDATE_ENV` selects `test` or `production` for both the URL embedded during packaging and the later COS upload; an absent value selects `test`. Test packaging requires its HTTPS origin in `DOWNLOAD_TEST_ORIGIN`; production uses `https://download.deepseek.com`. Upload requires the selected bucket in `DOWNLOAD_TEST_COS_BUCKET` or `DOWNLOAD_PROD_COS_BUCKET`. Production feeds use `dsh-desk/feeds//` and binaries use `dsh-desk/bin//`. Test releases require `DOWNLOAD_TEST_RELEASE_ID`: 32 lowercase hexadecimal characters inserted as `dsh-desk//feeds//` and `dsh-desk//bin//`. YAML references, stable-channel aliases, and blockmaps stay inside that release directory. Targets are `mac-arm64`, `mac-x64`, and `win-x64`. -The update destination and upload credentials follow the selected deployment: +The update destination and upload credentials follow the selected deployment; production fixed downloads also require the [EdgeOne settings](#fixed-downloads): | Environment | Public origin | COS bucket | COS credentials | |---|---|---|---| @@ -69,13 +69,17 @@ Windows NSIS uploads require the generated, nonempty `.exe.blockmap` beside the ## Fixed installer downloads -Fixed installer downloads use `pnpm run upload:latest:mac:arm64`, `pnpm run upload:latest:mac:x64`, or `pnpm run upload:latest:win:x64` from the repository root. They upload the completed target's DMG or EXE to `desktop/dsh-latest-macos-arm64.dmg`, `desktop/dsh-latest-macos-x64.dmg`, or `desktop/dsh-latest-windows-x64.exe`. Production URLs start with `https://download.deepseek.com/`; test URLs use `DOWNLOAD_TEST_ORIGIN`. Both deployments reuse the target dotenv's deployment, bucket, and credentials, all normal release validation, and the upload audit below. Each command replaces one fixed object without publishing feeds, blockmaps, or release tags. Test fixed downloads are shared across release IDs in the test bucket; the packaged update URL still requires its original release ID. Publication is explicit, including prerelease versions. Deployment infrastructure must configure revalidation or purge cached fixed downloads after replacement. Local loopback tests cover the upload; release operators own real COS and CDN acceptance. +Fixed installer downloads use `pnpm run upload:latest:mac:arm64`, `pnpm run upload:latest:mac:x64`, or `pnpm run upload:latest:win:x64` from the repository root. They upload the completed target's DMG or EXE to `desktop/dsh-latest-macos-arm64.dmg`, `desktop/dsh-latest-macos-x64.dmg`, or `desktop/dsh-latest-windows-x64.exe`. Production URLs start with `https://download.deepseek.com/`; test URLs use `DOWNLOAD_TEST_ORIGIN`. Both deployments reuse the target dotenv's deployment, bucket, and credentials, all normal release validation, and the upload audit below. Each command replaces one fixed object without publishing feeds, blockmaps, or release tags. Test fixed downloads are shared across release IDs in the test bucket; the packaged update URL still requires its original release ID. Publication is explicit, including prerelease versions. Production commands then submit one EdgeOne URL purge and report its task ID; they do not query or wait for cache completion. Test uploads retain deployment-managed cache policy. Local loopback tests cover the upload; release operators own real COS and CDN acceptance. + +Production fixed downloads require `DOWNLOAD_PROD_EO_ZONE_ID` in the target dotenv and `teo:CreatePurgeTask` permission for that zone on the existing `DOWNLOAD_PROD_COS_SECRET_ID` / `DOWNLOAD_PROD_COS_SECRET_KEY` identity. The release operator must confirm that permission before use; no additional CI Secret is required. Required settings are resolved before uploading. The submission has a 30-second request timeout and is never retried automatically. + +The command reports submission acceptance, not completed cache invalidation. EO results go to the console without separate purge record files; the existing upload audit records only COS writes. A rejected or unconfirmed submission exits unsuccessfully; the uploaded installer stays published. Check EO task history before submitting again after a connection loss or missing response. To submit a purge without uploading, use `pnpm run purge:latest ` with production settings and credentials; no local installer is needed. DPAPI users reuse the original credential launcher and add `-PurgeOnly` to their `-Upload -Latest` invocation. Serialize uploads and cache-only submissions per target. The CI publisher does not publish fixed downloads or run this refresh. ## Upload credentials -Windows operators can keep a CLIXML object with DPAPI-encrypted `SecretId` and `SecretKey` SecureString fields outside the repository. The [credential launcher](../scripts/upload-with-credentials.ps1) requires an explicit `-CredentialFile` and `-Environment production` or `test`; without `-Upload`, it only verifies decryption and injection into a local Node child, with no network request. It requires Node on `PATH` and the Windows user and machine that encrypted the file. Plaintext, empty, and whitespace-only fields fail. The parent environment is unchanged; the child receives only the selected COS pair after unrelated secrets and Node preload options are removed. Raw child stderr is suppressed and credential values in stdout are redacted. This check does not prove COS authorization. An explicit upload additionally requires `-Upload -Target -Bucket ` and the normal completed-release prerequisites below; actual cloud upload remains release-operator qualification. Add `-Latest` to that upload invocation to replace the fixed installer without publishing update feeds or a release tag. This launcher supports permanent keys, not STS credentials. An explicit upload requires the selected deployment and bucket to match the target dotenv and completed package record before network writes; it uses the DPAPI credential pair even if the dotenv contains other COS keys. +Windows operators can keep a CLIXML object with DPAPI-encrypted `SecretId` and `SecretKey` SecureString fields outside the repository. The [credential launcher](../scripts/upload-with-credentials.ps1) requires an explicit `-CredentialFile` and `-Environment production` or `test`; without `-Upload`, it only verifies decryption and injection into a local Node child, with no network request. It requires Node on `PATH` and the Windows user and machine that encrypted the file. Plaintext, empty, and whitespace-only fields fail. The parent environment is unchanged; the child receives only the selected COS pair after unrelated secrets and Node preload options are removed. Raw child stderr is suppressed and credential values in stdout are redacted. This check does not prove COS authorization. An explicit upload additionally requires `-Upload -Target -Bucket ` and the normal completed-release prerequisites below; actual cloud upload remains release-operator qualification. Add `-Latest` to that upload invocation to replace the fixed installer without publishing update feeds or a release tag. For a cache-only submission, add `-PurgeOnly` to `-Upload -Latest -Environment production`. Deployment and bucket must still match the dotenv, while package artifacts are not read. This launcher supports permanent keys, not STS credentials. An explicit upload requires the selected deployment and bucket to match the target dotenv and completed package record before network writes; it uses the DPAPI credential pair even if the dotenv contains other COS keys. diff --git a/apps/desktop/publishing/README.zh.md b/apps/desktop/publishing/README.zh.md index 22952efae4..0f64fb31ee 100644 --- a/apps/desktop/publishing/README.zh.md +++ b/apps/desktop/publishing/README.zh.md @@ -33,7 +33,7 @@ description: "将完整 Desktop 安装包与更新清单发布到选定下载服 `DSH_DESKTOP_AUTO_UPDATE_ENV` 同时选择打包写入的 URL 与后续 COS 上传环境,可取 `test` 或 `production`;缺省为 `test`。测试打包通过 `DOWNLOAD_TEST_ORIGIN` 提供 HTTPS origin;生产使用 `https://download.deepseek.com`。上传通过 `DOWNLOAD_TEST_COS_BUCKET` 或 `DOWNLOAD_PROD_COS_BUCKET` 提供所选 bucket。生产清单位于 `dsh-desk/feeds//`,安装包位于 `dsh-desk/bin//`。测试发布必须配置 `DOWNLOAD_TEST_RELEASE_ID`:32 位小写十六进制字符,分别插入路径 `dsh-desk//feeds//` 和 `dsh-desk//bin//`。YAML 引用、稳定通道别名和 blockmap 都位于该发布目录内。目标为 `mac-arm64`、`mac-x64` 和 `win-x64`。 -更新目标与上传凭据都与所选环境对应: +更新目标与上传凭据都与所选环境对应;生产固定下载还需配置 [EdgeOne 设置](#fixed-downloads): | 环境 | 公开 origin | COS bucket | COS 凭据 | |---|---|---|---| @@ -69,13 +69,17 @@ Windows NSIS 上传要求安装包旁存在生成的非空 `.exe.blockmap`。blo ## 固定安装包下载 -固定安装包下载在仓库根目录使用 `pnpm run upload:latest:mac:arm64`、`pnpm run upload:latest:mac:x64` 或 `pnpm run upload:latest:win:x64`。它们把已完成目标的 DMG 或 EXE 上传到 `desktop/dsh-latest-macos-arm64.dmg`、`desktop/dsh-latest-macos-x64.dmg` 或 `desktop/dsh-latest-windows-x64.exe`。生产 URL 以 `https://download.deepseek.com/` 开头;测试 URL 使用 `DOWNLOAD_TEST_ORIGIN`。两个部署环境均复用目标 dotenv 中的部署、bucket 和凭据、全部常规发布校验以及下述上传审计。每个命令覆盖一个固定对象,不发布更新清单、blockmap 或发布标签。测试固定下载在测试 bucket 中跨发布 ID 共享;包内更新 URL 仍要求原有发布 ID。发布由操作人员显式执行,也允许预发布版本。部署基础设施必须配置重新验证,或在覆盖后清除固定下载的缓存。本地回环测试覆盖上传行为;真实 COS 和 CDN 验收由发布操作人员负责。 +固定安装包下载在仓库根目录使用 `pnpm run upload:latest:mac:arm64`、`pnpm run upload:latest:mac:x64` 或 `pnpm run upload:latest:win:x64`。它们把已完成目标的 DMG 或 EXE 上传到 `desktop/dsh-latest-macos-arm64.dmg`、`desktop/dsh-latest-macos-x64.dmg` 或 `desktop/dsh-latest-windows-x64.exe`。生产 URL 以 `https://download.deepseek.com/` 开头;测试 URL 使用 `DOWNLOAD_TEST_ORIGIN`。两个部署环境均复用目标 dotenv 中的部署、bucket 和凭据、全部常规发布校验以及下述上传审计。每个命令覆盖一个固定对象,不发布更新清单、blockmap 或发布标签。测试固定下载在测试 bucket 中跨发布 ID 共享;包内更新 URL 仍要求原有发布 ID。发布由操作人员显式执行,也允许预发布版本。生产命令随后提交一次 EdgeOne URL 缓存清除任务并报告任务 ID,不查询或等待缓存清除完成。测试上传仍由部署基础设施管理缓存策略。本地回环测试覆盖上传行为;真实 COS 和 CDN 验收由发布操作人员负责。 + +生产固定下载要求在目标 dotenv 中配置 `DOWNLOAD_PROD_EO_ZONE_ID`,且既有 `DOWNLOAD_PROD_COS_SECRET_ID` / `DOWNLOAD_PROD_COS_SECRET_KEY` 身份具备对应站点的 `teo:CreatePurgeTask` 权限。发布操作人员须在使用前确认该权限;无需新增 CI Secret。上传前解析必需配置。提交请求超时为 30 秒,不自动重试。 + +命令报告提交已接受,不代表缓存清除已完成。EO 结果输出到控制台,不另建刷新记录文件;既有上传审计仅记录 COS 写入。提交被拒绝或未确认时,命令以失败状态退出,已上传安装包保持发布。连接中断或未收到响应时,先检查 EO 任务历史再重新提交。使用 `pnpm run purge:latest ` 可只提交刷新,无需上传;需要生产配置和凭据,无需本地安装包。DPAPI 用户复用原凭据启动器,在 `-Upload -Latest` 命令上添加 `-PurgeOnly`。按目标串行执行上传和单独刷新。CI 发布器不发布固定下载,也不执行此刷新。 ## 上传凭据 -Windows 操作人员可以在仓库外保存 CLIXML 对象,其中 `SecretId` 和 `SecretKey` 是经 DPAPI 加密的 SecureString 字段。[凭据启动器](../scripts/upload-with-credentials.ps1)要求显式提供 `-CredentialFile` 和 `-Environment production` 或 `test`;不指定 `-Upload` 时,只验证解密以及向本地 Node 子进程注入凭据,不发起网络请求。它要求 `PATH` 中有 Node,并使用加密该文件时的 Windows 用户和机器。明文、空字段及纯空白字段都会失败。父进程环境保持不变;子进程先清除无关密钥与 Node 预加载选项,再仅接收所选 COS 凭据对。原始子进程 stderr 不会显示,stdout 中的凭据值会被遮盖。此检查不能证明 COS 授权有效。显式上传还要求 `-Upload -Target -Bucket ` 及下述常规发布完成前提;真实云端上传仍需发布操作人员验收。在该上传调用中添加 `-Latest`,即可替换固定安装包,而不发布更新清单或发布标签。此启动器支持长期密钥,不支持 STS 凭据。显式上传要求所选部署环境和 bucket 与目标 dotenv 文件及已完成的打包记录一致,才会发起网络写入;即使 dotenv 文件含有其他 COS 密钥,也使用 DPAPI 凭据对。 +Windows 操作人员可以在仓库外保存 CLIXML 对象,其中 `SecretId` 和 `SecretKey` 是经 DPAPI 加密的 SecureString 字段。[凭据启动器](../scripts/upload-with-credentials.ps1)要求显式提供 `-CredentialFile` 和 `-Environment production` 或 `test`;不指定 `-Upload` 时,只验证解密以及向本地 Node 子进程注入凭据,不发起网络请求。它要求 `PATH` 中有 Node,并使用加密该文件时的 Windows 用户和机器。明文、空字段及纯空白字段都会失败。父进程环境保持不变;子进程先清除无关密钥与 Node 预加载选项,再仅接收所选 COS 凭据对。原始子进程 stderr 不会显示,stdout 中的凭据值会被遮盖。此检查不能证明 COS 授权有效。显式上传还要求 `-Upload -Target -Bucket ` 及下述常规发布完成前提;真实云端上传仍需发布操作人员验收。在该上传调用中添加 `-Latest`,即可替换固定安装包,而不发布更新清单或发布标签。仅提交缓存刷新时,在 `-Upload -Latest -Environment production` 上添加 `-PurgeOnly`。部署环境和 bucket 仍须匹配 dotenv,但不读取安装包产物。此启动器支持长期密钥,不支持 STS 凭据。显式上传要求所选部署环境和 bucket 与目标 dotenv 文件及已完成的打包记录一致,才会发起网络写入;即使 dotenv 文件含有其他 COS 密钥,也使用 DPAPI 凭据对。 diff --git a/apps/desktop/publishing/ci/README.i18n.yaml b/apps/desktop/publishing/ci/README.i18n.yaml index eec80bdac1..d6940d4d14 100644 --- a/apps/desktop/publishing/ci/README.i18n.yaml +++ b/apps/desktop/publishing/ci/README.i18n.yaml @@ -21,8 +21,8 @@ en: 0478d8e35f9a3d55 zh: 0aa3fd828bc02691 /desktop-ci-publication/machine-setup: - en: f80388c2b1ffef4c - zh: f242ab2316bf38a1 + en: 6e7b928af17f4377 + zh: 7c559078306df3fd /desktop-ci-publication/package-and-publish: en: f7c12d343891e547 zh: da596ee1366c2046 diff --git a/apps/desktop/publishing/ci/README.md b/apps/desktop/publishing/ci/README.md index 9835027926..02f1e1b6af 100644 --- a/apps/desktop/publishing/ci/README.md +++ b/apps/desktop/publishing/ci/README.md @@ -56,7 +56,7 @@ Keep `DSH_PACKAGING_CONFIG_DIR` pointed at the existing machine packaging direct Create `publishing/test.json` and, when authorized, `publishing/production.json` below the configuration directory. Each declares `bucket`, HTTPS `origin` without a path, and an absolute `credentialsFile`. The credential file contains only `secretId` and `secretKey`. macOS also requires `macOS.signingIdentity` and `macOS.teamId`; Windows requires `windows.certificateThumbprint`. Production requires the exact origin `https://download.deepseek.com` and `githubApp.clientId` plus an absolute `githubApp.privateKeyFile`. These files stay outside Git. The uploader supports permanent COS credentials, not STS session credentials. Administrators must keep test and production buckets unversioned (neither enabled nor suspended). Publication assumes this deployment prerequisite and does not call `GetBucketVersioning` or require its permission. Immutable binary PUTs retain `x-cos-forbid-overwrite`; their protection depends on administrators maintaining the bucket configuration. -Set the repository variable `DESKTOP_PUBLISH_RUNNERS` to a JSON mapping of registered runner names to unique labels and targets, for example `{"mini":{"label":"dsh-mini-publish","target":"mac-arm64"}}`. Assign that label to exactly that runner. Windows uses target `win-x64`. The routing job checks the original successful job, and the publisher checks its own runner name against the recorded name. Machines need no shared filesystem or LAN connection to each other. +Set the repository variable `DESKTOP_PUBLISH_RUNNERS` to a JSON mapping of registered runner names to unique labels and targets, for example `{"mini":{"label":"dsh-mini-publish","target":["mac-arm64","mac-x64"]}}`. Assign that label to exactly that runner. A target may be a single name or a nonempty list; the Mac entry above authorizes both packaging workflows on the same machine. Windows uses target `win-x64`. The retained target must match the selected packaging workflow. The routing job checks the original successful job, and the publisher checks its own runner name against the recorded name. Machines need no shared filesystem or LAN connection to each other. An administrator must create `desktop-test` and `desktop-production`, each with required reviewers and an allowed `master` deployment branch. The workflow checks reviewers for either deployment. Test can allow self-approval. The GitHub App needs installation access to this repository and Contents write permission, plus permission under the `desktop-v*` tag ruleset to create release tags. Reviewer approval does not transfer the reviewer's tag permissions to the App. Missing configuration stops publication; there is no personal-token fallback. The App action creates and later revokes a short-lived token. Protect the local private key and COS credential files with the publishing account's filesystem permissions. The token action accepts key contents, so the masked PEM also passes through the runner's temporary `GITHUB_OUTPUT` file during the job; masking hides logs, not local file access. diff --git a/apps/desktop/publishing/ci/README.zh.md b/apps/desktop/publishing/ci/README.zh.md index f8c41b6d95..3a635b5116 100644 --- a/apps/desktop/publishing/ci/README.zh.md +++ b/apps/desktop/publishing/ci/README.zh.md @@ -56,7 +56,7 @@ job 排队时,先对照 job 页面请求的标签与 **Settings → Actions 在配置目录下创建 `publishing/test.json`,获得授权后再创建 `publishing/production.json`。每份配置声明 `bucket`、不带路径的 HTTPS `origin` 和绝对路径 `credentialsFile`。凭据文件只包含 `secretId` 和 `secretKey`。macOS 还需 `macOS.signingIdentity` 和 `macOS.teamId`;Windows 需要 `windows.certificateThumbprint`。production 必须使用 origin `https://download.deepseek.com`,并提供 `githubApp.clientId` 和绝对路径 `githubApp.privateKeyFile`。这些文件不进入 Git。上传器支持长期 COS 凭据,不支持 STS 会话凭据。管理员必须维持 test 和 production bucket 未启用版本控制(不能是已启用或已暂停状态)。发布流程以此为部署前提,不调用 `GetBucketVersioning`,也不要求该权限。二进制 PUT 仍携带 `x-cos-forbid-overwrite`;防覆盖保证依赖管理员维持桶配置。 -将仓库变量 `DESKTOP_PUBLISH_RUNNERS` 配置为已注册 runner 名称到唯一标签和目标的 JSON 映射,例如 `{"mini":{"label":"dsh-mini-publish","target":"mac-arm64"}}`。该标签只能分配给这台 runner。Windows 使用目标 `win-x64`。路由 job 检查原构建成功的 job,发布器再检查自身 runner 名称与记录一致。机器之间不需要共享文件系统或局域网连接。 +将仓库变量 `DESKTOP_PUBLISH_RUNNERS` 配置为已注册 runner 名称到唯一标签和目标的 JSON 映射,例如 `{"mini":{"label":"dsh-mini-publish","target":["mac-arm64","mac-x64"]}}`。该标签只能分配给这台 runner。目标可为单个名称或非空列表;上述 Mac 条目允许同一台机器处理两种架构的打包工作流。Windows 使用目标 `win-x64`。留存包的目标必须与所选打包工作流一致。路由 job 检查原构建成功的 job,发布器再检查自身 runner 名称与记录一致。机器之间不需要共享文件系统或局域网连接。 管理员必须创建 `desktop-test` 和 `desktop-production`,分别设置必需审批人并允许 `master` 部署分支。工作流会检查两种环境的审批人配置。test 可允许发起者自行审批。GitHub App 需要安装到本仓库、具有 Contents write 权限,并获准在 `desktop-v*` tag ruleset 下创建发布 tag。审批不会把审批人的 tag 权限传给 App。缺少配置会停止发布,不回退到个人 token。App action 创建短期 token,并在结束后撤销。使用发布账户的文件系统权限保护本机私钥及 COS 凭据文件。token action 需要密钥内容,因此已屏蔽日志的 PEM 在 job 期间也会经过 runner 临时 `GITHUB_OUTPUT` 文件;日志屏蔽不限制本地文件访问。 diff --git a/apps/desktop/scripts/desktop-auto-update-environment.d.mts b/apps/desktop/scripts/desktop-auto-update-environment.d.mts index f656f9baaf..4d42b3dd1e 100644 --- a/apps/desktop/scripts/desktop-auto-update-environment.d.mts +++ b/apps/desktop/scripts/desktop-auto-update-environment.d.mts @@ -7,6 +7,13 @@ export type DesktopAutoUpdateEnvironment = 'test' | 'production' /** Directory name of one supported Desktop release target. */ export type DesktopAutoUpdateTarget = 'mac-arm64' | 'mac-x64' | 'win-x64' +/** + * Name the fixed installer object shared by uploading and cache refresh. + * @param target Supported Desktop target. + * @returns Fixed download filename. + */ +export function desktopLatestInstallerFilename(target: DesktopAutoUpdateTarget): string + /** Public updater URL and object prefixes for one release target. */ export interface DesktopAutoUpdateConfig { readonly environment: DesktopAutoUpdateEnvironment @@ -98,3 +105,11 @@ export function resolveDesktopUploadConfig( * @returns Validated channel. */ export function resolveDesktopUpdateChannel(env: NodeJS.ProcessEnv): 'nightly' | 'latest' + +/** + * Read a required nonempty deployment setting. + * @param env Packaging or upload settings. + * @param name Setting name included in missing-value diagnostics. + * @returns Trimmed setting value. + */ +export function requiredEnvironmentValue(env: NodeJS.ProcessEnv, name: string): string diff --git a/apps/desktop/scripts/desktop-auto-update-environment.mjs b/apps/desktop/scripts/desktop-auto-update-environment.mjs index a1973a9c64..835beadd6d 100644 --- a/apps/desktop/scripts/desktop-auto-update-environment.mjs +++ b/apps/desktop/scripts/desktop-auto-update-environment.mjs @@ -35,6 +35,17 @@ const UPDATE_ENVIRONMENTS = { const UPDATE_TARGETS = new Set(['mac-arm64', 'mac-x64', 'win-x64']) +/** + * Name the fixed installer object shared by uploading and cache refresh. + * @param {'mac-arm64' | 'mac-x64' | 'win-x64'} target Supported Desktop target. + * @returns {string} Fixed download filename. + */ +export function desktopLatestInstallerFilename(target) { + if (!UPDATE_TARGETS.has(target)) throw new Error(`desktop upload: unsupported target ${target}`) + const windows = target === 'win-x64' + return `dsh-latest-${windows ? 'windows' : 'macos'}-${target === 'mac-arm64' ? 'arm64' : 'x64'}.${windows ? 'exe' : 'dmg'}` +} + /** * Resolve the update deployment, defaulting local release work to test. * @param {NodeJS.ProcessEnv} env - Packaging or upload environment. @@ -99,7 +110,7 @@ export function desktopUpdateMetadataFilename(version, platform, channel = 'nigh * @param {string} name - Environment variable to read. * @returns {string} Trimmed setting. */ -function requiredEnvironmentValue(env, name) { +export function requiredEnvironmentValue(env, name) { const value = env[name]?.trim() if (value === undefined || value === '') { throw new Error(`desktop auto-update: ${name} must be set to a non-empty value`) diff --git a/apps/desktop/scripts/desktop-build-paths.mjs b/apps/desktop/scripts/desktop-build-paths.mjs index bf2361a695..edfd9cdf78 100644 --- a/apps/desktop/scripts/desktop-build-paths.mjs +++ b/apps/desktop/scripts/desktop-build-paths.mjs @@ -75,10 +75,12 @@ export function desktopTargetBuildPaths(target, environment = process.env) { */ export function desktopTargetPlatform(target) { assertSupportedTarget(target) - return { - platform: /** @type {'darwin' | 'win32'} */ (target === 'win-x64' ? 'win32' : 'darwin'), - arch: /** @type {'arm64' | 'x64'} */ (target === 'mac-arm64' ? 'arm64' : 'x64'), - } + const targets = /** @type {const} */ ({ + 'mac-arm64': { platform: 'darwin', arch: 'arm64' }, + 'mac-x64': { platform: 'darwin', arch: 'x64' }, + 'win-x64': { platform: 'win32', arch: 'x64' }, + }) + return targets[target] } /** diff --git a/apps/desktop/scripts/desktop-edgeone.ts b/apps/desktop/scripts/desktop-edgeone.ts new file mode 100644 index 0000000000..e2faf122a0 --- /dev/null +++ b/apps/desktop/scripts/desktop-edgeone.ts @@ -0,0 +1,73 @@ +/** Submit one production fixed-installer cache purge to EdgeOne. */ +import type { teo } from 'tencentcloud-sdk-nodejs-teo' +import type { DesktopPackageTargetName } from './package-target.ts' +import { desktopLatestInstallerFilename, requiredEnvironmentValue, resolveDesktopAutoUpdateEnvironment, resolveDesktopUploadConfig } from './desktop-auto-update-environment.mjs' +import { desktopTargetPlatform } from './desktop-build-paths.mjs' + +/** Validated production destination and credentials; never serialize this value. */ +export interface DesktopPurgeConfig { + readonly zoneId: string + readonly publicUrl: string + readonly secretId: string + readonly secretKey: string +} + +/** The single SDK operation needed to submit a purge. */ +export type DesktopPurgeClient = Pick, 'CreatePurgeTask'> + +/** + * Resolve production purge settings before uploading. + * @param env Target dotenv settings, including any credential-launcher replacement. + * @param target Fixed installer selected by the operator. + * @param publicUrl Uploaded object URL; omitted derives the URL for a cache-only submission. + * @returns Validated destination and credentials. + */ +export function resolveDesktopPurgeConfig( + env: NodeJS.ProcessEnv, target: DesktopPackageTargetName, publicUrl?: string, +): DesktopPurgeConfig { + if (resolveDesktopAutoUpdateEnvironment(env) !== 'production') throw new DesktopPurgeError('desktop purge: production is required') + const { platform, arch } = desktopTargetPlatform(target) + const update = resolveDesktopUploadConfig(env, platform, arch) + const zoneId = requiredEnvironmentValue(env, 'DOWNLOAD_PROD_EO_ZONE_ID') + if (!/^zone-[a-z0-9]+$/u.test(zoneId)) throw new DesktopPurgeError('desktop purge: DOWNLOAD_PROD_EO_ZONE_ID must name one EO zone') + return { + zoneId, publicUrl: publicUrl ?? `${update.origin}/desktop/${desktopLatestInstallerFilename(target)}`, + secretId: requiredEnvironmentValue(env, update.secretIdEnvName), secretKey: requiredEnvironmentValue(env, update.secretKeyEnvName), + } +} + +/** A purge submission failure containing only safe CLI diagnostics. */ +export class DesktopPurgeError extends Error {} + +function safeField(value: unknown, config: DesktopPurgeConfig): string | undefined { + if (typeof value !== 'string' || !/^[\w.-]{1,128}$/u.test(value) + || value.includes(config.secretId) || value.includes(config.secretKey)) return undefined + return value +} + +/** + * Submit once and report the accepted task ID, without querying cache completion. + * @param config Validated production destination and credentials. + * @param client Optional caller-owned SDK client; omitted lazily creates one with a 30-second request timeout. + * @returns Resolves when EO accepts the submission; this does not confirm cache completion. + * @throws DesktopPurgeError on rejection or an unconfirmed submission; never retries or changes uploaded bytes. + */ +export async function purgeDesktopInstaller(config: DesktopPurgeConfig, client?: DesktopPurgeClient): Promise { + let response: Awaited> + try { + client ??= new (await import('tencentcloud-sdk-nodejs-teo')).teo.v20220901.Client({ + credential: { secretId: config.secretId, secretKey: config.secretKey }, region: '', + profile: { httpProfile: { endpoint: 'teo.tencentcloudapi.com', reqTimeout: 30 } }, + }) + response = await client.CreatePurgeTask({ ZoneId: config.zoneId, Type: 'purge_url', Targets: [config.publicUrl] }) + } catch (error) { + const code = safeField(typeof error === 'object' && error !== null && 'code' in error ? error.code : undefined, config) + throw new DesktopPurgeError(`desktop purge: submission not confirmed (${code ?? 'request failed'}); check EO task history and teo:CreatePurgeTask permission before submitting again.`) + } + if ((response.FailedList?.length ?? 0) > 0) { + throw new DesktopPurgeError('desktop purge: EO rejected the URL; inspect EO before submitting again.') + } + const jobId = safeField(response.JobId, config) + if (!jobId) throw new DesktopPurgeError('desktop purge: EO returned no task ID; check EO task history before submitting again.') + process.stdout.write(`desktop purge: submitted ${config.publicUrl}; task ${jobId}. Cache completion is not checked.\n`) +} diff --git a/apps/desktop/scripts/desktop-package-environment.mjs b/apps/desktop/scripts/desktop-package-environment.mjs index 9877b25d09..27a7f92c7f 100644 --- a/apps/desktop/scripts/desktop-package-environment.mjs +++ b/apps/desktop/scripts/desktop-package-environment.mjs @@ -15,7 +15,7 @@ import { resolveWindowsPackageSettings } from './windows-package-settings.mjs' import { resolveDesktopTargetBuildPaths } from './desktop-build-paths.mjs' const APP_ROOT = fileURLToPath(new URL('..', import.meta.url)) -const SHARED_SETTING = /^(?:DSH_DESKTOP_(?:APP_ID|AUTO_UPDATE_ENV|NPM_REGISTRY|MANDATORY_UPDATE_(?:CONFIG|(?:TEST|PROD)_ORIGIN))|DOWNLOAD_TEST_RELEASE_ID|DOWNLOAD_(?:TEST|PROD)_(?:ORIGIN|COS_BUCKET|COS_SECRET_ID|COS_SECRET_KEY))$/u +const SHARED_SETTING = /^(?:DSH_DESKTOP_(?:APP_ID|AUTO_UPDATE_ENV|NPM_REGISTRY|MANDATORY_UPDATE_(?:CONFIG|(?:TEST|PROD)_ORIGIN))|DOWNLOAD_TEST_RELEASE_ID|DOWNLOAD_PROD_EO_ZONE_ID|DOWNLOAD_(?:TEST|PROD)_(?:ORIGIN|COS_BUCKET|COS_SECRET_ID|COS_SECRET_KEY))$/u const WINDOWS_SETTING = /^DSH_DESKTOP_WINDOWS_(?:CER_FILE|SIGNTOOL|KEY_CONTAINER|TOKEN_PIN|SIGNATURE_CACHE_DIR|SIGNATURE_CACHE_CONCURRENCY|PACK_CONCURRENCY|COMPRESSION_LEVEL)$/u const MACOS_SETTING = /^(?:DSH_DESKTOP_MACOS_(?:SIGNING_IDENTITY|TEAM_ID|SIGNATURE_CACHE_DIR|PACK_CONCURRENCY|ZIP_COMPRESSION_LEVEL|DOWNLOAD_PROXY|NOTARIZATION_PROXY)|APPLE_(?:API_KEY|API_KEY_ID|API_ISSUER|ID|APP_SPECIFIC_PASSWORD|TEAM_ID|KEYCHAIN|KEYCHAIN_PROFILE)|CSC_(?:LINK|KEY_PASSWORD))$/u const AMBIENT_RELEASE_SETTING = /^(?:DSH_DESKTOP_(?:APP_ID|AUTO_UPDATE_ENV|MANDATORY_UPDATE_.*|WINDOWS_.*|MACOS_.*)|APPLE_.*|(?:WIN_)?CSC_.*|DOWNLOAD_(?:TEST|PROD)_.*)$/iu diff --git a/apps/desktop/scripts/desktop-publish-route.ts b/apps/desktop/scripts/desktop-publish-route.ts index 303c447040..9b6fedfa42 100644 --- a/apps/desktop/scripts/desktop-publish-route.ts +++ b/apps/desktop/scripts/desktop-publish-route.ts @@ -1,5 +1,6 @@ /** Validate a completed packaging attempt and route publication back to its original runner. */ import { z } from 'zod' +import { desktopPublishRunnersSchema, desktopPackageWorkflows } from './desktop-publish-targets.ts' import { desktopPublishQualificationError } from './desktop-publish-qualification.mjs' const positive = z.number().int().positive() @@ -12,12 +13,11 @@ const requestSchema = z.object({ source: z.object({ repository: z.string(), runId: z.string(), attempt: z.string(), commit: z.string().regex(/^[a-f0-9]{40}$/u), workflowCommit: z.string(), sourceRef: z.string().min(1), channel: z.enum(['nightly', 'latest']), - runnerName: z.string(), target: z.enum(['mac-arm64', 'win-x64']), preset: z.enum(['test', 'production']), + runnerName: z.string(), target: z.enum(['mac-arm64', 'mac-x64', 'win-x64']), preset: z.enum(['test', 'production']), qualification: z.enum(['signed', 'notarized', 'unsigned', 'unnotarized']), }).refine(source => source.preset === 'test' || /^(?:master|dsh-v[0-9][0-9A-Za-z.-]*)$/u.test(source.sourceRef), 'Production source must be master or dsh-v*') }) const jobsSchema = z.array(z.object({ name: z.string(), runner_name: z.string().nullable(), conclusion: z.string().nullable() })) -const routesSchema = z.record(z.string(), z.object({ label: z.string().regex(/^dsh-[a-z0-9-]+$/u), target: z.enum(['mac-arm64', 'win-x64']) })) /** Validated values handed to the privileged publisher; no caller-selected shell fragments. */ export interface DesktopPublishRoute { @@ -28,7 +28,7 @@ export interface DesktopPublishRoute { readonly channel: 'nightly' | 'latest' readonly runnerName: string readonly runnerLabel: string - readonly target: 'mac-arm64' | 'win-x64' + readonly target: 'mac-arm64' | 'mac-x64' | 'win-x64' readonly preset: 'test' | 'production' } @@ -49,8 +49,8 @@ export function resolveDesktopPublishRoute( const diagnostic = desktopPublishQualificationError(source.qualification) if (diagnostic) throw new Error(diagnostic) const jobs = jobsSchema.parse(jobsValue) - const routes = routesSchema.parse(routesValue) - const workflow = source.target === 'win-x64' ? '.github/workflows/windows-package.yml' : '.github/workflows/macos-package.yml' + const routes = desktopPublishRunnersSchema.parse(routesValue) + const workflow = desktopPackageWorkflows[source.target] if (run.repository.full_name !== repository || source.repository !== repository || run.path !== workflow || source.runId !== String(run.id) || source.attempt !== String(run.run_attempt) || source.workflowCommit !== run.head_sha @@ -58,7 +58,7 @@ export function resolveDesktopPublishRoute( throw new Error('desktop publish: request does not match the successful packaging attempt') } const route = routes[source.runnerName] - if (!route || route.target !== source.target) throw new Error('desktop publish: original machine has no configured publishing route') + if (!route || !route.target.includes(source.target)) throw new Error('desktop publish: original machine has no configured publishing route') if (new Set(Object.values(routes).map(value => value.label)).size !== Object.keys(routes).length) { throw new Error('desktop publish: each configured machine needs its own unique runner label') } diff --git a/apps/desktop/scripts/desktop-publish-targets.ts b/apps/desktop/scripts/desktop-publish-targets.ts new file mode 100644 index 0000000000..3270b99b25 --- /dev/null +++ b/apps/desktop/scripts/desktop-publish-targets.ts @@ -0,0 +1,18 @@ +/** Approved packaging workflows and machine publication targets. */ +import { z } from 'zod' + +/** Each target is produced by its own manually dispatched workflow. */ +export const desktopPackageWorkflows = { + 'mac-arm64': '.github/workflows/macos-package.yml', + 'mac-x64': '.github/workflows/macos-intel-package.yml', + 'win-x64': '.github/workflows/windows-package.yml', +} as const + +const targetSchema = z.enum(['mac-arm64', 'mac-x64', 'win-x64']) + +/** Machine labels are unique; a scalar target is shorthand for a one-element target list. */ +export const desktopPublishRunnersSchema = z.record(z.string(), z.object({ + label: z.string().regex(/^dsh-[a-z0-9-]+$/u), + target: z.union([targetSchema, z.array(targetSchema).min(1)]) + .transform(value => typeof value === 'string' ? [value] : value), +})) diff --git a/apps/desktop/scripts/desktop-upload-plan.ts b/apps/desktop/scripts/desktop-upload-plan.ts index aa05966c4a..1461444fc6 100644 --- a/apps/desktop/scripts/desktop-upload-plan.ts +++ b/apps/desktop/scripts/desktop-upload-plan.ts @@ -8,6 +8,7 @@ import { dump, load } from 'js-yaml' import type { DesktopPackageTargetName } from './package-target.ts' import { desktopBuildRecordFilename, + desktopLatestInstallerFilename, desktopUpdateMetadataFilename, resolveDesktopUploadConfig, } from './desktop-auto-update-environment.mjs' @@ -277,7 +278,7 @@ export async function createDesktopUploadPlan( contents: dump(published), } artifacts.push(channelArtifact) - const latestFilename = `dsh-latest-${target.platform === 'darwin' ? 'macos' : 'windows'}-${target.arch}.${target.platform === 'darwin' ? 'dmg' : 'exe'}` + const latestFilename = desktopLatestInstallerFilename(targetName) const latestKey = `desktop/${latestFilename}` return { environment: update.environment, diff --git a/apps/desktop/scripts/preflight-desktop-publish.mjs b/apps/desktop/scripts/preflight-desktop-publish.mjs index 3d9624b20f..dfc1dcb4a6 100644 --- a/apps/desktop/scripts/preflight-desktop-publish.mjs +++ b/apps/desktop/scripts/preflight-desktop-publish.mjs @@ -29,7 +29,7 @@ export function preflightDesktopPublish(repository, inputs, api) { for (const { name } of pages.flatMap(page => page.artifacts)) { if (!name.endsWith(`-${inputs.package_run_id}-${attempt}`)) continue const qualification = name.startsWith('windows-x64-unsigned-') ? 'unsigned' - : name.startsWith('macos-arm64-unnotarized-debug-') ? 'unnotarized' : '' + : /^macos-(?:arm64|x64)-unnotarized-debug-/u.test(name) ? 'unnotarized' : '' const diagnostic = desktopPublishQualificationError(qualification) if (diagnostic) throw new Error(diagnostic) } diff --git a/apps/desktop/scripts/prepare-macos-ci.mjs b/apps/desktop/scripts/prepare-macos-ci.mjs index fb7d6b4747..6484a038fc 100644 --- a/apps/desktop/scripts/prepare-macos-ci.mjs +++ b/apps/desktop/scripts/prepare-macos-ci.mjs @@ -2,9 +2,12 @@ import { appendFileSync, readFileSync } from 'node:fs' import { resolve } from 'node:path' import { execFileSync } from 'node:child_process' +import { desktopTargetPlatform, resolveDesktopBuildTarget } from './desktop-build-paths.mjs' import { desktopCiVersion } from './desktop-ci-version.mjs' import { loadDesktopPackageEnvironment, validateDesktopPackageEnvironment } from './desktop-package-environment.mjs' +const target = desktopTargetPlatform(resolveDesktopBuildTarget(process.env)) +if (target.platform !== 'darwin') throw new Error('macOS CI requires a macOS target') const appRoot = resolve(import.meta.dirname, '..') const product = JSON.parse(readFileSync(resolve(appRoot, 'package.json'), 'utf8')) const version = desktopCiVersion(process.env, product.version) @@ -18,8 +21,8 @@ for (const [name, value] of Object.entries(environment)) { process.stdout.write(`::add-mask::${escaped}\n`) } } -validateDesktopPackageEnvironment(environment, { platform: 'darwin', arch: 'arm64' }, { skipNotarization }) +validateDesktopPackageEnvironment(environment, target, { skipNotarization }) if (!process.env.GITHUB_OUTPUT || !process.env.GITHUB_STEP_SUMMARY) throw new Error('Run this adapter from GitHub Actions') appendFileSync(process.env.GITHUB_OUTPUT, `version=${version}\nartifacts=${skipNotarization ? 'unnotarized-artifacts' : 'artifacts'}\nmode=${skipNotarization ? 'unnotarized-debug' : 'signed'}\n`) -appendFileSync(process.env.GITHUB_STEP_SUMMARY, `macOS arm64 signed installer\n\nVersion: ${version}\n\nCommit: ${execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim()}\n\nChannel: ${process.env.DSH_DESKTOP_UPDATE_CHANNEL}\n\nSource: ${process.env.PACKAGE_SOURCE_REF}\n\nPreset: ${process.env.DSH_PACKAGING_PRESET}\n`) +appendFileSync(process.env.GITHUB_STEP_SUMMARY, `macOS ${target.arch} signed installer\n\nVersion: ${version}\n\nCommit: ${execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim()}\n\nChannel: ${process.env.DSH_DESKTOP_UPDATE_CHANNEL}\n\nSource: ${process.env.PACKAGE_SOURCE_REF}\n\nPreset: ${process.env.DSH_PACKAGING_PRESET}\n`) if (skipNotarization) appendFileSync(process.env.GITHUB_STEP_SUMMARY, '\nWARNING: signed but NOT notarized or stapled. Internal debugging only; Gatekeeper may block downloaded copies. Not qualified for distribution or update publishing.\n') diff --git a/apps/desktop/scripts/publish-desktop-ci.ts b/apps/desktop/scripts/publish-desktop-ci.ts index 4b9c3e77d1..f9348ec2eb 100644 --- a/apps/desktop/scripts/publish-desktop-ci.ts +++ b/apps/desktop/scripts/publish-desktop-ci.ts @@ -18,7 +18,7 @@ import { desktopPublishQualificationError } from './desktop-publish-qualificatio const routeSchema = z.object({ runId: z.string(), attempt: z.string(), commit: z.string(), runnerName: z.string(), sourceRef: z.string(), channel: z.enum(['nightly', 'latest']), - target: z.enum(['mac-arm64', 'win-x64']), preset: z.enum(['test', 'production']) }) + target: z.enum(['mac-arm64', 'mac-x64', 'win-x64']), preset: z.enum(['test', 'production']) }) /** * Check retained bytes, original machine, destination and signatures before reading any upload secret. diff --git a/apps/desktop/scripts/resolve-desktop-publish-ci.ts b/apps/desktop/scripts/resolve-desktop-publish-ci.ts index ce39bc32bc..152369c49b 100644 --- a/apps/desktop/scripts/resolve-desktop-publish-ci.ts +++ b/apps/desktop/scripts/resolve-desktop-publish-ci.ts @@ -3,17 +3,17 @@ import { execFileSync } from 'node:child_process' import { appendFile, readFile } from 'node:fs/promises' import { resolve } from 'node:path' import { z } from 'zod' +import { desktopPublishRunnersSchema, desktopPackageWorkflows } from './desktop-publish-targets.ts' const positiveId = z.string().regex(/^[1-9]\d*$/u) const inputsSchema = z.object({ package_run_id: positiveId, package_run_attempt: z.union([positiveId, z.literal('')]).optional() }) const runSchema = z.object({ id: z.number().int().positive(), run_attempt: z.number().int().positive(), head_sha: z.string().regex(/^[a-f0-9]{40}$/u), status: z.literal('completed'), conclusion: z.literal('success'), - event: z.literal('workflow_dispatch'), path: z.enum(['.github/workflows/macos-package.yml', '.github/workflows/windows-package.yml']), + event: z.literal('workflow_dispatch'), path: z.enum(Object.values(desktopPackageWorkflows)), repository: z.object({ full_name: z.string() }) }) const jobsSchema = z.object({ jobs: z.array(z.object({ name: z.string(), runner_name: z.string().nullable(), conclusion: z.string().nullable(), })) }) -const routesSchema = z.record(z.string(), z.object({ label: z.string().regex(/^dsh-[a-z0-9-]+$/u), target: z.enum(['mac-arm64', 'win-x64']) })) /** * Read GitHub evidence for one explicit attempt, or the latest attempt without falling back after failure. @@ -37,10 +37,11 @@ export function selectDesktopPackage(repository: string, inputsValue: unknown, r const packages = jobs.filter(job => job.name === 'package' && job.conclusion === 'success' && job.runner_name) if (packages.length !== 1) throw new Error('desktop publish: expected one successful package job') const runnerName = packages[0]!.runner_name! - const routes = routesSchema.parse(routesValue) + const routes = desktopPublishRunnersSchema.parse(routesValue) const route = routes[runnerName] - const target = run.path.endsWith('/macos-package.yml') ? 'mac-arm64' : 'win-x64' - if (!route || route.target !== target) throw new Error('desktop publish: original machine has no configured publishing route') + const target = (Object.keys(desktopPackageWorkflows) as Array) + .find(name => desktopPackageWorkflows[name] === run.path)! + if (!route || !route.target.includes(target)) throw new Error('desktop publish: original machine has no configured publishing route') if (new Set(Object.values(routes).map(value => value.label)).size !== Object.keys(routes).length) { throw new Error('desktop publish: each configured machine needs its own unique runner label') } diff --git a/apps/desktop/scripts/runtime-patch-policy.ts b/apps/desktop/scripts/runtime-patch-policy.ts index d285f9bab8..a56dc95e45 100644 --- a/apps/desktop/scripts/runtime-patch-policy.ts +++ b/apps/desktop/scripts/runtime-patch-policy.ts @@ -17,6 +17,8 @@ export const DESKTOP_RUNTIME_PATCHES: Readonly(['mac-arm64', 'mac-x64', 'win-x64']) function targetName(value: string): DesktopPackageTargetName { if (!SUPPORTED_TARGETS.has(value as DesktopPackageTargetName)) { - throw new Error(`desktop upload: unsupported target ${JSON.stringify(value)}; expected ${[...SUPPORTED_TARGETS].join(', ')}`) + throw new DesktopUploadUsageError(`desktop upload: unsupported target ${JSON.stringify(value)}; expected ${[...SUPPORTED_TARGETS].join(', ')}`) } return value as DesktopPackageTargetName } -function requiredEnvironmentValue(environment: NodeJS.ProcessEnv, name: string): string { - const value = environment[name]?.trim() - if (value === undefined || value === '') { - throw new Error(`desktop upload: ${name} must be set to a non-empty value`) - } - return value -} +/** Invalid command arguments or release configuration, reported before network writes. */ +class DesktopUploadUsageError extends Error {} /** * Use the credential launcher's selected deployment only when it matches the packaged release destination. @@ -41,15 +38,14 @@ export function resolveCredentialUploadEnvironment( selected: 'test' | 'production', bucket: string, target: DesktopPackageTargetName, ): NodeJS.ProcessEnv { - const platform = target === 'win-x64' ? 'win32' : 'darwin' - const arch = target === 'mac-arm64' ? 'arm64' : 'x64' + const { platform, arch } = desktopTargetPlatform(target) const destination = resolveDesktopUploadConfig(fileEnvironment, platform, arch) if (destination.environment !== selected || destination.bucket !== bucket) { - throw new Error('desktop upload: credential launcher deployment or bucket differs from the packaged release destination') + throw new DesktopUploadUsageError('desktop upload: credential launcher deployment or bucket differs from the packaged release destination') } if (injectedEnvironment.DSH_DESKTOP_AUTO_UPDATE_ENV !== selected || injectedEnvironment[`${selected === 'test' ? 'DOWNLOAD_TEST' : 'DOWNLOAD_PROD'}_COS_BUCKET`] !== bucket) { - throw new Error('desktop upload: credential launcher environment differs from its explicit arguments') + throw new DesktopUploadUsageError('desktop upload: credential launcher environment differs from its explicit arguments') } return { ...fileEnvironment, @@ -58,40 +54,74 @@ export function resolveCredentialUploadEnvironment( } } +function purgeConfig(environment: NodeJS.ProcessEnv, target: DesktopPackageTargetName, publicUrl?: string) { + try { + return resolveDesktopPurgeConfig(environment, target, publicUrl) + } catch (error) { + if (error instanceof DesktopPurgeError) throw error + throw new DesktopUploadUsageError('Invalid production purge settings; check DOWNLOAD_PROD_COS_BUCKET, DOWNLOAD_PROD_COS_SECRET_ID, DOWNLOAD_PROD_COS_SECRET_KEY and DOWNLOAD_PROD_EO_ZONE_ID in the target dotenv.') + } +} + /** - * Upload the selected target and record a release tag only for production update uploads. + * Upload the selected target, refresh production fixed downloads, or submit only their cache purge. * @param args Target and upload options from the command line. - * @returns Resolves after upload and any release-tag attempt complete. + * @returns Resolves after upload, any required EO submission, and any release-tag attempt complete. + * @throws DesktopPurgeError when purge submission fails; a completed upload is not repeated or rolled back. */ export async function uploadDesktopTarget(args: string[]): Promise { const { positionals, values } = parseArgs({ args, allowPositionals: true, options: { latest: { type: 'boolean' }, + 'purge-only': { type: 'boolean' }, 'credential-launcher': { type: 'boolean' }, environment: { type: 'string' }, bucket: { type: 'string' }, } }) const target = positionals[0] if (target === undefined || positionals.length !== 1) { - throw new Error('desktop upload: expected exactly one target') + throw new DesktopUploadUsageError('desktop upload: expected exactly one target') } const name = targetName(target) - const fileEnvironment = loadDesktopPackageEnvironment(name === 'win-x64' ? 'win32' : 'darwin') + if (values['purge-only'] && !values.latest) { + throw new DesktopUploadUsageError('desktop upload: --purge-only requires --latest') + } + let fileEnvironment: NodeJS.ProcessEnv + try { + fileEnvironment = loadDesktopPackageEnvironment(desktopTargetPlatform(name).platform) + } catch (error) { + throw new DesktopUploadUsageError('Cannot load target dotenv; check the target dotenv file and release settings.') + } const launcher = values['credential-launcher'] === true if (launcher ? values.environment === undefined || values.bucket === undefined : values.environment !== undefined || values.bucket !== undefined) { - throw new Error('desktop upload: credential launcher requires an explicit environment and bucket') + throw new DesktopUploadUsageError('desktop upload: credential launcher requires an explicit environment and bucket') } if (values.environment !== undefined && values.environment !== 'test' && values.environment !== 'production') { - throw new Error('desktop upload: credential launcher environment must be test or production') + throw new DesktopUploadUsageError('desktop upload: credential launcher environment must be test or production') } const environment = launcher ? resolveCredentialUploadEnvironment(fileEnvironment, process.env, values.environment as 'test' | 'production', values.bucket!, name) : fileEnvironment + try { + resolveDesktopAutoUpdateEnvironment(environment) + } catch (error) { + throw new DesktopUploadUsageError('DSH_DESKTOP_AUTO_UPDATE_ENV must be "test" or "production".') + } + const recordsRoot = resolve(import.meta.dirname, '../.desktop-build/upload-records') + if (values['purge-only']) { + await purgeDesktopInstaller(purgeConfig(environment, name)) + return + } const plan = await createDesktopUploadPlan(name, { environment, latest: values.latest === true }) + const purge = plan.environment === 'production' && values.latest ? purgeConfig(environment, name, plan.publicUrl) : undefined const cos = createDesktopCos({ secretId: requiredEnvironmentValue(environment, plan.secretIdEnvName), secretKey: requiredEnvironmentValue(environment, plan.secretKeyEnvName), }) process.stdout.write(`desktop upload: ${plan.target} ${plan.version} -> ${plan.publicUrl}\n`) - await uploadDesktopRelease(plan, cos, resolve(import.meta.dirname, '../.desktop-build/upload-records')) + await uploadDesktopRelease(plan, cos, recordsRoot) + if (purge !== undefined) { + process.stdout.write('desktop upload: installer uploaded; submitting cache purge.\n') + await purgeDesktopInstaller(purge) + } if (plan.environment === 'production' && !values.latest) recordProductionRelease(plan) } @@ -121,9 +151,27 @@ function recordProductionRelease(plan: DesktopUploadPlan): void { process.stdout.write(`desktop upload: ${result.tag} ${result.status === 'created' ? 'records' : 'already recorded'} ${plan.commit}\n`) } -if (process.argv[1] !== undefined && import.meta.filename === resolve(process.argv[1])) { - uploadDesktopTarget(process.argv.slice(2)).catch(() => { - process.stderr.write('desktop upload: failed; inspect the printed record directory if allocated. No automatic retry; reconcile remote state before another upload.\n') +/** + * Report command failures without exposing raw storage or SDK exceptions. + * @param args Target and options forwarded by the package command or credential launcher. + * @returns Resolves after reporting any failure and setting the process exit status. + */ +export async function runDesktopUploadCommand(args: string[]): Promise { + try { + await uploadDesktopTarget(args) + } catch (error) { + const usage = error instanceof DesktopUploadUsageError + || (error instanceof Error && 'code' in error && typeof error.code === 'string' && error.code.startsWith('ERR_PARSE_ARGS_')) + const message = error instanceof DesktopPurgeError ? error.message + : usage ? `desktop upload/purge: ${error instanceof DesktopUploadUsageError ? error.message : 'invalid arguments'}. Usage: upload-target.ts [--latest [--purge-only]]; targets: mac-arm64, mac-x64, win-x64.` + : 'desktop upload: failed; inspect the printed record directory if allocated. No automatic retry; reconcile remote state before another upload.' + // The DPAPI launcher suppresses raw stderr; these diagnostics contain no SDK details. + const output = args.includes('--credential-launcher') ? process.stdout : process.stderr + output.write(`${message}\n`) process.exitCode = 1 - }) + } +} + +if (process.argv[1] !== undefined && import.meta.filename === resolve(process.argv[1])) { + void runDesktopUploadCommand(process.argv.slice(2)) } diff --git a/apps/desktop/scripts/upload-with-credentials.ps1 b/apps/desktop/scripts/upload-with-credentials.ps1 index 4f146d59b8..e6cde5f27e 100644 --- a/apps/desktop/scripts/upload-with-credentials.ps1 +++ b/apps/desktop/scripts/upload-with-credentials.ps1 @@ -18,6 +18,8 @@ COS bucket for an explicit upload. Not needed for the local credential check. Publish only the installer at its fixed download URL. Requires Upload. .PARAMETER Upload Authorize the existing target upload entry. Omit to check credentials locally. +.PARAMETER PurgeOnly +Submit a purge for the production fixed download without uploading. Requires Upload and Latest. #> [CmdletBinding(DefaultParameterSetName = 'Check')] param( @@ -36,7 +38,9 @@ param( [Parameter(ParameterSetName = 'Publish', Mandatory = $true)] [switch]$Upload, [Parameter(ParameterSetName = 'Publish')] - [switch]$Latest + [switch]$Latest, + [Parameter(ParameterSetName = 'Publish')] + [switch]$PurgeOnly ) $ErrorActionPreference = 'Stop' @@ -49,6 +53,11 @@ $credentials = $null $startInfo = $null $stage = 'decrypt-file' +if ($PurgeOnly -and (-not $Latest -or $Environment -ne 'production')) { + Write-Output 'desktop credentials: invalid arguments; PurgeOnly requires Latest and production.' + exit 1 +} + try { if ([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT) { throw 'Windows DPAPI is required.' @@ -92,7 +101,12 @@ try { $startInfo.EnvironmentVariables["${prefix}_BUCKET"] = $Bucket $startInfo.Arguments = "--import tsx/esm apps/desktop/scripts/upload-target.ts $Target --credential-launcher --environment $Environment --bucket $Bucket" if ($Latest) { $startInfo.Arguments += " --latest" } - Write-Output "desktop credentials: uploading $Target to $Environment; release validation runs before network writes." + if ($PurgeOnly) { $startInfo.Arguments += " --purge-only" } + if ($PurgeOnly) { + Write-Output "desktop credentials: submitting a cache purge for $Target in $Environment; no installer upload." + } else { + Write-Output "desktop credentials: uploading $Target to $Environment; release validation runs before network writes." + } } else { $probe = "const id=process.env.${prefix}_SECRET_ID;const key=process.env.${prefix}_SECRET_KEY;process.exit(id?.trim()&&key?.trim()?0:1)" $startInfo.Arguments = "-e `"$probe`"" diff --git a/apps/desktop/tests/desktop-edgeone.spec.ts b/apps/desktop/tests/desktop-edgeone.spec.ts new file mode 100644 index 0000000000..cada969378 --- /dev/null +++ b/apps/desktop/tests/desktop-edgeone.spec.ts @@ -0,0 +1,79 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { teo } from 'tencentcloud-sdk-nodejs-teo' +import { purgeDesktopInstaller, resolveDesktopPurgeConfig } from '../scripts/desktop-edgeone.ts' + +const environment = { + DSH_DESKTOP_AUTO_UPDATE_ENV: 'production', DOWNLOAD_PROD_COS_BUCKET: 'fixture-bucket', + DOWNLOAD_PROD_COS_SECRET_ID: 'fixture-secret-id', DOWNLOAD_PROD_COS_SECRET_KEY: 'fixture-secret-key', + DOWNLOAD_PROD_EO_ZONE_ID: 'zone-fixture', +} +const config = resolveDesktopPurgeConfig(environment, 'mac-arm64') + +afterEach(() => vi.restoreAllMocks()) + +describe('production EdgeOne configuration', () => { + it.each([ + [{ DSH_DESKTOP_AUTO_UPDATE_ENV: 'test' }, /production is required/u], + [{ DSH_DESKTOP_AUTO_UPDATE_ENV: 'prod' }, /must be "test" or "production"/u], + [{ DOWNLOAD_PROD_EO_ZONE_ID: '' }, /DOWNLOAD_PROD_EO_ZONE_ID/u], + [{ DOWNLOAD_PROD_EO_ZONE_ID: 'invalid' }, /must name one EO zone/u], + [{ DOWNLOAD_PROD_COS_SECRET_KEY: '' }, /DOWNLOAD_PROD_COS_SECRET_KEY/u], + ] as const)('rejects invalid settings %j', (patch, message) => { + expect(() => resolveDesktopPurgeConfig({ ...environment, ...patch }, 'mac-arm64')).toThrow(message) + }) + + it('uses the upload plan URL verbatim and derives only cache-only destinations', () => { + const url = 'https://download.deepseek.com/changed/installer.dmg' + expect(resolveDesktopPurgeConfig(environment, 'mac-arm64', url).publicUrl).toBe(url) + expect(resolveDesktopPurgeConfig(environment, 'win-x64').publicUrl).toBe('https://download.deepseek.com/desktop/dsh-latest-windows-x64.exe') + expect(resolveDesktopPurgeConfig(environment, 'mac-x64').publicUrl).toBe('https://download.deepseek.com/desktop/dsh-latest-macos-x64.dmg') + }) +}) + +describe('EdgeOne purge submission', () => { + it('submits once with the pinned SDK and reports acceptance without querying completion', async () => { + const output = vi.spyOn(process.stdout, 'write').mockReturnValue(true) + const submit = vi.spyOn(teo.v20220901.Client.prototype, 'CreatePurgeTask') + .mockImplementation(async function (this: InstanceType) { + expect(this.profile.httpProfile).toMatchObject({ reqMethod: 'POST', protocol: 'https://', reqTimeout: 30 }) + return { JobId: 'job-1' } + }) + const query = vi.spyOn(teo.v20220901.Client.prototype, 'DescribePurgeTasks').mockRejectedValue(new Error('must not query')) + + await purgeDesktopInstaller(config) + + expect(submit).toHaveBeenCalledExactlyOnceWith({ ZoneId: config.zoneId, Type: 'purge_url', Targets: [config.publicUrl] }) + expect(query).not.toHaveBeenCalled() + expect(output).toHaveBeenCalledExactlyOnceWith( + `desktop purge: submitted ${config.publicUrl}; task job-1. Cache completion is not checked.\n`, + ) + }) + + it('reports a rejected URL without printing the raw response or claiming acceptance', async () => { + const output = vi.spyOn(process.stdout, 'write').mockReturnValue(true) + const submit = vi.fn().mockResolvedValue({ JobId: 'job-1', FailedList: [{ Targets: [config.publicUrl], Reason: config.secretKey }] }) + await expect(purgeDesktopInstaller(config, { CreatePurgeTask: submit })).rejects.toThrow( + 'desktop purge: EO rejected the URL; inspect EO before submitting again.', + ) + expect(submit).toHaveBeenCalledOnce() + expect(output).not.toHaveBeenCalled() + }) + + it.each(['ECONNRESET', 'UnauthorizedOperation', undefined, config.secretKey])('does not retry or expose SDK errors (%s)', async (code) => { + const submit = vi.fn().mockRejectedValue(Object.assign(new Error(config.secretKey), { code })) + await expect(purgeDesktopInstaller(config, { CreatePurgeTask: submit })).rejects.toThrow( + `desktop purge: submission not confirmed (${code === undefined || code === config.secretKey ? 'request failed' : code}); check EO task history and teo:CreatePurgeTask permission before submitting again.`, + ) + expect(submit).toHaveBeenCalledOnce() + }) + + it.each([undefined, 'unsafe value', config.secretId])('reports an unconfirmed submission without a usable task ID (%s)', async (jobId) => { + const output = vi.spyOn(process.stdout, 'write').mockReturnValue(true) + const submit = vi.fn().mockResolvedValue({ JobId: jobId }) + await expect(purgeDesktopInstaller(config, { CreatePurgeTask: submit })).rejects.toThrow( + 'desktop purge: EO returned no task ID; check EO task history before submitting again.', + ) + expect(submit).toHaveBeenCalledOnce() + expect(output).not.toHaveBeenCalled() + }) +}) diff --git a/apps/desktop/tests/desktop-package-selection.spec.ts b/apps/desktop/tests/desktop-package-selection.spec.ts index fa6a042775..1da8dde6e8 100644 --- a/apps/desktop/tests/desktop-package-selection.spec.ts +++ b/apps/desktop/tests/desktop-package-selection.spec.ts @@ -121,3 +121,17 @@ it('resolves an old locally retained package with publishRequested=false without await expect(resolveRetainedDesktopPublication(env, api)).rejects.toThrow() } finally { await rm(root, { recursive: true, force: true }) } }) + +it('routes both Mac architectures to one machine without authorizing an unlisted target', () => { + const f = fixture() + const routes = { mini: { label: 'dsh-mini', target: ['mac-arm64', 'mac-x64'] } } + for (const path of ['macos-package.yml', 'macos-intel-package.yml']) { + f.run.path = `.github/workflows/${path}` + expect(selectDesktopPackage('owner/repo', { package_run_id: '42' }, routes, f.api)) + .toMatchObject({ runnerName: 'mini', runnerLabel: 'dsh-mini' }) + } + expect(() => selectDesktopPackage('owner/repo', { package_run_id: '42' }, f.routes, f.api)).toThrow(/no configured/u) + for (const target of [[], ['mac-x64', 'linux-x64']]) { + expect(() => selectDesktopPackage('owner/repo', { package_run_id: '42' }, { mini: { label: 'dsh-mini', target } }, f.api)).toThrow() + } +}) diff --git a/apps/desktop/tests/desktop-publish-preflight.spec.ts b/apps/desktop/tests/desktop-publish-preflight.spec.ts index 2af6f288b1..f6c99ae280 100644 --- a/apps/desktop/tests/desktop-publish-preflight.spec.ts +++ b/apps/desktop/tests/desktop-publish-preflight.spec.ts @@ -2,6 +2,7 @@ import { expect, it } from 'vitest' import { preflightDesktopPublish } from '../scripts/preflight-desktop-publish.mjs' it.each([ + ['macos-x64-unnotarized-debug-1.0.0-42-2', '跳过了公证'], ['macos-arm64-unnotarized-debug-1.0.0-42-2', '跳过了公证'], ['windows-x64-unsigned-1.0.0-42-2', '未签名'], ])('rejects %s from paginated artifact metadata with rebuilding instructions', (name, reason) => { diff --git a/apps/desktop/tests/desktop-publish-route.spec.ts b/apps/desktop/tests/desktop-publish-route.spec.ts index 37258e26ed..4e3acc79f3 100644 --- a/apps/desktop/tests/desktop-publish-route.spec.ts +++ b/apps/desktop/tests/desktop-publish-route.spec.ts @@ -75,3 +75,16 @@ it('allows repository branch packages for test but rejects them for production', f.source.preset = 'production' expect(() => resolveDesktopPublishRoute('owner/repo', f.run, { source: f.source }, f.jobs, f.routes)).toThrow(/Production source/u) }) + +it('matches Intel artifacts to the Intel workflow on a machine serving both architectures', () => { + const f = fixture() + f.source.target = 'mac-x64' + const routes = { mini: { label: 'dsh-mini', target: ['mac-arm64', 'mac-x64'] } } + expect(() => resolveDesktopPublishRoute('owner/repo', f.run, { source: f.source }, f.jobs, routes)).toThrow(/does not match/u) + f.run.path = '.github/workflows/macos-intel-package.yml' + expect(resolveDesktopPublishRoute('owner/repo', f.run, { source: f.source }, f.jobs, routes)) + .toMatchObject({ target: 'mac-x64', runnerLabel: 'dsh-mini' }) + expect(() => resolveDesktopPublishRoute('owner/repo', f.run, { source: f.source }, f.jobs, f.routes)).toThrow(/no configured/u) + f.source.target = 'mac-arm64' + expect(() => resolveDesktopPublishRoute('owner/repo', f.run, { source: f.source }, f.jobs, routes)).toThrow(/does not match/u) +}) diff --git a/apps/desktop/tests/macos-package-preset.spec.ts b/apps/desktop/tests/macos-package-preset.spec.ts index a4ab457ec0..a864b9b9bb 100644 --- a/apps/desktop/tests/macos-package-preset.spec.ts +++ b/apps/desktop/tests/macos-package-preset.spec.ts @@ -60,7 +60,7 @@ it.skipIf(process.platform !== 'darwin')('prepares private machine directories w } }) -it('records debugging artifact paths only for an explicit CI selection without notarization credentials', async () => { +it.each(['arm64', 'x64'])('records %s debugging artifact paths only for an explicit CI selection without notarization credentials', async (arch) => { const root = await mkdtemp(join(tmpdir(), 'macos-ci-settings-')) try { await mkdir(join(root, 'presets')) @@ -81,6 +81,7 @@ it('records debugging artifact paths only for an explicit CI selection without n const run = (skip: string) => spawnSync(process.execPath, [join(import.meta.dirname, '../scripts/prepare-macos-ci.mjs')], { encoding: 'utf8', env: { ...process.env, DSH_PACKAGING_CONFIG_DIR: root, DSH_PACKAGING_PRESET: 'test', + DSH_DESKTOP_TARGET_PLATFORM: 'darwin', DSH_DESKTOP_TARGET_ARCH: arch, PACKAGE_SKIP_NOTARIZATION: skip, PACKAGE_BUILD_VERSION: 'auto', GITHUB_RUN_NUMBER: '1', GITHUB_RUN_ATTEMPT: '1', GITHUB_OUTPUT: output, GITHUB_STEP_SUMMARY: summary, } }) @@ -91,10 +92,13 @@ it('records debugging artifact paths only for an explicit CI selection without n expect(invalidSelection.status).toBe(1) expect(invalidSelection.stderr).toContain('Invalid skip notarization selection') const debugging = run('true') + expect(debugging.error).toBeUndefined() + expect(debugging.signal).toBeNull() expect(debugging.stderr).toBe('') expect(debugging.status).toBe(0) expect(await readFile(output, 'utf8')).toContain('artifacts=unnotarized-artifacts\nmode=unnotarized-debug\n') expect(await readFile(summary, 'utf8')).toContain('Internal debugging only') + expect(await readFile(summary, 'utf8')).toContain(`macOS ${arch} signed installer`) } finally { await rm(root, { recursive: true, force: true }) } diff --git a/apps/desktop/tests/prepare-runtime-patches.spec.ts b/apps/desktop/tests/prepare-runtime-patches.spec.ts index d7b7dca895..5ff1d2896f 100644 --- a/apps/desktop/tests/prepare-runtime-patches.spec.ts +++ b/apps/desktop/tests/prepare-runtime-patches.spec.ts @@ -43,7 +43,7 @@ it('classifies the current workspace and shares only runtime patch bytes', () => const before = readFileSync(join(repository, 'pnpm-lock.yaml')) const plan = prepareRuntimePatches(project, repository) expect(plan.patches.map(entry => entry.spec)).toEqual(['@earendil-works/pi-ai@1.3.0', 'node-pty@1.2.0-beta.15']) - expect(plan.workspaceOnly).toHaveLength(8) + expect(plan.workspaceOnly).toHaveLength(10) expect(plan.workspaceOnly).toContain('@botiverse/oar') expect(plan.workspaceOnly).toContain('lexical') const settings = yaml.load(readFileSync(join(project, 'pnpm-workspace.yaml'), 'utf8')) as { overrides: Record } diff --git a/apps/desktop/tests/purge-command.spec.ts b/apps/desktop/tests/purge-command.spec.ts new file mode 100644 index 0000000000..a690db7a35 --- /dev/null +++ b/apps/desktop/tests/purge-command.spec.ts @@ -0,0 +1,63 @@ +import { execFile } from 'node:child_process' +import { copyFile, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { promisify } from 'node:util' +import { z } from 'zod' +import { afterEach, expect, it } from 'vitest' + +const execute = promisify(execFile) +const root = fileURLToPath(new URL('../../..', import.meta.url)) +const roots: string[] = [] + +afterEach(async () => { + await Promise.all(roots.splice(0).map(path => rm(path, { recursive: true, force: true }))) +}) + +it('forwards the documented pnpm purge command through both package scripts to the real argument parser', async () => { + const directory = await mkdtemp(join(tmpdir(), 'desktop-purge-command-')) + roots.push(directory) + const scripts = join(directory, 'apps/desktop/scripts') + await mkdir(scripts, { recursive: true }) + const manifest = z.object({ name: z.string(), scripts: z.object({ 'purge:latest': z.string() }) }) + const repository = manifest.parse(JSON.parse(await readFile(join(root, 'package.json'), 'utf8'))) + const desktop = manifest.parse(JSON.parse(await readFile(join(root, 'apps/desktop/package.json'), 'utf8'))) + await writeFile(join(directory, 'package.json'), JSON.stringify({ private: true, type: 'module', scripts: { 'purge:latest': repository.scripts['purge:latest'] } })) + await writeFile(join(directory, 'pnpm-workspace.yaml'), 'packages:\n - apps/desktop\n') + await writeFile(join(directory, 'apps/desktop/package.json'), JSON.stringify({ name: desktop.name, type: 'module', scripts: { 'purge:latest': desktop.scripts['purge:latest'] } })) + await mkdir(join(directory, 'node_modules')) + for (const dependency of ['.bin', 'pnpm', 'tsx', 'semver']) { + await symlink(join(root, 'node_modules', dependency), join(directory, 'node_modules', dependency), 'junction') + } + await copyFile(join(root, 'apps/desktop/scripts/upload-target.ts'), join(scripts, 'upload-target.ts')) + for (const filename of ['desktop-auto-update-environment.mjs', 'desktop-build-paths.mjs']) { + await copyFile(join(root, 'apps/desktop/scripts', filename), join(scripts, filename)) + } + // Only command forwarding and parsing are under test; all external work fails closed. + const stubs = { + 'desktop-package-environment.mjs': "export const loadDesktopPackageEnvironment = () => ({ DSH_DESKTOP_AUTO_UPDATE_ENV: 'production' })", + 'desktop-cos.ts': "export const createDesktopCos = () => { throw new Error('unexpected COS') }", + 'desktop-upload-plan.ts': "export const createDesktopUploadPlan = () => { throw new Error('unexpected installer read') }", + 'desktop-upload-run.ts': "export const uploadDesktopRelease = () => { throw new Error('unexpected upload') }", + 'desktop-release-tag.ts': "export const desktopReleaseTag = () => { throw new Error('unexpected tag') }; export const tagDesktopRelease = desktopReleaseTag", + 'desktop-edgeone.ts': ` + import assert from 'node:assert/strict' + export class DesktopPurgeError extends Error {} + export const resolveDesktopPurgeConfig = (_env, target) => ({ target }) + export const purgeDesktopInstaller = async (config) => { + assert.equal(config.target, 'mac-arm64') + console.log('purge-submit: mac-arm64') + } + `, + } + await Promise.all(Object.entries(stubs).map(([name, contents]) => writeFile(join(scripts, name), contents))) + const env = Object.fromEntries(Object.entries(process.env).filter(([name]) => + !/KEY|SECRET|TOKEN|PASSWORD|^NODE_OPTIONS$|^DSH_|^DOWNLOAD_|^PACKAGE_/iu.test(name))) + const args = [join(root, 'node_modules/pnpm/bin/pnpm.mjs'), 'run', 'purge:latest', 'mac-arm64'] + const result = await execute(process.execPath, args, { cwd: directory, env, timeout: 30_000 }).catch((error: unknown) => { + expect(error).not.toMatchObject({ killed: true }) + throw error + }) + expect(result.stdout).toContain('purge-submit: mac-arm64') +}) diff --git a/apps/desktop/tests/retain-desktop-ci.spec.ts b/apps/desktop/tests/retain-desktop-ci.spec.ts index abe3565e93..f83592fa4a 100644 --- a/apps/desktop/tests/retain-desktop-ci.spec.ts +++ b/apps/desktop/tests/retain-desktop-ci.spec.ts @@ -10,7 +10,7 @@ const roots: string[] = [] afterEach(async () => { await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))) }) it.each([ - ['mac-arm64', 'notarized'], ['win-x64', 'signed'], ['mac-arm64', 'unnotarized'], ['win-x64', 'unsigned'], + ['mac-arm64', 'notarized'], ['mac-x64', 'notarized'], ['mac-x64', 'unnotarized'], ['win-x64', 'signed'], ['mac-arm64', 'unnotarized'], ['win-x64', 'unsigned'], ] as const)('retains the complete %s %s release without signing or upload credentials', async (target, qualification) => { const root = await mkdtemp(join(tmpdir(), 'retain-ci-')) roots.push(root) @@ -23,13 +23,13 @@ it.each([ await writeFile(join(appRoot, 'package.json'), '{"version":"1.0.0"}') await writeFile(join(config, 'presets/test.env'), `DSH_DESKTOP_AUTO_UPDATE_ENV=test\nDOWNLOAD_TEST_RELEASE_ID=${'a'.repeat(32)}\n`) const names = debug ? (target === 'win-x64' ? [`deepseek-harness-1.0.0-win-x64-${qualification}.exe`] - : [`deepseek-harness-1.0.0-mac-arm64-${qualification}.dmg`, `deepseek-harness-1.0.0-mac-arm64-${qualification}.zip`]) : target === 'win-x64' + : [`deepseek-harness-1.0.0-${target}-${qualification}.dmg`, `deepseek-harness-1.0.0-${target}-${qualification}.zip`]) : target === 'win-x64' ? ['deepseek-harness-1.0.0-win-x64.exe', 'deepseek-harness-1.0.0-win-x64.exe.blockmap', 'win-x64-release.json', 'nightly.yml'] - : ['deepseek-harness-1.0.0-mac-arm64.dmg', 'deepseek-harness-1.0.0-mac-arm64.zip', 'deepseek-harness-1.0.0-mac-arm64.zip.blockmap', 'mac-arm64-release.json', 'nightly-mac.yml'] + : [`deepseek-harness-1.0.0-${target}.dmg`, `deepseek-harness-1.0.0-${target}.zip`, `deepseek-harness-1.0.0-${target}.zip.blockmap`, `${target}-release.json`, 'nightly-mac.yml'] await Promise.all(names.map(name => writeFile(join(artifacts, name), 'fixture'))) const env = { DSH_PACKAGING_CONFIG_DIR: config, DSH_PACKAGING_PRESET: 'test', DSH_DESKTOP_TARGET_PLATFORM: target === 'win-x64' ? 'win32' : 'darwin', - DSH_DESKTOP_TARGET_ARCH: target === 'win-x64' ? 'x64' : 'arm64', PACKAGE_SOURCE_REF: 'master', PACKAGE_SOURCE_SHA: 'b'.repeat(40), PACKAGE_VERSION: '1.0.0', + DSH_DESKTOP_TARGET_ARCH: target === 'mac-arm64' ? 'arm64' : 'x64', PACKAGE_SOURCE_REF: 'master', PACKAGE_SOURCE_SHA: 'b'.repeat(40), PACKAGE_VERSION: '1.0.0', PACKAGE_SIGNING: qualification === 'unsigned' ? 'unsigned' : 'signed', PACKAGE_SKIP_NOTARIZATION: qualification === 'unnotarized' ? 'true' : 'false', GITHUB_RUN_ID: '123', GITHUB_RUN_ATTEMPT: '2', GITHUB_STEP_SUMMARY: join(root, 'summary.md'), GITHUB_REPOSITORY: 'owner/repository', GITHUB_SHA: 'b'.repeat(40), RUNNER_NAME: 'dedicated', diff --git a/apps/desktop/tests/upload-target.spec.ts b/apps/desktop/tests/upload-target.spec.ts index 860d7867c2..94e9aa5b4f 100644 --- a/apps/desktop/tests/upload-target.spec.ts +++ b/apps/desktop/tests/upload-target.spec.ts @@ -1,9 +1,15 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { DesktopPurgeError, purgeDesktopInstaller } from '../scripts/desktop-edgeone.ts' import { createDesktopUploadPlan, type DesktopUploadPlan } from '../scripts/desktop-upload-plan.ts' import { loadDesktopPackageEnvironment } from '../scripts/desktop-package-environment.mjs' import { tagDesktopRelease } from '../scripts/desktop-release-tag.ts' import { uploadDesktopRelease } from '../scripts/desktop-upload-run.ts' -import { uploadDesktopTarget } from '../scripts/upload-target.ts' +import { runDesktopUploadCommand, uploadDesktopTarget } from '../scripts/upload-target.ts' + +vi.mock('../scripts/desktop-edgeone.ts', async importOriginal => ({ + ...await importOriginal(), + purgeDesktopInstaller: vi.fn(), +})) vi.mock('../scripts/desktop-package-environment.mjs', () => ({ loadDesktopPackageEnvironment: vi.fn() })) vi.mock('../scripts/desktop-upload-plan.ts', () => ({ createDesktopUploadPlan: vi.fn() })) @@ -27,7 +33,7 @@ describe('desktop upload command', () => { { environment: 'test', latest: false, tagged: false }, ] as const)('uploads $environment latest=$latest with tagged=$tagged', async ({ environment, latest, tagged }) => { vi.spyOn(process.stdout, 'write').mockReturnValue(true) - const credentials = { DOWNLOAD_TEST_COS_SECRET_ID: 'fixture-id', DOWNLOAD_TEST_COS_SECRET_KEY: 'fixture-key' } + const credentials = { ...productionEnvironment, DSH_DESKTOP_AUTO_UPDATE_ENV: environment, DOWNLOAD_TEST_COS_SECRET_ID: 'fixture-id', DOWNLOAD_TEST_COS_SECRET_KEY: 'fixture-key' } const plan: DesktopUploadPlan = { environment, target: 'win-x64', version: '1.2.3-alpha.4', publicUrl: 'https://desktop-updates.example.com/desktop/dsh-latest-windows-x64.exe', @@ -44,6 +50,15 @@ describe('desktop upload command', () => { expect(createDesktopUploadPlan).toHaveBeenCalledWith('win-x64', { environment: credentials, latest }) expect(uploadDesktopRelease).toHaveBeenCalledOnce() expect(vi.mocked(uploadDesktopRelease).mock.calls[0]![0]).toBe(plan) + if (environment === 'production' && latest) { + expect(purgeDesktopInstaller).toHaveBeenCalledWith( + expect.objectContaining({ publicUrl: plan.publicUrl }), + ) + expect(vi.mocked(purgeDesktopInstaller).mock.invocationCallOrder[0]) + .toBeGreaterThan(vi.mocked(uploadDesktopRelease).mock.invocationCallOrder[0]!) + } else { + expect(purgeDesktopInstaller).not.toHaveBeenCalled() + } if (tagged) { expect(tagDesktopRelease).toHaveBeenCalledWith(expect.objectContaining({ version: plan.version, commit: plan.commit })) expect(vi.mocked(tagDesktopRelease).mock.invocationCallOrder[0]) @@ -54,3 +69,77 @@ describe('desktop upload command', () => { } }) }) + +const productionEnvironment = { + DSH_DESKTOP_AUTO_UPDATE_ENV: 'production', DOWNLOAD_PROD_COS_BUCKET: 'fixture-bucket', + DOWNLOAD_PROD_COS_SECRET_ID: 'fixture-id', DOWNLOAD_PROD_COS_SECRET_KEY: 'fixture-key', DOWNLOAD_PROD_EO_ZONE_ID: 'zone-fixture', +} + +describe('cache-only submission command', () => { + beforeEach(() => { + vi.mocked(loadDesktopPackageEnvironment).mockReturnValue(productionEnvironment) + }) + + it('submits a purge without reading or uploading an installer', async () => { + await uploadDesktopTarget(['--latest', '--purge-only', 'mac-arm64']) + expect(createDesktopUploadPlan).not.toHaveBeenCalled() + expect(uploadDesktopRelease).not.toHaveBeenCalled() + expect(tagDesktopRelease).not.toHaveBeenCalled() + expect(purgeDesktopInstaller).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ + publicUrl: 'https://download.deepseek.com/desktop/dsh-latest-macos-arm64.dmg', + })) + }) + + it.each([[], ['win-x64', '--purge-only'], ['win-x64', '--latest', '--purge-only', '--job-id', 'job-1'], ['win-x64', '--latest', '--', '--purge-only']])('rejects invalid arguments %j before uploading', async (...args) => { + await expect(uploadDesktopTarget(args)).rejects.toThrow() + expect(uploadDesktopRelease).not.toHaveBeenCalled() + expect(purgeDesktopInstaller).not.toHaveBeenCalled() + }) + + it('reports a completed upload when refresh fails, without tagging or repeating the upload', async () => { + const output = vi.spyOn(process.stdout, 'write').mockReturnValue(true) + vi.mocked(createDesktopUploadPlan).mockResolvedValue({ + environment: 'production', target: 'mac-arm64', version: '1.2.3', publicUrl: 'https://example.com/new-prefix/installer.dmg', + bucket: 'fixture-bucket', secretIdEnvName: 'DOWNLOAD_PROD_COS_SECRET_ID', secretKeyEnvName: 'DOWNLOAD_PROD_COS_SECRET_KEY', artifacts: [], + }) + vi.mocked(purgeDesktopInstaller).mockRejectedValue(new DesktopPurgeError('desktop purge: submission not confirmed')) + await expect(uploadDesktopTarget(['mac-arm64', '--latest'])).rejects.toThrow('desktop purge: submission not confirmed') + expect(output).toHaveBeenCalledWith('desktop upload: installer uploaded; submitting cache purge.\n') + expect(uploadDesktopRelease).toHaveBeenCalledOnce() + expect(purgeDesktopInstaller).toHaveBeenCalledOnce() + expect(tagDesktopRelease).not.toHaveBeenCalled() + }) + + it.each([false, true])('reports safe purge diagnostics to the launcher-visible stream: launcher=%s', async (launcher) => { + const previousExitCode = process.exitCode + const stdout = vi.spyOn(process.stdout, 'write').mockReturnValue(true) + const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true) + vi.stubEnv('DSH_DESKTOP_AUTO_UPDATE_ENV', 'production') + vi.stubEnv('DOWNLOAD_PROD_COS_BUCKET', 'fixture-bucket') + vi.stubEnv('DOWNLOAD_PROD_COS_SECRET_ID', 'injected-id') + vi.stubEnv('DOWNLOAD_PROD_COS_SECRET_KEY', 'injected-key') + try { + vi.mocked(purgeDesktopInstaller).mockRejectedValue(new DesktopPurgeError('desktop purge: safe recovery message')) + await runDesktopUploadCommand(['mac-arm64', '--latest', '--purge-only', ...launcher ? ['--credential-launcher', '--environment', 'production', '--bucket', 'fixture-bucket'] : []]) + expect(launcher ? stdout : stderr).toHaveBeenCalledExactlyOnceWith('desktop purge: safe recovery message\n') + expect(launcher ? stderr : stdout).not.toHaveBeenCalled() + expect(process.exitCode).toBe(1) + expect(purgeDesktopInstaller).toHaveBeenCalledWith(expect.objectContaining({ secretId: launcher ? 'injected-id' : 'fixture-id' })) + } finally { + process.exitCode = previousExitCode + vi.unstubAllEnvs() + } + }) + + it('shows usage without referring to a nonexistent record for invalid arguments', async () => { + const previousExitCode = process.exitCode + const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true) + try { + await runDesktopUploadCommand([]) + expect(stderr).toHaveBeenCalledWith(expect.stringContaining('Usage:')) + expect(stderr).not.toHaveBeenCalledWith(expect.stringContaining('record directory')) + } finally { + process.exitCode = previousExitCode + } + }) +}) diff --git a/apps/desktop/tests/upload-with-credentials.spec.ts b/apps/desktop/tests/upload-with-credentials.spec.ts index 8951f4a01b..4bd2bb1336 100644 --- a/apps/desktop/tests/upload-with-credentials.spec.ts +++ b/apps/desktop/tests/upload-with-credentials.spec.ts @@ -12,13 +12,13 @@ const launcher = fileURLToPath(new URL('../scripts/upload-with-credentials.ps1', const roots: string[] = [] const quote = (value: string): string => `'${value.replaceAll("'", "''")}'` -async function check(mode: 'valid' | 'plaintext' | 'blank' | 'missing' | 'upload' | 'upload-latest' | 'upload-failure', deployment = 'production') { +async function check(mode: 'valid' | 'plaintext' | 'blank' | 'missing' | 'upload' | 'upload-latest' | 'upload-failure' | 'upload-purge' | 'invalid-purge', deployment = 'production') { const root = await mkdtemp(join(tmpdir(), 'desktop-credentials-')) roots.push(root) const credentialPath = join(root, 'credential.clixml') let entry = launcher let uploadArguments = '' - if (mode === 'upload' || mode === 'upload-latest' || mode === 'upload-failure') { + if (mode === 'upload' || mode === 'upload-latest' || mode === 'upload-failure' || mode === 'upload-purge' || mode === 'invalid-purge') { const scripts = join(root, 'apps/desktop/scripts') await mkdir(scripts, { recursive: true }) entry = join(scripts, 'upload-with-credentials.ps1') @@ -34,12 +34,12 @@ async function check(mode: 'valid' | 'plaintext' | 'blank' | 'missing' | 'upload assert.equal(process.env.DSH_DESKTOP_WINDOWS_TOKEN_PIN, undefined) assert.equal(process.env.NODE_OPTIONS, undefined) assert.equal(process.argv[2], 'win-x64') - assert.deepEqual(process.argv.slice(3), ['--credential-launcher', '--environment', 'production', '--bucket', 'fixture-bucket'${mode === 'upload-latest' ? ", '--latest'" : ''}]) + assert.deepEqual(process.argv.slice(3), ['--credential-launcher', '--environment', 'production', '--bucket', 'fixture-bucket'${mode === 'upload-purge' ? ", '--latest', '--purge-only'" : mode === 'upload-latest' ? ", '--latest'" : ''}]) console.log('fixture-id fixture-secret') console.error('private service details fixture-secret') process.exit(${mode === 'upload-failure' ? 17 : 0}) `) - uploadArguments = ` -Upload -Target win-x64 -Bucket fixture-bucket${mode === 'upload-latest' ? ' -Latest' : ''}` + uploadArguments = ` -Upload -Target win-x64 -Bucket fixture-bucket${mode === 'upload-purge' ? ' -Latest -PurgeOnly' : mode === 'invalid-purge' ? ' -PurgeOnly' : mode === 'upload-latest' ? ' -Latest' : ''}` } const setup = mode === 'missing' ? '' : ` [pscustomobject]@{ @@ -134,7 +134,14 @@ describe.skipIf(process.platform !== 'win32')('Windows upload credentials', () = expect(result.output).not.toContain('child environment verified') }) - it.each(['upload', 'upload-latest', 'upload-failure'] as const)('isolates the %s child and sanitizes both output streams', async (mode) => { + it('reports invalid purge arguments before decrypting credentials', async () => { + const result = await check('invalid-purge') + expect(result.code).toBe(1) + expect(result.output).toContain('invalid arguments; PurgeOnly requires Latest and production') + expect(result.output).not.toContain('stage=decrypt-file') + }) + + it.each(['upload', 'upload-latest', 'upload-failure', 'upload-purge'] as const)('isolates the %s child and sanitizes both output streams', async (mode) => { const result = await check(mode) expect(result.code, result.output).toBe(mode === 'upload-failure' ? 1 : 0) expect(result.output).toContain('[REDACTED] [REDACTED]') diff --git a/apps/web/package.json b/apps/web/package.json index ad83fe56d6..7e2d1ad835 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -72,7 +72,8 @@ "@deepseek-ai/dsh-experimental-voice-input-bundle": "workspace:*", "@deepseek-ai/dsh-experimental-cot-translation-bundle": "workspace:*", "@deepseek-ai/dsh-experimental-worktree": "workspace:*", - "@deepseek-ai/dsh-experimental-plugin-market-bundle": "workspace:*" + "@deepseek-ai/dsh-experimental-plugin-market-bundle": "workspace:*", + "@deepseek-ai/dsh-experimental-forge-bundle": "workspace:*" }, "dependencies": { "@deepseek-ai/dsh-message-quotes": "workspace:*" diff --git a/apps/web/tests/autolink-cjk-punctuation.e2e.ts b/apps/web/tests/autolink-cjk-punctuation.e2e.ts new file mode 100644 index 0000000000..a9e4e37de9 --- /dev/null +++ b/apps/web/tests/autolink-cjk-punctuation.e2e.ts @@ -0,0 +1,50 @@ +/** A bare URL in a settled CJK reply ends before the following full-width punctuation. */ +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { chromium, type Browser, type Page } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { + assertFixtureInventory, compareOrRefreshGolden, + launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { newEnglishPage, saveFailureShot } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/autolink-cjk-punctuation', import.meta.url)) +const MODE = webSnapshotMode() +const URL_TEXT = 'http://127.0.0.1:8790/' + +describe('web e2e: bare URL before CJK punctuation', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + + beforeAll(async () => { + scaffold = await launchWebScaffold({}) + await seedSession(scaffold, await readFile(join(SNAPSHOT_DIR, 'session.v4.jsonl'), 'utf8'), 'autolink-cjk-punctuation') + browser = await chromium.launch() + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await page.locator('[role="treeitem"]').first().click() + await page.locator('[role="treeitem"]').nth(1).click() + await page.getByRole('link', { name: URL_TEXT, exact: true }).waitFor() + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it('links only the URL and renders the following sentence as text', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-autolink-cjk-punctuation')) + const link = page.getByRole('link', { name: URL_TEXT, exact: true }) + expect(await link.getAttribute('href')).toBe(URL_TEXT) + const prose = await link.locator('..').ariaSnapshot() + await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'reply.expected.md'), prose, MODE) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + await assertFixtureInventory(SNAPSHOT_DIR, ['session.v4.jsonl', 'reply.expected.md']) + }) +}) diff --git a/apps/web/tests/expected/forge-panel/changes-view.expected.md b/apps/web/tests/expected/forge-panel/changes-view.expected.md new file mode 100644 index 0000000000..27d92b67f7 --- /dev/null +++ b/apps/web/tests/expected/forge-panel/changes-view.expected.md @@ -0,0 +1,20 @@ +- img "变更请求状态:需要处理" +- text: "Add the pull request panel #7" +- button "在浏览器中打开" +- button "全部 2 个文件" +- text: +4 −2 +- button "刷新变更" +- button "全部折叠" +- button "左右对比" [pressed] +- button "自动换行" [pressed] +- button "收起 panel.ts" [expanded] +- text: src/panel.ts +2 −2 +- button "panel.ts 的更多操作" +- text: 1 export const n1 = 1 1 export const n1 = 1 2 export const n2 = 2 2 export const n2 = 'two' 3 export const n3 = 3 3 export const n3 = 3 4 export const n4 = 4 4 export const n4 = 4 5 export const n5 = 5 5 export const n5 = 5 +- button "6 行未变更" +- text: 12 export const n12 = 12 12 export const n12 = 12 13 export const n13 = 13 13 export const n13 = 13 14 export const n14 = 14 14 export const n14 = 14 15 export const n15 = 15 15 export const n15 = 'fifteen' 16 export const n16 = 16 16 export const n16 = 16 17 export const n17 = 17 17 export const n17 = 17 18 export const n18 = 18 18 export const n18 = 18 +- button "其余未变更的行" +- button "收起 panel.md" [expanded] +- text: docs/panel.md 新文件 +2 −0 +- button "panel.md 的更多操作" +- text: "1 + # Panel 2 + Shows the change requests of a Session." diff --git a/apps/web/tests/expected/forge-panel/merge-menu.expected.md b/apps/web/tests/expected/forge-panel/merge-menu.expected.md new file mode 100644 index 0000000000..e180c4167d --- /dev/null +++ b/apps/web/tests/expected/forge-panel/merge-menu.expected.md @@ -0,0 +1,7 @@ +- menu: + - text: "将 #9 合并到 main" + - menuitem "压缩合并 把全部提交压缩成一个提交" + - menuitem "创建合并提交 保留全部提交,并新增一个合并提交" + - menuitem "变基合并 把提交逐个变基到 main" + - separator + - menuitem "确认合并" diff --git a/apps/web/tests/expected/forge-panel/merge-stale.expected.md b/apps/web/tests/expected/forge-panel/merge-stale.expected.md new file mode 100644 index 0000000000..c7e3ec5891 --- /dev/null +++ b/apps/web/tests/expected/forge-panel/merge-stale.expected.md @@ -0,0 +1,7 @@ +- menu: + - text: "将 #9 合并到 main" + - menuitem "压缩合并 把全部提交压缩成一个提交" + - menuitem "创建合并提交 保留全部提交,并新增一个合并提交" + - menuitem "变基合并 把提交逐个变基到 main" + - separator + - menuitem "提交已更新,请重新检查后再打开合并菜单" [disabled] diff --git a/apps/web/tests/expected/forge-panel/panel.expected.md b/apps/web/tests/expected/forge-panel/panel.expected.md new file mode 100644 index 0000000000..3450262cd7 --- /dev/null +++ b/apps/web/tests/expected/forge-panel/panel.expected.md @@ -0,0 +1,17 @@ +- text: dsh-{{worktree}} 工作树 · 基于 release +- button "复制工作树路径" +- heading "本会话的变更请求 · 1" [level=3] +- button "刷新状态" +- list: + - listitem: + - img "变更请求状态:需要处理" + - link "Add the pull request panel": + - /url: https://github.com/octo/demo/pull/7 + - text: "#7 feat → main" + - button "在浏览器中打开" + - button "复制变更请求链接" + - 'button "从本会话移除 #7"' + - 'button "收起 #7" [expanded]' + - button "检查 1 项失败 · 1 项通过" + - text: 评审 等待评审 · 1 条未解决讨论 合并 无冲突 + - 'button "查看 #7 的变更:新增 128 行,删除 14 行"': 变更 +128 −14 diff --git a/apps/web/tests/expected/forge-panel/reference.expected.md b/apps/web/tests/expected/forge-panel/reference.expected.md new file mode 100644 index 0000000000..cc5f2e87f6 --- /dev/null +++ b/apps/web/tests/expected/forge-panel/reference.expected.md @@ -0,0 +1,12 @@ +- listitem: + - img "变更请求状态:需要处理" + - link "Add the pull request panel": + - /url: https://github.com/octo/demo/pull/7 + - text: "#7 feat → main" + - button "在浏览器中打开" + - button "复制变更请求链接" + - 'button "从本会话移除 #7"' + - 'button "收起 #7" [expanded]' + - button "检查 1 项失败 · 1 项通过" + - text: 评审 等待评审 · 1 条未解决讨论 合并 无冲突 + - 'button "查看 #7 的变更:新增 128 行,删除 14 行"': 变更 +128 −14 diff --git a/apps/web/tests/expected/forge-panel/session-row-hover.expected.md b/apps/web/tests/expected/forge-panel/session-row-hover.expected.md new file mode 100644 index 0000000000..1e00472353 --- /dev/null +++ b/apps/web/tests/expected/forge-panel/session-row-hover.expected.md @@ -0,0 +1 @@ +- text: "dsh-{{worktree}} 工作树 · 基于 release #7 Add the pull request panel 需要处理 · 检查失败" diff --git a/apps/web/tests/expected/plugin-config/official.expected.md b/apps/web/tests/expected/plugin-config/official.expected.md index 0140faf9af..1060826d8a 100644 --- a/apps/web/tests/expected/plugin-config/official.expected.md +++ b/apps/web/tests/expected/plugin-config/official.expected.md @@ -42,6 +42,11 @@ - text: 实验性 提供“自动审查”访问模式:每次工具调用前,由模型对照你的要求检查,获准的调用立即运行,被拒绝的调用等你审批。 - status - switch "启用 自动授权审查" + - listitem: + - button "查看 变更请求与工作树": 变更请求与工作树 + - text: 实验性 跟踪每个会话的变更请求及其检查与评审,新会话也可以在 Git 工作树中开始;GitHub 通过 gh 命令行访问。 + - status + - switch "启用 变更请求与工作树" - heading "已安装插件" [level=3] - list: - listitem: diff --git a/apps/web/tests/expected/plugin-manager/live-enabled.expected.md b/apps/web/tests/expected/plugin-manager/live-enabled.expected.md index d5cb4aac9a..561ee7ae23 100644 --- a/apps/web/tests/expected/plugin-manager/live-enabled.expected.md +++ b/apps/web/tests/expected/plugin-manager/live-enabled.expected.md @@ -42,6 +42,11 @@ - text: 实验性 提供“自动审查”访问模式:每次工具调用前,由模型对照你的要求检查,获准的调用立即运行,被拒绝的调用等你审批。 - status - switch "启用 自动授权审查" + - listitem: + - button "查看 变更请求与工作树": 变更请求与工作树 + - text: 实验性 跟踪每个会话的变更请求及其检查与评审,新会话也可以在 Git 工作树中开始;GitHub 通过 gh 命令行访问。 + - status + - switch "启用 变更请求与工作树" - heading "已安装插件" [level=3] - list: - listitem: diff --git a/apps/web/tests/expected/plugin-manager/manager.expected.md b/apps/web/tests/expected/plugin-manager/manager.expected.md index 3d0660724f..88dd772e51 100644 --- a/apps/web/tests/expected/plugin-manager/manager.expected.md +++ b/apps/web/tests/expected/plugin-manager/manager.expected.md @@ -42,6 +42,11 @@ - text: 实验性 提供“自动审查”访问模式:每次工具调用前,由模型对照你的要求检查,获准的调用立即运行,被拒绝的调用等你审批。 - status - switch "启用 自动授权审查" + - listitem: + - button "查看 变更请求与工作树": 变更请求与工作树 + - text: 实验性 跟踪每个会话的变更请求及其检查与评审,新会话也可以在 Git 工作树中开始;GitHub 通过 gh 命令行访问。 + - status + - switch "启用 变更请求与工作树" - heading "已安装插件" [level=3] - list: - listitem: diff --git a/apps/web/tests/expected/plugin-manager/refresh.expected.md b/apps/web/tests/expected/plugin-manager/refresh.expected.md index 75dda78845..85d666fd3e 100644 --- a/apps/web/tests/expected/plugin-manager/refresh.expected.md +++ b/apps/web/tests/expected/plugin-manager/refresh.expected.md @@ -6,7 +6,7 @@ Focus at 500ms: - tooltip "刷新" failure: pending - button "刷新" [disabled] -{"busy":"true","spinners":1,"cards":11,"skeletons":0,"alerts":[],"retry":0,"toasts":[]} +{"busy":"true","spinners":1,"cards":12,"skeletons":0,"alerts":[],"retry":0,"toasts":[]} failure: settled - button "刷新" -{"busy":"false","spinners":0,"cards":11,"skeletons":0,"alerts":[],"retry":0,"toasts":["刷新失败,请重试"]} +{"busy":"false","spinners":0,"cards":12,"skeletons":0,"alerts":[],"retry":0,"toasts":["刷新失败,请重试"]} diff --git a/apps/web/tests/forge-panel.e2e.ts b/apps/web/tests/forge-panel.e2e.ts new file mode 100644 index 0000000000..f8c319630c --- /dev/null +++ b/apps/web/tests/forge-panel.e2e.ts @@ -0,0 +1,641 @@ +/** Worktree Sessions and the change request panel through the real optional bundle, a Git checkout, and a scripted GitHub CLI. */ +import { execFile } from 'node:child_process' +import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { homedir, tmpdir } from 'node:os' +import { basename, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { promisify } from 'node:util' +import type {} from '@deepseek-ai/dsh-experimental-forge-session' +import { pluginRecordOf, type SessionEvent } from '@deepseek-ai/dsh-session' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { abbreviateHomePath } from '@deepseek-ai/dsh-util-workspace-path' +import { chromium, type Browser, type Page } from 'playwright' +import { expect, it, onTestFailed, onTestFinished } from 'vitest' +import { + captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, readPersistedEvents, watchConsole, + webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { connectFreshWorkspaceZh, saveFailureShot, writeComposerDraft, ZH_BROWSER_LOCALE } from './support.ts' + +const BUNDLE = fileURLToPath(new URL('../../../packages/experimental/forge-bundle', import.meta.url)) +const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/lifecycle-chrome/session.v4.jsonl', import.meta.url)) +const EXPECTED = fileURLToPath(new URL('./expected/forge-panel', import.meta.url)) +const MODE = webSnapshotMode() +const PULL = 'https://github.com/octo/demo/pull/7' +/** A draft change request that the double lets the panel mark ready and merge. */ +const DRAFT = 'https://github.com/octo/demo/pull/9' +/** Message sent after the worktree is removed, which waits until the worktree is restored. */ +const FOLLOW_UP = 'Summarize the failed check' +/** Reply to {@link FOLLOW_UP}, a model call appended after the borrowed scenario's single one. */ +const FOLLOW_UP_REPLY = 'RESTORED' +const run = promisify(execFile) + +/** + * GitHub CLI double: signed in to github.com, with octo/demo allowing every + * merge method and the viewer's usual one squash. GraphQL serves pull request + * 7 with one failed and one passed check run, one unresolved thread, and a + * required review, and pull request 9, an approved draft whose checks passed. + * `gh pr ready 9` and the REST merge of pull request 9 change what later + * queries report, through `state.json`. `gh pr diff N` prints `pr-N.diff`, or + * fails with `pr-N.refused` on standard error when that file exists, and the raw + * contents API serves `contents.json`, keyed by `path@commit`. Every invocation + * is appended to `calls.jsonl`. + */ +const GH = `#!/usr/bin/env node +import { appendFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +const args = process.argv.slice(2) +const here = dirname(fileURLToPath(import.meta.url)) +appendFileSync(join(here, 'calls.jsonl'), JSON.stringify(args) + '\\n') +const statePath = join(here, 'state.json') +const state = existsSync(statePath) ? JSON.parse(readFileSync(statePath, 'utf8')) : { ready: false, mergedAt: null } +const save = () => { writeFileSync(statePath, JSON.stringify(state)) } +if (args[0] === 'pr' && args[1] === 'view' && args[2] === '7') { + const fields = args[args.indexOf('--json') + 1] + process.stdout.write(JSON.stringify(fields === 'url' + ? { url: 'https://github.com/octo/demo/pull/7' } + : { number: 7, title: 'Add the pull request panel' })) + process.exit(0) +} +if (args[0] === 'pr' && args[1] === 'ready' && args[2] === '9') { + state.ready = true + save() + process.stderr.write('Pull request octo/demo#9 is marked as "ready for review"\\n') + process.exit(0) +} +if (args[0] === 'api' && args[5] === 'repos/octo/demo/pulls/9/merge') { + state.mergedAt = new Date().toISOString() + save() + process.stdout.write(JSON.stringify({ sha: 'c'.repeat(40), merged: true, message: 'Pull Request successfully merged' })) + process.exit(0) +} +if (args[0] === 'pr' && args[1] === 'diff') { + const refused = join(here, 'pr-' + args[2] + '.refused') + if (existsSync(refused)) { + process.stderr.write(readFileSync(refused, 'utf8')) + process.exit(1) + } + process.stdout.write(readFileSync(join(here, 'pr-' + args[2] + '.diff'), 'utf8')) + process.exit(0) +} +if (args[0] === 'api' && args[3] === '-H' && args[4] === 'Accept: application/vnd.github.raw+json') { + const [resource, query] = args[5].split('?') + const path = decodeURIComponent(resource.slice('repos/octo/demo/contents/'.length)) + const text = JSON.parse(readFileSync(join(here, 'contents.json'), 'utf8'))[path + '@' + query.slice('ref='.length)] + if (text === undefined) { + process.stderr.write('gh: Not Found (HTTP 404)\\n') + process.exit(1) + } + process.stdout.write(text) + process.exit(0) +} +if (args[0] === 'auth' && args[1] === 'status') { + process.stdout.write('github.com\\n Logged in to github.com account octo\\n') + process.exit(0) +} +if (args[0] === 'api' && args[1] === 'graphql') { + const query = args.find(arg => arg.startsWith('query=')).slice('query='.length) + const check = (name, conclusion, completedAt) => ({ + __typename: 'CheckRun', name, status: 'COMPLETED', conclusion, + detailsUrl: 'https://github.com/octo/demo/actions/runs/1', startedAt: '2026-10-09T00:00:00Z', completedAt, + checkSuite: { workflowRun: { workflow: { name: 'CI' } } }, + }) + const pulls = { + 7: { + number: 7, url: 'https://github.com/octo/demo/pull/7', title: 'Add the pull request panel', state: 'OPEN', isDraft: false, + updatedAt: '2026-10-09T00:00:00Z', closedAt: null, author: { login: 'octo' }, headRefName: 'feat', headRefOid: 'a'.repeat(40), + baseRefName: 'main', additions: 128, deletions: 14, mergeable: 'MERGEABLE', mergeStateStatus: 'BLOCKED', reviewDecision: 'REVIEW_REQUIRED', + reviewThreads: { nodes: [{ isResolved: false }] }, + commits: { nodes: [{ commit: { statusCheckRollup: { state: 'FAILURE', contexts: { + pageInfo: { hasNextPage: false }, + nodes: [check('lint', 'SUCCESS', '2026-10-09T00:00:42Z'), check('test', 'FAILURE', '2026-10-09T00:02:07Z')], + } } } }] }, + }, + 9: { + number: 9, url: 'https://github.com/octo/demo/pull/9', title: 'Add the merge button', state: state.mergedAt === null ? 'OPEN' : 'MERGED', + isDraft: !state.ready, updatedAt: '2026-10-09T00:00:00Z', closedAt: state.mergedAt, author: { login: 'octo' }, + headRefName: 'feat/merge', headRefOid: state.head ?? 'b'.repeat(40), baseRefName: 'main', additions: 30, deletions: 2, mergeable: 'MERGEABLE', + mergeStateStatus: state.ready ? 'CLEAN' : 'BLOCKED', reviewDecision: 'APPROVED', reviewThreads: { nodes: [] }, + commits: { nodes: [{ commit: { statusCheckRollup: { state: 'SUCCESS', contexts: { + pageInfo: { hasNextPage: false }, nodes: [check('lint', 'SUCCESS', '2026-10-09T00:00:42Z')], + } } } }] }, + }, + } + const settings = { + squashMergeAllowed: true, mergeCommitAllowed: true, rebaseMergeAllowed: true, viewerPermission: 'WRITE', viewerDefaultMergeMethod: 'SQUASH', + } + const data = {} + let repository + for (const match of query.matchAll(/(r\\d+): repository\\(|(p\\d+): pullRequest\\(number: (\\d+)\\)/g)) { + if (match[1] !== undefined) data[repository = match[1]] = { ...settings } + else data[repository][match[2]] = pulls[Number(match[3])] ?? null + } + process.stdout.write(JSON.stringify({ data })) + process.exit(0) +} +process.stderr.write('unexpected gh invocation\\n') +process.exit(1) +` + +/** Head commit of pull request 7 in the GitHub CLI double. */ +const HEAD7 = 'a'.repeat(40) + +/** What gh 2.100 prints for the diff of pull request 9 when GitHub refuses it for having over 300 files. */ +const PR9_REFUSAL = [ + 'could not find pull request diff: HTTP 406: Sorry, the diff exceeded the maximum number of files (300). Consider using \'List pull ' + + 'requests files\' API or locally cloning the repository instead. (https://api.github.com/repos/octo/demo/pulls/9)', + 'PullRequest.diff too_large', + '', +].join('\n') + +/** Line `n` of `src/panel.ts` before (`old`) or after (`new`) pull request 7, which changes lines 2 and 15 of its 20. */ +function panelLine(n: number, side: 'old' | 'new'): string { + if (n === 2) return side === 'old' ? 'export const n2 = 2' : 'export const n2 = \'two\'' + if (n === 15) return side === 'old' ? 'export const n15 = 15' : 'export const n15 = \'fifteen\'' + return `export const n${n} = ${n}` +} + +/** Patch of pull request 7 as `gh pr diff` prints it: two hunks of `src/panel.ts` and the new `docs/panel.md`. */ +const PR7_DIFF = [ + 'diff --git a/src/panel.ts b/src/panel.ts', + 'index 1111111..2222222 100644', + '--- a/src/panel.ts', + '+++ b/src/panel.ts', + '@@ -1,5 +1,5 @@', + ` ${panelLine(1, 'new')}`, `-${panelLine(2, 'old')}`, `+${panelLine(2, 'new')}`, + ...[3, 4, 5].map(n => ` ${panelLine(n, 'new')}`), + '@@ -12,7 +12,7 @@', + ...[12, 13, 14].map(n => ` ${panelLine(n, 'new')}`), `-${panelLine(15, 'old')}`, `+${panelLine(15, 'new')}`, + ...[16, 17, 18].map(n => ` ${panelLine(n, 'new')}`), + 'diff --git a/docs/panel.md b/docs/panel.md', + 'new file mode 100644', + 'index 0000000..3333333', + '--- /dev/null', + '+++ b/docs/panel.md', + '@@ -0,0 +1,2 @@', + '+# Panel', + '+Shows the change requests of a Session.', + '', +].join('\n') + +async function git(cwd: string, ...args: string[]): Promise { + const { stdout } = await run('git', args, { cwd, env: { ...process.env, GIT_CONFIG_NOSYSTEM: '1', GIT_TERMINAL_PROMPT: '0' } }) + return stdout.trim() +} + +/** A Web scaffold with the bundle, a GitHub checkout of `main` and `release`, and the New Session screen of that workspace. */ +interface PanelLaunch { + readonly scaffold: WebScaffold + readonly page: Page + /** Temporary directory of the GitHub CLI double and its call log. */ + readonly root: string + /** The Session's workspace, a main checkout. */ + readonly project: string + readonly tripwire: ReturnType +} + +/** + * Launch the bundle against a GitHub checkout of `main` and `release` and open the New Session screen of that workspace. + * @param failureShot - name of the screenshot saved when the test fails. + * @param scenario - normal conversation, a restoration follow-up, or a numbered PR lookup. + * @returns the scaffold, page, and checkout. + */ +async function launchPanel(failureShot: string, scenario: 'normal' | 'follow-up' | 'reference' = 'normal'): Promise { + const resources: { scaffold?: WebScaffold; browser?: Browser } = {} + const root = await mkdtemp(join(tmpdir(), 'dsh-forge-panel-')) + onTestFinished(async () => { + const failures: unknown[] = [] + await resources.browser?.close().catch((error: unknown) => failures.push(error)) + await resources.scaffold?.close().catch((error: unknown) => failures.push(error)) + await rm(root, { recursive: true, force: true }).catch((error: unknown) => failures.push(error)) + if (failures.length > 0) throw new AggregateError(failures, 'Forge panel teardown failed') + }) + const gh = join(root, 'gh') + await writeFile(gh, GH) + await chmod(gh, 0o755) + await writeFile(join(root, 'pr-7.diff'), PR7_DIFF) + await writeFile(join(root, 'pr-9.refused'), PR9_REFUSAL) + const panelText = `${Array.from({ length: 20 }, (_, index) => panelLine(index + 1, 'new')).join('\n')}\n` + await writeFile(join(root, 'contents.json'), JSON.stringify({ [`src/panel.ts@${HEAD7}`]: panelText })) + const overlay = join(root, 'forge.patch.yml') + await writeFile(overlay, `- id: forge-github\n config: ${JSON.stringify({ ghCommand: gh })}\n`) + const override = join(root, 'replay.override.json') + // The borrowed scenario records one model call, so index 1 appends the reply to the follow-up. + if (scenario === 'follow-up') await writeFile(override, JSON.stringify({ patches: [{ at: 1, entry: { kind: 'chunks', chunks: textReply(FOLLOW_UP_REPLY) } }] })) + if (scenario === 'reference') { + const command = `PATH=${JSON.stringify(`.fixture-bin:${process.env.PATH ?? ''}`)} gh pr view 7 --repo octo/demo --json number,title 2>&1 | head -200` + const call = { type: 'tool-call', id: 'view-pr', name: 'bash', arguments: JSON.stringify({ command, description: 'View PR metadata' }) } + await writeFile(override, JSON.stringify({ patches: [ + { at: 0, entry: { kind: 'chunks', chunks: [ + { type: 'block-start', index: 0, blockType: 'tool-call' }, + { type: 'block-end', index: 0, block: call }, + { type: 'finish', reason: { kind: 'tool-calls' } }, + ] } }, + { at: 1, entry: { kind: 'chunks', chunks: textReply('Viewed PR 7.') } }, + ] })) + } + const scaffold = await launchWebScaffold({ + profile: { packages: [{ dir: BUNDLE, enabled: true }] }, + extraOverlayPath: overlay, + replayFixture: FIXTURE, + ...scenario !== 'normal' ? { replayOverride: override } : {}, + // The worktree notice, worktree records, and bindings are asserted separately from the borrowed conversation. + compareReplaySession: false, + }) + resources.scaffold = scaffold + + const project = join(scaffold.workspaceCwd, 'workspace') + await mkdir(project, { recursive: true }) + await git(project, 'init', '--quiet', '--initial-branch=main') + await git(project, 'config', 'user.name', 'Forge Panel') + await git(project, 'config', 'user.email', 'forge-panel@example.test') + await git(project, 'config', 'commit.gpgsign', 'false') + await writeFile(join(project, 'README.md'), 'demo\n') + await git(project, 'add', 'README.md') + await git(project, 'commit', '--quiet', '-m', 'init') + await git(project, 'remote', 'add', 'origin', 'https://github.com/octo/demo.git') + await git(project, 'branch', 'release') + await git(project, 'commit', '--quiet', '--allow-empty', '-m', 'main only') + if (scenario === 'reference') { + // The model's confined command must own the CLI double's writable call log. + const modelBin = join(project, '.fixture-bin') + await mkdir(modelBin) + await writeFile(join(modelBin, 'gh'), GH) + await chmod(join(modelBin, 'gh'), 0o755) + } + + const browser = await chromium.launch() + resources.browser = browser + const page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE, timezoneId: 'Asia/Shanghai' }) + const tripwire = watchConsole(page) + onTestFailed(async () => { await saveFailureShot(page, failureShot) }) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await connectFreshWorkspaceZh(page, scaffold.workspaceCwd) + return { scaffold, page, root, project, tripwire } +} + +/** Stream chunks of a model reply that is one text block. */ +function textReply(text: string): unknown[] { + return [ + { type: 'block-start', index: 0, blockType: 'text' }, + { type: 'text-delta', index: 0, text }, + { type: 'block-end', index: 0, block: { type: 'text', text } }, + { type: 'usage', usage: { inputTokens: 50, outputTokens: 2, cacheReadTokens: 0, reasoningTokens: 0 } }, + { type: 'finish', reason: { kind: 'stop' } }, + ] +} + +/** + * Launch the panel and choose the worktree option of the fresh Session from `release`. + * @param failureShot - name of the screenshot saved when the test fails. + * @param followUp - whether the replay answers a second model call. + * @returns the scaffold, page, and checkout. + */ +async function requestReleaseWorktree(failureShot: string, followUp = false): Promise { + const launched = await launchPanel(failureShot, followUp ? 'follow-up' : 'normal') + const { page } = launched + const option = page.locator('[data-forge-worktree]') + await option.waitFor({ timeout: 20_000 }) + await option.getByRole('checkbox', { name: '工作树', exact: true }).check() + await option.getByRole('button', { name: '基于 main', exact: true }).click() + await page.getByRole('menuitem', { name: 'release', exact: true }).click() + await option.getByRole('button', { name: '基于 release', exact: true }).waitFor() + return launched +} + +/** + * Send the first prompt of the borrowed scenario. + * @returns the prompt and the turn's settlement, which resolves to the Session id. + */ +async function sendPrompt(page: Page, scaffold: WebScaffold): Promise<{ readonly prompt: string; readonly settled: Promise }> { + const [prompt] = fixtureUserPrompts(await readFile(FIXTURE, 'utf8')) + if (prompt === undefined) throw new Error('The borrowed scenario requires a user prompt') + const settled = scaffold.whenTurnSettled() + const composer = page.locator('[data-composer-input]').first() + await writeComposerDraft(page, composer, prompt) + await composer.press('Enter') + return { prompt, settled } +} + +function worktreeRecords(events: readonly SessionEvent[]): unknown[] { + return events.flatMap((event) => { + const record = pluginRecordOf(event) + return record?.type === 'plugin:client-ui-forge/worktree' ? [record.data] : [] + }) +} + +it.skipIf(MODE === 'record')('automatically shows a numbered PR when gh JSON omits its URL', async () => { + const { scaffold, page, project, tripwire } = await launchPanel('web-e2e-forge-numbered-reference', 'reference') + const { settled } = await sendPrompt(page, scaffold) + const sessionId = await settled + const events = await readPersistedEvents(scaffold, sessionId) + const toolOutput = events.flatMap(event => event.type === 'tool/result' + ? event.data.message.content.flatMap(block => block.type === 'text' ? [block.text] : []) : []).join('\n') + expect(toolOutput).toContain('Add the pull request panel') + const header = page.locator('[data-forge-header]') + await expect.poll(() => header.getAttribute('aria-label'), { timeout: 20_000 }).toBe('本会话的变更请求需要处理') + await header.click() + const change = page.locator(`[data-forge-change="${PULL}"]`) + await change.getByRole('link', { name: 'Add the pull request panel', exact: true }).waitFor({ timeout: 20_000 }) + await change.getByText('等待评审 · 1 条未解决讨论', { exact: true }).waitFor() + await compareOrRefreshGolden(join(EXPECTED, 'reference.expected.md'), + await captureStableAria(page, `[data-forge-change="${PULL}"]`, scaffold.workspaceCwd), MODE) + expect(events.flatMap((event) => { + const record = pluginRecordOf(event) + return record?.type === 'plugin:forge-session/binding' ? [record.data] : [] + })).toEqual([{ action: 'reference', url: PULL }]) + const calls = (await readFile(join(project, '.fixture-bin', 'calls.jsonl'), 'utf8')).trim().split('\n').map(line => JSON.parse(line) as string[]) + expect(calls).toContainEqual(['pr', 'view', '7', '--repo', 'octo/demo', '--json', 'url']) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) +}) + +it.skipIf(MODE === 'record')('starts a Session in a worktree before its first message, follows its change request, and restores the removed worktree', async () => { + const { scaffold, page, root, project, tripwire } = await requestReleaseWorktree('web-e2e-forge-panel', true) + const { settled } = await sendPrompt(page, scaffold) + const sessionId = await settled + await page.getByText('LIGHTHOUSE', { exact: true }).waitFor() + await page.locator('[data-forge-creation]').waitFor({ state: 'detached' }) + await page.locator('[data-forge-worktree]').waitFor({ state: 'detached' }) + + const session = scaffold.ctx.sessions.get(sessionId) + if (session === undefined) throw new Error('The worktree Session must stay loaded') + const directory = scaffold.ctx.workingDirectory.get(session) + const branch = basename(directory) + expect(directory).toBe(join(project, '.agents', 'worktrees', branch)) + expect(branch).toMatch(new RegExp(`^dsh-${sessionId.slice('session-'.length, 'session-'.length + 8)}-[0-9a-f]{4}$`)) + expect(await git(directory, 'rev-parse', '--abbrev-ref', 'HEAD')).toBe(branch) + const baseCommit = await git(project, 'rev-parse', 'release') + expect(await git(directory, 'rev-parse', 'HEAD')).toBe(baseCommit) + const events = await readPersistedEvents(scaffold, sessionId) + expect(worktreeRecords(events)).toEqual([ + { action: 'request', from: 'main' }, + { action: 'request', from: 'release' }, + { action: 'created', worktree: { path: directory, branch, from: 'release', baseCommit, repositoryRoot: project } }, + ]) + // The worktree existed before the turn, so the first model request carried its directory notice. + const created = events.findIndex((event) => { + const record = pluginRecordOf(event) + const data = record?.type === 'plugin:client-ui-forge/worktree' ? record.data : null + return typeof data === 'object' && data !== null && 'action' in data && data.action === 'created' + }) + const turn = events.findIndex(event => event.type === 'turn/start') + const notice = events.findIndex(event => event.type === 'user/message' && event.data.source.kind === 'working-directory') + expect(created).toBeLessThan(turn) + expect(notice).toBeGreaterThan(turn) + expect(notice).toBeLessThan(events.findIndex(event => event.type === 'assistant/message')) + + // A change request bound the way a `gh pr create` result binds one. + scaffold.ctx.forgeSession.track(session, PULL) + const header = page.locator('[data-forge-header]') + await expect.poll(() => header.getAttribute('aria-label'), { timeout: 20_000 }).toBe('本会话的变更请求需要处理') + expect(await header.getAttribute('aria-pressed')).toBe('false') + + // 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]') + const checkout = panel.locator('[data-forge-checkout]') + const checkoutName = checkout.getByText(branch, { exact: true }) + await checkoutName.waitFor({ timeout: 20_000 }) + await checkout.getByText('工作树 · 基于 release', { exact: true }).waitFor() + await checkoutName.hover() + await page.getByRole('tooltip').filter({ hasText: `工作树(基于 release):${abbreviateHomePath(directory, homedir())}` }).waitFor() + await page.mouse.move(0, 0) + const change = panel.locator(`[data-forge-change="${PULL}"]`) + const title = change.getByRole('link', { name: 'Add the pull request panel', exact: true }) + await title.waitFor({ timeout: 20_000 }) + // The only open change request starts expanded. + await change.getByText('等待评审 · 1 条未解决讨论', { exact: true }).waitFor() + // The random branch suffix can read as a duration, so the branch is replaced before the capture normalizes durations. + await compareOrRefreshGolden(join(EXPECTED, 'panel.expected.md'), + await captureStableAria(page, '[data-forge-panel]', scaffold.workspaceCwd, { replacements: [[branch, 'dsh-{{worktree}}']] }), MODE) + const checksRow = change.getByRole('button', { name: /^检查/ }) + await checksRow.click() + const checks = page.getByRole('menu') + await expect.poll(() => checks.getByRole('menuitem').allTextContents()).toEqual([expect.stringMatching(/^test/), expect.stringMatching(/^lint/)]) + await page.keyboard.press('Escape') + await checks.waitFor({ state: 'detached' }) + expect(await checksRow.getAttribute('aria-expanded')).toBe('false') + + const bindings = async () => (await readPersistedEvents(scaffold, sessionId)).flatMap((event) => { + const record = pluginRecordOf(event) + return record?.type === 'plugin:forge-session/binding' ? [record.data] : [] + }) + await change.getByRole('button', { name: '从本会话移除 #7', exact: true }).click() + await change.waitFor({ state: 'detached' }) + await panel.getByText('本会话还没有变更请求', { exact: true }).waitFor() + await page.getByRole('alert').filter({ hasText: '已从本会话移除 #7' }).getByRole('button', { name: '撤销', exact: true }).click() + await title.waitFor({ timeout: 20_000 }) + await expect.poll(bindings).toEqual([{ action: 'track', url: PULL }, { action: 'dismiss', url: PULL }, { action: 'track', url: PULL }]) + + const calls = (await readFile(join(root, 'calls.jsonl'), 'utf8')).trim().split('\n').map(line => JSON.parse(line) as string[]) + expect(calls).toContainEqual(['auth', 'status', '--hostname', 'github.com']) + expect(calls.some(args => args[0] === 'api' && args[1] === 'graphql' && args.includes('--hostname'))).toBe(true) + await header.click() + await expect.poll(() => header.getAttribute('aria-pressed')).toBe('false') + + // Removing the worktree outside the Session holds the next message in the inbox until the worktree is restored. + await git(project, 'worktree', 'remove', '--force', directory) + const composer = page.locator('[data-composer-input]').first() + await writeComposerDraft(page, composer, FOLLOW_UP) + const accepted = page.waitForResponse('**/api/session/prompt') + await composer.press('Enter') + const envelope = await (await accepted).json() as { readonly result: unknown } + expect(envelope.result).toMatchObject({ ok: true }) + const missing = page.locator('[data-forge-missing]') + await missing.getByText(`可以在原位置恢复分支 ${branch} 的工作树。删除前未提交的修改无法恢复`, { exact: true }).waitFor() + await missing.getByText('恢复工作树或在项目目录继续后,消息会接着发送', { exact: true }).waitFor() + await expect.poll(() => checkout.getAttribute('data-missing')).toBe('') + const turns = async (): Promise => (await readPersistedEvents(scaffold, sessionId)).filter(event => event.type === 'turn/start').length + expect(await turns()).toBe(1) + await missing.getByRole('button', { name: '恢复工作树', exact: true }).click() + await missing.waitFor({ state: 'detached', timeout: 20_000 }) + await page.getByRole('alert').filter({ hasText: '工作树已恢复' }).waitFor() + expect(await git(directory, 'rev-parse', '--abbrev-ref', 'HEAD')).toBe(branch) + expect(await git(directory, 'rev-parse', 'HEAD')).toBe(baseCommit) + await expect.poll(() => checkout.getAttribute('data-missing')).toBeNull() + // The waiting message goes on in the restored worktree. + await page.getByText(FOLLOW_UP_REPLY, { exact: true }).waitFor({ timeout: 20_000 }) + expect(await turns()).toBe(2) + expect(scaffold.ctx.workingDirectory.get(session)).toBe(directory) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) +}) + +it.skipIf(MODE === 'record')('keeps the first message waiting when the worktree cannot be created, tries again, and sends it without the worktree', async () => { + const { scaffold, page, project, tripwire } = await requestReleaseWorktree('web-e2e-forge-panel-failure') + await git(project, 'branch', '--quiet', '-D', 'release') + const { settled } = await sendPrompt(page, scaffold) + const card = page.locator('[data-forge-creation="failed"]') + const failure = card.getByRole('region', { name: '创建工作树失败', exact: true }) + await failure.getByText('原因', { exact: true }).waitFor({ timeout: 20_000 }) + const attempted = await failure.getByRole('definition').first().textContent() + expect(attempted).toMatch(/^dsh-[0-9a-f]{8}-[0-9a-f]{4}$/) + await failure.getByText('消息会在工作树建好后发送', { exact: true }).waitFor() + + // Retry makes another attempt with a new branch name while the message keeps waiting. + await card.getByRole('button', { name: '重试', exact: true }).click() + await expect.poll(() => failure.getByRole('definition').first().textContent(), { timeout: 20_000 }).not.toBe(attempted) + + await card.getByRole('button', { name: '不使用工作树', exact: true }).click() + const sessionId = await settled + await page.getByText('LIGHTHOUSE', { exact: true }).waitFor() + await card.waitFor({ state: 'detached' }) + + const session = scaffold.ctx.sessions.get(sessionId) + if (session === undefined) throw new Error('The Session must stay loaded') + expect(scaffold.ctx.workingDirectory.get(session)).toBe(project) + const events = await readPersistedEvents(scaffold, sessionId) + expect(worktreeRecords(events)).toEqual([{ action: 'request', from: 'main' }, { action: 'request', from: 'release' }, { action: 'cancel' }]) + expect(events.filter(event => event.type === 'turn/start')).toHaveLength(1) + expect(events.some(event => event.type === 'user/message' && event.data.source.kind === 'working-directory')).toBe(false) + await page.locator('[data-forge-header]').click({ timeout: 20_000 }) + const checkout = page.locator('[data-forge-panel] [data-forge-checkout]') + const checkoutName = checkout.getByText('main', { exact: true }) + await checkoutName.waitFor({ timeout: 20_000 }) + await checkout.getByText('项目目录', { exact: true }).waitFor() + await checkoutName.hover() + await page.getByRole('tooltip').filter({ hasText: `项目目录:${abbreviateHomePath(project, homedir())}` }).waitFor() + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) +}) + +it.skipIf(MODE === 'record')('marks a draft change request ready for review and merges it with the usual method', async () => { + const { scaffold, page, root, tripwire } = await launchPanel('web-e2e-forge-panel-merge') + const { settled } = await sendPrompt(page, scaffold) + const sessionId = await settled + await page.getByText('LIGHTHOUSE', { exact: true }).waitFor() + const session = scaffold.ctx.sessions.get(sessionId) + if (session === undefined) throw new Error('The Session must stay loaded') + + scaffold.ctx.forgeSession.track(session, DRAFT) + const header = page.locator('[data-forge-header]') + await expect.poll(() => header.getAttribute('aria-label'), { timeout: 20_000 }).toBe('本会话的变更请求') + await header.click() + const change = page.locator(`[data-forge-change="${DRAFT}"]`) + await change.getByRole('link', { name: 'Add the merge button', exact: true }).waitFor({ timeout: 20_000 }) + + // The only open change request starts expanded, so its action is in view. + await change.getByRole('button', { name: '标记为可评审', exact: true }).click() + await page.getByRole('alert').filter({ hasText: '已将 #9 标记为可评审' }).waitFor() + const merge = change.getByRole('button', { name: '合并', exact: true }) + await merge.waitFor({ timeout: 20_000 }) + await merge.click() + const menu = page.getByRole('menu') + await menu.getByText('将 #9 合并到 main', { exact: true }).waitFor() + await compareOrRefreshGolden(join(EXPECTED, 'merge-menu.expected.md'), await captureStableAria(page, '[role="menu"]', scaffold.workspaceCwd), MODE) + await writeFile(join(root, 'state.json'), JSON.stringify({ ready: true, mergedAt: null, head: 'c'.repeat(40) })) + scaffold.ctx.forge.refresh({ repository: { forge: 'github', host: 'github.com', owner: 'octo', name: 'demo' }, number: 9 }) + const stale = menu.getByRole('menuitem', { name: '提交已更新,请重新检查后再打开合并菜单', exact: true }) + await stale.waitFor() + expect(await stale.isDisabled()).toBe(true) + await compareOrRefreshGolden(join(EXPECTED, 'merge-stale.expected.md'), await captureStableAria(page, '[role="menu"]', scaffold.workspaceCwd), MODE) + await merge.click() + await merge.click() + await menu.getByRole('menuitem', { name: '确认合并', exact: true }).click() + await page.getByRole('alert').filter({ hasText: '已合并 #9' }).waitFor() + // Merged, the change request is no longer open, so its card collapses to the merge line. + await change.getByText(/^已合并到 main · /).waitFor({ timeout: 20_000 }) + + const calls = (await readFile(join(root, 'calls.jsonl'), 'utf8')).trim().split('\n').map(line => JSON.parse(line) as string[]) + expect(calls).toContainEqual(['pr', 'ready', '9', '--repo', 'github.com/octo/demo']) + expect(calls).toContainEqual([ + 'api', '--hostname', 'github.com', '--method', 'PUT', 'repos/octo/demo/pulls/9/merge', '-f', 'merge_method=squash', '-f', `sha=${'c'.repeat(40)}`, + ]) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) +}) + +it.skipIf(MODE === 'record')('shows the local changes of the Session by scope and the changes of its change requests, expanding unchanged lines', async () => { + const { scaffold, page, root, project, tripwire } = await launchPanel('web-e2e-forge-panel-changes') + // origin/main stops at the twelve-line README; the branch adds a commit, a staged file, an edit, and an untracked file. + await writeFile(join(project, 'README.md'), Array.from({ length: 12 }, (_, index) => `line ${index + 1}\n`).join('')) + await git(project, 'commit', '--quiet', '-am', 'readme') + await git(project, 'update-ref', 'refs/remotes/origin/main', 'HEAD') + await git(project, 'symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/main') + await mkdir(join(project, 'src')) + await writeFile(join(project, 'src', 'app.ts'), 'export const app = 1\n') + await git(project, 'add', 'src/app.ts') + await git(project, 'commit', '--quiet', '-m', 'app') + await writeFile(join(project, 'src', 'extra.ts'), 'export const extra = 1\n') + await git(project, 'add', 'src/extra.ts') + await writeFile(join(project, 'README.md'), Array.from({ length: 12 }, (_, index) => index === 9 ? 'line ten\n' : `line ${index + 1}\n`).join('')) + await writeFile(join(project, 'notes.md'), 'note\n') + + const { settled } = await sendPrompt(page, scaffold) + const sessionId = await settled + await page.getByText('LIGHTHOUSE', { exact: true }).waitFor() + const session = scaffold.ctx.sessions.get(sessionId) + if (session === undefined) throw new Error('The Session must stay loaded') + const header = page.locator('[data-forge-header]') + await header.click() + // The New Session screen inspected the checkout before these changes, which came from outside the Session. + await page.locator('[data-forge-panel]').getByRole('button', { name: '刷新状态', exact: true }).click() + const card = page.locator('[data-forge-local]') + await card.getByText('HEAD → origin/main', { exact: true }).waitFor({ timeout: 20_000 }) + await card.getByRole('button', { name: '查看本会话的变更:4 个文件,新增 4 行,删除 1 行', exact: true }).click() + + const local = page.locator('[data-forge-changes="local"]') + await local.getByText('与 origin/main 比较,包含未提交的修改', { exact: true }).waitFor({ timeout: 20_000 }) + const files = local.locator('[data-forge-diff-file]') + await expect.poll(() => files.evaluateAll(sections => sections.map(section => section.getAttribute('data-forge-diff-file'))), { timeout: 20_000 }) + .toEqual(['README.md', 'src/app.ts', 'src/extra.ts', 'notes.md']) + await local.locator('[data-forge-diff-file="notes.md"]').getByText('新文件 · 未提交', { exact: true }).waitFor() + const readme = local.locator('[data-forge-diff-file="README.md"]') + await readme.getByRole('button', { name: '6 行未变更', exact: true }).click() + await readme.getByText('line 1', { exact: true }).first().waitFor({ timeout: 20_000 }) + await local.getByRole('button', { name: '分支', exact: true }).click() + await page.getByRole('menuitem', { name: '已暂存', exact: true }).click() + await local.getByText('暂存区与 HEAD 比较', { exact: true }).waitFor() + await expect.poll(() => files.evaluateAll(sections => sections.map(section => section.getAttribute('data-forge-diff-file'))), { timeout: 20_000 }) + .toEqual(['src/extra.ts']) + + // A change request bound the way a `gh pr create` result binds one. + scaffold.ctx.forgeSession.track(session, PULL) + await expect.poll(() => header.getAttribute('aria-pressed'), { timeout: 20_000 }).toBe('false') + await header.click() + const change = page.locator(`[data-forge-change="${PULL}"]`) + await change.getByRole('link', { name: 'Add the pull request panel', exact: true }).waitFor({ timeout: 20_000 }) + await change.getByRole('button', { name: '查看 #7 的变更:新增 128 行,删除 14 行', exact: true }).click() + const view = page.locator('[data-forge-changes="change"]') + const panelFile = view.locator('[data-forge-diff-file="src/panel.ts"]') + await panelFile.getByRole('button', { name: '6 行未变更', exact: true }).waitFor({ timeout: 20_000 }) + await compareOrRefreshGolden(join(EXPECTED, 'changes-view.expected.md'), + await captureStableAria(page, '[data-forge-changes="change"]', scaffold.workspaceCwd), MODE) + await panelFile.getByRole('button', { name: '6 行未变更', exact: true }).click() + await panelFile.getByText('export const n6 = 6', { exact: true }).first().waitFor({ timeout: 20_000 }) + await panelFile.getByRole('button', { name: '2 行未变更', exact: true }).waitFor() + + // GitHub refuses the diff of pull request 9 as too large, so its view offers the web page instead of a retry. + scaffold.ctx.forgeSession.track(session, DRAFT) + await expect.poll(() => header.getAttribute('aria-pressed'), { timeout: 20_000 }).toBe('false') + await header.click() + const draft = page.locator(`[data-forge-change="${DRAFT}"]`) + await draft.getByRole('button', { name: '展开 #9', exact: true }).click() + await draft.getByRole('button', { name: '查看 #9 的变更:新增 30 行,删除 2 行', exact: true }).click({ timeout: 20_000 }) + const refused = page.locator('[data-forge-changes="change"]').filter({ hasText: 'Add the merge button' }) + await refused.getByRole('status').filter({ hasText: '变更太大,无法在这里显示' }).waitFor({ timeout: 20_000 }) + await refused.getByRole('button', { name: '在浏览器中查看', exact: true }).waitFor() + expect(await refused.getByRole('button', { name: '重试', exact: true }).count()).toBe(0) + + const calls = (await readFile(join(root, 'calls.jsonl'), 'utf8')).trim().split('\n').map(line => JSON.parse(line) as string[]) + expect(calls).toContainEqual(['pr', 'diff', '7', '--repo', 'github.com/octo/demo', '--color', 'never']) + expect(calls).toContainEqual(['pr', 'diff', '9', '--repo', 'github.com/octo/demo', '--color', 'never']) + expect(calls).toContainEqual([ + 'api', '--hostname', 'github.com', '-H', 'Accept: application/vnd.github.raw+json', `repos/octo/demo/contents/src/panel.ts?ref=${HEAD7}`, + ]) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) +}) diff --git a/apps/web/tests/plugin-manager.e2e.ts b/apps/web/tests/plugin-manager.e2e.ts index efb336cb09..d968ac13e0 100644 --- a/apps/web/tests/plugin-manager.e2e.ts +++ b/apps/web/tests/plugin-manager.e2e.ts @@ -863,6 +863,7 @@ describe('web e2e: plugin manager', () => { ['@deepseek-ai/dsh-experimental-voice-input-bundle', 1], ['@deepseek-ai/dsh-experimental-plugin-market-bundle', 1], ['@deepseek-ai/dsh-experimental-auto-review', 1], + ['@deepseek-ai/dsh-experimental-forge-bundle', 1], ['@deepseek-ai/dsh-personalized-recommendations', 1], ['@deepseek-ai/dsh-experimental-inspector-profile', 1], ['@deepseek-ai/dsh-experimental-tool-worktree', 1], diff --git a/apps/web/tests/present-svg.e2e.ts b/apps/web/tests/present-svg.e2e.ts index cc698a8186..1d46fc88b8 100644 --- a/apps/web/tests/present-svg.e2e.ts +++ b/apps/web/tests/present-svg.e2e.ts @@ -2,9 +2,11 @@ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' +import { monitorEventLoopDelay } from 'node:perf_hooks' import { fileURLToPath } from 'node:url' import { chromium, type Browser, type Page } from 'playwright' -import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import WebSocket from 'ws' import type {} from '@deepseek-ai/dsh-tool-present/types' import { deriveReplayScript, parseSessionLog } from '@deepseek-ai/dsh-llm-replay' import { @@ -20,6 +22,18 @@ const MODE = webSnapshotMode() const RECORD_PROMPT = '简单画一个 SVG 表示冯诺依曼架构,保存为 von-neumann.svg,并提供独立文件卡片,方便打开。' const FILE = 'von-neumann.svg' +/** Queue counters from the installed ws implementation; payload contents are never recorded. */ +type DiagnosticSocket = WebSocket & { + readonly _sender?: { readonly _state: number; readonly _bufferedBytes: number; readonly _queue: readonly unknown[] } + readonly _receiver?: { readonly _state: number; readonly _bufferedBytes: number } + readonly _socket?: { + readonly bytesRead: number + readonly bytesWritten: number + readonly readableLength: number + readonly writableLength: number + } +} + describe('web e2e: requested SVG is explicitly delivered', () => { let scaffold: WebScaffold let browser: Browser @@ -27,9 +41,40 @@ describe('web e2e: requested SVG is explicitly delivered', () => { let tripwire: ReturnType let cwd: string let replayRoot: string | undefined + let restoreTransportDiagnostics: (() => void) | undefined const connectionDiagnostics: string[] = [] beforeAll(async () => { + const loopDelay = monitorEventLoopDelay({ resolution: 20 }) + loopDelay.enable() + const ping = vi.spyOn(WebSocket.prototype, 'ping') + const terminated = vi.spyOn(WebSocket.prototype, 'terminate').mockImplementation(function (this: WebSocket) { + const socket = this as DiagnosticSocket + connectionDiagnostics.push(`Host termination: ${JSON.stringify({ + readyState: socket.readyState, + bufferedBytes: socket.bufferedAmount, + pingCalls: ping.mock.contexts.filter(context => context === socket).length, + senderState: socket._sender?._state, + senderBufferedBytes: socket._sender?._bufferedBytes, + senderQueueLength: socket._sender?._queue.length, + receiverState: socket._receiver?._state, + receiverBufferedBytes: socket._receiver?._bufferedBytes, + bytesRead: socket._socket?.bytesRead, + bytesWritten: socket._socket?.bytesWritten, + readableLength: socket._socket?.readableLength, + writableLength: socket._socket?.writableLength, + maxEventLoopDelayMs: loopDelay.max / 1e6, + p99EventLoopDelayMs: loopDelay.percentile(99) / 1e6, + })}`) + connectionDiagnostics.push(new Error('Host WebSocket termination').stack ?? 'Host WebSocket termination') + terminated.mockRestore() + this.terminate() + }) + restoreTransportDiagnostics = () => { + terminated.mockRestore() + ping.mockRestore() + loopDelay.disable() + } let replayOverride: string | undefined if (MODE !== 'record') { replayRoot = await mkdtemp(join(tmpdir(), 'dsh-present-svg-replay-')) @@ -53,6 +98,7 @@ describe('web e2e: requested SVG is explicitly delivered', () => { if (message.text().startsWith('[connection]')) connectionDiagnostics.push(message.text()) }) page.on('websocket', (socket) => { + loopDelay.reset() const openedAt = Date.now() let received = 0 socket.on('framereceived', () => { received++ }) @@ -67,6 +113,7 @@ describe('web e2e: requested SVG is explicitly delivered', () => { }) afterAll(async () => { + restoreTransportDiagnostics?.() try { await browser?.close() } finally { diff --git a/apps/web/tests/present.e2e.ts b/apps/web/tests/present.e2e.ts index 6a9a790de7..c8d4abc172 100644 --- a/apps/web/tests/present.e2e.ts +++ b/apps/web/tests/present.e2e.ts @@ -164,6 +164,8 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action, con const revealResponse = page.waitForResponse(response => response.url().includes('action=reveal') && response.request().method() === 'POST') await page.getByRole('menuitem', { name: 'Show file location', exact: true }).click() expect((await revealResponse).status()).toBe(204) + await page.waitForFunction(() => document.querySelector('[data-presented-files-row] button:disabled') === null) + expect(await page.getByRole('alert').count()).toBe(0) await expect.poll(opened).toHaveLength(beforeReveal + 1) expect((await opened()).at(-1)).toEqual({ action: 'reveal', content: null, path: await realpath(process.platform === 'darwin' ? join(cwd, 'report.txt') : cwd) }) for (const [name, bytes] of [['report.txt', 'EDITED_REPORT\n'], ['说明.txt', 'EDITED_NOTE\n']] as const) { @@ -172,34 +174,11 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action, con await row.locator('[data-presented-file]').filter({ hasText: name }).getByRole('button', { name: 'Open in Test Editor', exact: true }).click() expect((await response).status()).toBe(204) await page.waitForFunction(() => document.querySelector('[data-presented-files-row] button:disabled') === null) + expect(await page.getByRole('alert').count()).toBe(0) + expect(await row.locator('[data-presented-file] [role="status"]').count()).toBe(0) expect(await opened()).toHaveLength(count + 1) expect((await opened()).at(-1)).toEqual({ action: 'open', path: await realpath(join(cwd, name)), content: bytes }) } - const success = row.locator('[data-presented-file]').filter({ hasText: '说明.txt' }).locator('[data-success]') - await success.waitFor() - const animation = await success.evaluate((element) => { - const style = getComputedStyle(element) - const fade = element.getAnimations()[0] - if (fade === undefined) throw new Error('Successful file feedback has no fade animation') - // Seek the CSS animation so the 200 ms fade does not race the assertion. - fade.pause() - fade.currentTime = 5100 - return { - delay: style.animationDelay, duration: style.animationDuration, - fill: style.animationFillMode, opacity: Number(getComputedStyle(element).opacity), - } - }) - expect(animation.delay).toBe('5s') - expect(animation.duration).toBe('0.2s') - expect(animation.fill).toBe('forwards') - expect(animation.opacity).toBeGreaterThan(0) - expect(animation.opacity).toBeLessThan(1) - try { - await page.emulateMedia({ reducedMotion: 'reduce' }) - expect(await success.evaluate(element => getComputedStyle(element).animationName)).toBe('none') - } finally { - await page.emulateMedia({ reducedMotion: 'no-preference' }) - } } expect(downloads).toEqual([]) const response = await page.request.get(new URL(`/api/session.export?sessionId=${sessionId}`, scaffold.authenticatedUrl).href) @@ -318,4 +297,64 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action, con expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) }) + + it('announces mobile native opens and reveals on another device, and reports a deleted file', async () => { + const context = await browser.newContext({ + storageState: await page.context().storageState(), + userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 Version/18.6 Mobile/15E148 Safari/604.1', + viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true, locale: 'en-US', timezoneId: 'Asia/Shanghai', + }) + try { + const phone = await context.newPage() + const consoleWatch = watchConsole(phone) + const phoneDownloads: string[] = [] + phone.on('download', (download) => { phoneDownloads.push(download.suggestedFilename()) }) + await phone.goto(page.url(), { waitUntil: 'load' }) + await phone.getByText(/^PRESENT_DONE\.?$/).waitFor({ timeout: 30_000 }) + const card = phone.locator('[data-presented-file]').filter({ hasText: '说明.txt' }) + const open = card.getByRole('button', { name: 'Open in Test Editor', exact: true }) + await open.waitFor() + const notices: string[] = [] + const beforeOpen = (await opened()).length + const openResponse = phone.waitForResponse(response => response.url().includes('/api/present.open?') && response.request().method() === 'POST') + await open.tap() + expect((await openResponse).status()).toBe(204) + const success = phone.getByRole('alert').filter({ hasText: 'Opened on another device' }) + await success.waitFor() + notices.push(`## Open\n\n${await success.ariaSnapshot()}`) + await expect.poll(opened).toHaveLength(beforeOpen + 1) + expect((await opened()).at(-1)).toEqual({ action: 'open', path: await realpath(join(cwd, '说明.txt')), content: 'EDITED_NOTE\n' }) + expect(await card.locator('[role="status"]').count()).toBe(0) + const bounds = await success.boundingBox() + expect(bounds).not.toBeNull() + expect(bounds!.x).toBeGreaterThanOrEqual(0) + expect(bounds!.x + bounds!.width).toBeLessThanOrEqual(390) + + await card.getByRole('button', { name: 'More ways to open', exact: true }).tap() + const revealResponse = phone.waitForResponse(response => response.url().includes('action=reveal') && response.request().method() === 'POST') + await phone.getByRole('menuitem', { name: 'Show file location', exact: true }).tap() + expect((await revealResponse).status()).toBe(204) + const revealed = phone.getByRole('alert').filter({ hasText: 'File location shown on another device' }) + await revealed.waitFor() + notices.push(`## Show file location\n\n${await revealed.ariaSnapshot()}`) + await expect.poll(opened).toHaveLength(beforeOpen + 2) + expect((await opened()).at(-1)).toEqual({ action: 'reveal', content: null, path: await realpath(process.platform === 'darwin' ? join(cwd, '说明.txt') : cwd) }) + + await unlink(join(cwd, '说明.txt')) + const missingResponse = phone.waitForResponse(response => response.url().includes('/api/present.open?') && response.request().method() === 'POST') + await open.tap() + expect((await missingResponse).status()).toBe(404) + const failure = phone.getByRole('alert').filter({ hasText: 'Could not open. Try again.' }) + await failure.waitFor() + notices.push(`## Missing file\n\n${await failure.ariaSnapshot()}`) + await card.getByText('Could not open. Click to retry.', { exact: true }).waitFor() + expect(await opened()).toHaveLength(beforeOpen + 2) + expect(phoneDownloads).toEqual([]) + await compareOrRefreshGolden(join(DIR, 'mobile-open.expected.md'), notices.join('\n\n'), MODE) + expect(consoleWatch.pageErrors).toEqual([]) + expect(consoleWatch.warnings).toEqual([]) + } finally { + await context.close() + } + }) }) diff --git a/apps/web/tests/settings-appearance.e2e.ts b/apps/web/tests/settings-appearance.e2e.ts index 8f51764f88..fa55a444df 100644 --- a/apps/web/tests/settings-appearance.e2e.ts +++ b/apps/web/tests/settings-appearance.e2e.ts @@ -1,4 +1,4 @@ -/** Built settings controls share radius and card materials across account, model, and plugin pages. */ +/** Built settings controls share materials and adapt their geometry to the viewport and pointer. */ import { fileURLToPath } from 'node:url' import { join } from 'node:path' import { chromium, type Locator } from 'playwright' @@ -23,6 +23,109 @@ function appearance(element: Locator) { }) } +function rowGeometry(control: Locator) { + return control.evaluate((node) => { + const row = node.parentElement! + const text = row.firstElementChild! + const description = text.lastElementChild!.getBoundingClientRect() + const rowBox = row.getBoundingClientRect() + const textBox = text.getBoundingClientRect() + const controlBox = node.getBoundingClientRect() + return { + widthDifference: rowBox.width - controlBox.width, + leftDifference: controlBox.left - textBox.left, + rightGap: rowBox.right - controlBox.right, + afterText: controlBox.left - textBox.right, + belowDescription: controlBox.top - description.bottom, + centerDifference: (controlBox.top + controlBox.bottom - textBox.top - textBox.bottom) / 2, + height: controlBox.height, + } + }) +} + +it('stacks font fields at narrow widths while keeping font-size controls on the right', async () => { + const scaffold = await launchWebScaffold({ developerTools: false }) + onTestFinished(() => scaffold.close()) + const browser = await chromium.launch() + onTestFinished(() => browser.close()) + + for (const platform of ['web', 'darwin', 'win32'] as const) { + const context = await browser.newContext({ + viewport: { width: 390, height: 844 }, locale: ZH_BROWSER_LOCALE, hasTouch: platform === 'web', + }) + try { + const page = await context.newPage() + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await openSettings(page, 'zh') + const dialog = page.getByRole('dialog', { name: '设置', exact: true }) + await dialog.getByRole('button', { name: '更多字体设置', exact: true }).click() + await page.evaluate((platform) => { + document.documentElement.toggleAttribute('data-windows-titlebar', platform === 'win32') + if (platform === 'darwin') document.documentElement.setAttribute('data-platform', platform) + }, platform) + + for (const width of platform === 'web' ? [390, 767, 768] : [520, 767, 768]) { + await page.setViewportSize({ width, height: 844 }) + for (const name of ['正文字体', '代码字体', '终端字体']) { + const geometry = await rowGeometry(dialog.getByRole('textbox', { name, exact: true }).locator('..')) + if (width <= 767) { + expect(Math.abs(geometry.widthDifference), `${platform} ${width}px ${name} width`).toBeLessThan(1) + expect(Math.abs(geometry.leftDifference), `${platform} ${width}px ${name} alignment`).toBeLessThan(1) + expect(geometry.belowDescription, `${platform} ${width}px ${name} placement`).toBeGreaterThanOrEqual(0) + expect(geometry.height, `${platform} ${width}px ${name} height`).toBeLessThan(60) + } else { + expect(geometry.afterText, `${platform} ${width}px ${name} placement`).toBeGreaterThanOrEqual(0) + } + } + for (const name of ['增大字号', '增大代码字号', '增大终端字号']) { + const geometry = await rowGeometry(dialog.getByRole('button', { name, exact: true }).locator('../../..')) + expect(geometry.afterText, `${platform} ${width}px ${name} placement`).toBeGreaterThanOrEqual(0) + expect(Math.abs(geometry.rightGap), `${platform} ${width}px ${name} alignment`).toBeLessThan(1) + expect(Math.abs(geometry.centerDifference), `${platform} ${width}px ${name} centering`).toBeLessThan(1) + } + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(width) + } + + const field = dialog.getByRole('textbox', { name: '正文字体', exact: true }) + await field.fill('Uncommitted font draft') + const original = await field.elementHandle() + for (const width of [1440, 390]) { + await page.setViewportSize({ width, height: 844 }) + expect(await original!.evaluate(input => input === document.activeElement && input.isConnected)).toBe(true) + expect(await field.inputValue()).toBe('Uncommitted font draft') + } + } finally { + await context.close() + } + } +}) + +it('uses 16px text for coarse-pointer font fields and preserves compact fine-pointer fields', async () => { + const scaffold = await launchWebScaffold({ developerTools: false }) + onTestFinished(() => scaffold.close()) + const browser = await chromium.launch() + onTestFinished(() => browser.close()) + + for (const hasTouch of [true, false]) { + const context = await browser.newContext({ viewport: { width: 390, height: 844 }, locale: ZH_BROWSER_LOCALE, hasTouch }) + try { + const page = await context.newPage() + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await openSettings(page, 'zh') + const dialog = page.getByRole('dialog', { name: '设置', exact: true }) + await dialog.getByRole('button', { name: '更多字体设置', exact: true }).click() + expect(await page.evaluate(() => matchMedia('(pointer: coarse)').matches)).toBe(hasTouch) + for (const name of ['正文字体', '代码字体', '终端字体']) { + const fontSize = await dialog.getByRole('textbox', { name, exact: true }) + .evaluate(input => getComputedStyle(input).fontSize) + expect(fontSize, name).toBe(hasTouch ? '16px' : '14px') + } + } finally { + await context.close() + } + } +}) + it('shares settings card materials and control sizes in both palettes', async () => { const scaffold = await launchWebScaffold({}) onTestFinished(() => scaffold.close()) diff --git a/apps/web/tests/sidebar-terminal.e2e.ts b/apps/web/tests/sidebar-terminal.e2e.ts index 6709a336ab..6ca14145f2 100644 --- a/apps/web/tests/sidebar-terminal.e2e.ts +++ b/apps/web/tests/sidebar-terminal.e2e.ts @@ -681,7 +681,7 @@ describe.skipIf(process.platform === 'win32')('Web sidebar terminal', () => { await expect.poll(() => page.locator('.xterm-rows:visible').innerText()).toContain('RECONNECT_READY') const transport = await controlTransport(page) await page.reload({ waitUntil: 'load' }) - await expect.poll(() => page.locator('.xterm-rows:visible').innerText()).toContain('RECONNECT_READY') + await page.locator('.xterm-rows:visible').filter({ hasText: 'RECONNECT_READY' }).waitFor({ timeout: 30_000 }) await transport.disconnect() const reconnect = page.getByRole('button', { name: 'Reconnect', exact: true }) await reconnect.waitFor() diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 62e0e67a61..f882829d98 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -53,6 +53,7 @@ "tests/voice-input.e2e.ts", "tests/cot-translation.e2e.ts", "tests/cot-translation-paid.e2e.ts", + "tests/forge-panel.e2e.ts", "tests/voice-setup.e2e.ts", "tests/voice-download.e2e.ts", "tests/prefix-proxy.ts", @@ -163,6 +164,7 @@ "tests/present.e2e.ts", "tests/present-svg.e2e.ts", "tests/present-after-closing-reply.e2e.ts", + "tests/autolink-cjk-punctuation.e2e.ts", "tests/composer-draft-scroll.e2e.ts", "tests/composer-focus.e2e.ts", "tests/composer-placeholder.e2e.ts", diff --git a/benchmarks/user-text/user-text.worker.client.ts b/benchmarks/user-text/user-text.worker.client.ts index 52e10ac919..dbd4f7fd1d 100644 --- a/benchmarks/user-text/user-text.worker.client.ts +++ b/benchmarks/user-text/user-text.worker.client.ts @@ -164,20 +164,22 @@ interface Workload { readonly evaluationsPerSample: number } -/** Named fixed workloads; adversarial inputs appear at two sizes so their scaling is observable. */ -function workloads(): readonly Workload[] { +/** Scaling sizes share adjacent rounds; standalone workloads occupy one group. */ +function workloadGroups(): readonly (readonly Workload[])[] { return [ - { id: 'typical-messages', group: 'unmatched', texts: messages(5_000), evaluationsPerSample: 1 }, + [{ id: 'typical-messages', group: 'unmatched', texts: messages(5_000), evaluationsPerSample: 1 }], ...Object.entries({ prose: PROSE, code: CODE, base64: BASE64, markdown: MARKDOWN, log: LOG, json: JSON_LINES, shell: SHELL }) - .map(([name, unit]): Workload => ({ + .map(([name, unit]): readonly Workload[] => [{ id: `${name}-256k`, group: 'unmatched', texts: [fill(unit, 256 * KIB)], evaluationsPerSample: SINGLE_DOCUMENT_EVALUATIONS, - })), - ...Object.entries(CREDENTIALS).map(([name, credential]): Workload => ({ + }]), + ...Object.entries(CREDENTIALS).map(([name, credential]): readonly Workload[] => [{ id: `messages-with-${name}`, group: 'matched', texts: credentialMessages(5_000, credential), evaluationsPerSample: 1, - })), - { id: 'env-keys-1000', group: 'matched', texts: [envFile(1_000)], evaluationsPerSample: 2 * SINGLE_DOCUMENT_EVALUATIONS }, - { id: 'env-keys-2000', group: 'matched', texts: [envFile(2_000)], evaluationsPerSample: SINGLE_DOCUMENT_EVALUATIONS }, - ...Object.entries(ADVERSARIAL_UNITS).flatMap(([name, unit]): Workload[] => [ + }]), + [ + { id: 'env-keys-1000', group: 'matched', texts: [envFile(1_000)], evaluationsPerSample: 2 * SINGLE_DOCUMENT_EVALUATIONS }, + { id: 'env-keys-2000', group: 'matched', texts: [envFile(2_000)], evaluationsPerSample: SINGLE_DOCUMENT_EVALUATIONS }, + ], + ...Object.entries(ADVERSARIAL_UNITS).map(([name, unit]): readonly Workload[] => [ { id: `adversarial-${name}-64k`, group: 'adversarial', texts: [fill(unit, 64 * KIB)], evaluationsPerSample: 2 * SINGLE_DOCUMENT_EVALUATIONS, @@ -256,25 +258,34 @@ async function evaluatePair(texts: readonly string[], index: number, evaluations return { base: await sample(withoutSecret, texts, evaluations), secret } } -async function measure(texts: readonly string[], attempts: number, evaluations: number): Promise { - for (let warm = 0; warm < WARMUP_PAIRS; warm++) await evaluatePair(texts, warm, evaluations) - const pairs: SamplePair[] = [] - for (let attempt = 0; attempt < attempts; attempt++) { - pairs.push(await evaluatePair(texts, attempt, evaluations)) - } - const best = pairs.reduce((min, pair) => ({ - base: pair.base.ms < min.base.ms ? pair.base : min.base, - secret: pair.secret.ms < min.secret.ms ? pair.secret : min.secret, - })) - return { - ...best, - samples: pairs.map(pair => ({ - baseMs: pair.base.ms, baseCpuMs: pair.base.cpuMs, - withSecretMs: pair.secret.ms, withSecretCpuMs: pair.secret.cpuMs, - })), +})[]> { + const measured = workloads.map(workload => ({ workload, pairs: new Array() })) + for (let round = 0; round < WARMUP_PAIRS + attempts; round++) { + for (let position = 0; position < measured.length; position++) { + const index = round % 2 === 0 ? position : measured.length - 1 - position + const { workload, pairs } = measured[index]! + const pair = await evaluatePair(workload.texts, round, workload.evaluationsPerSample) + if (round >= WARMUP_PAIRS) pairs.push(pair) + } } + return measured.map(({ workload, pairs }) => { + const best = pairs.reduce((min, pair) => ({ + base: pair.base.ms < min.base.ms ? pair.base : min.base, + secret: pair.secret.ms < min.secret.ms ? pair.secret : min.secret, + })) + return { + workload, + ...best, + samples: pairs.map(pair => ({ + baseMs: pair.base.ms, baseCpuMs: pair.base.cpuMs, + withSecretMs: pair.secret.ms, withSecretCpuMs: pair.secret.cpuMs, + })), + } + }) } declare const gc: (() => void) | undefined @@ -366,7 +377,7 @@ async function allocations(): Promise { if (gc === undefined) throw new Error('user-text allocation mode needs --expose-gc') const collect = gc const results: UserTextWorkloadAllocation[] = [] - for (const { id, texts } of workloads()) { + for (const { id, texts } of workloadGroups().flat()) { results.push({ id, ruleAllocatedBytes: await allocatedBytes(texts, collect), ...returned(texts) }) } return { workloads: results } @@ -374,23 +385,24 @@ async function allocations(): Promise { async function timings(attempts: number): Promise { const results: UserTextWorkloadTiming[] = [] - for (const { id, group, texts, evaluationsPerSample } of workloads()) { - const { base, secret, samples } = await measure(texts, attempts, evaluationsPerSample) - results.push({ - id, - group, - documents: texts.length, - characters: texts.reduce((sum, text) => sum + text.length, 0), - evaluationsPerSample, - secrets: secret.secrets, - maskedCharacters: secret.maskedCharacters, - baseMs: base.ms, - baseCpuMs: base.cpuMs, - withSecretMs: secret.ms, - withSecretCpuMs: secret.cpuMs, - ratio: secret.ms / base.ms, - samples, - }) + for (const workloads of workloadGroups()) { + for (const { workload: { id, group, texts, evaluationsPerSample }, base, secret, samples } of await measure(workloads, attempts)) { + results.push({ + id, + group, + documents: texts.length, + characters: texts.reduce((sum, text) => sum + text.length, 0), + evaluationsPerSample, + secrets: secret.secrets, + maskedCharacters: secret.maskedCharacters, + baseMs: base.ms, + baseCpuMs: base.cpuMs, + withSecretMs: secret.ms, + withSecretCpuMs: secret.cpuMs, + ratio: secret.ms / base.ms, + samples, + }) + } } return { warmupPairs: WARMUP_PAIRS, attempts, workloads: results } } diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index fba6972189..cb8f632442 100644 --- a/docs/capability-seams.i18n.yaml +++ b/docs/capability-seams.i18n.yaml @@ -6,5 +6,5 @@ en: 41d772192075e133 zh: 5ef3da69b4de1d8a /capability-seams-and-core-services: - en: 506d61dfa8c71186 - zh: 46f05c4ef2f5c322 + en: cbe99e1985f2deae + zh: 6e524e7a644ae2d6 diff --git a/docs/capability-seams.md b/docs/capability-seams.md index d28f3f3aee..14a1a92890 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -36,6 +36,13 @@ flowchart LR svc_connection["ctx.connection
Authenticated browser transport"] pkg_api_gateway["api-gateway"] pkg_host_frontend_static["host-frontend-static"] + pkg_experimental_forge["experimental-forge"] + svc_forge["ctx.forge
Code host change requests"] + pkg_experimental_forge_github["experimental-forge-github"] + pkg_experimental_forge_session["experimental-forge-session"] + pkg_experimental_client_ui_forge["experimental-client-ui-forge"] + svc_forgeSession["ctx.forgeSession
Session change request bindings"] + svc_forgePanel["ctx.forgePanel
Forge panel Remote"] pkg_experimental_worktree["experimental-worktree"] svc_worktrees["ctx.worktrees
Git worktree creation"] pkg_experimental_tool_worktree["experimental-tool-worktree"] @@ -356,8 +363,12 @@ flowchart LR pkg_experimental_browser_use_stagehand_native --> svc_browserUse pkg_experimental_claude_code_mods --> svc_claudeCodeMods pkg_experimental_client_ui_cot_translation --> svc_cotTranslation + pkg_experimental_client_ui_forge --> svc_forgePanel pkg_experimental_computer_use_cua_driver_mcp --> svc_computerUse pkg_experimental_computer_use_cua_driver_native --> svc_computerUse + pkg_experimental_forge --> svc_forge + pkg_experimental_forge_github --> svc_forge + pkg_experimental_forge_session --> svc_forgeSession pkg_experimental_ptc_runtime_python --> svc_ptcRuntime pkg_experimental_speech_to_text --> svc_speechToText pkg_experimental_speech_to_text_sensevoice --> svc_speechToText @@ -507,6 +518,9 @@ flowchart LR svc_dynamicCordisRunner --> pkg_tool_cordis svc_fileReferences --> pkg_api_session_controller svc_fileUploads --> pkg_api_session_controller + svc_forge --> pkg_experimental_client_ui_forge + svc_forge --> pkg_experimental_forge_session + svc_forgeSession --> pkg_experimental_client_ui_forge svc_fs --> pkg_tool_fs svc_headlessStartup --> pkg_headless svc_hmr --> pkg_app_boot @@ -642,6 +656,9 @@ flowchart LR | `ctx.pluginMarket` | `core` | [`plugin-market`](../packages/boot/plugin-market) | - | [`client-ui-plugin-market`](../packages/client/ui-plugin-market), [`plugin-market`](../packages/boot/plugin-market) | - | Fetches and validates the index sources the user configures; installation goes through the plugin manager. | | `ctx.profileContext` | `core` | [`app-boot`](../packages/boot/app-boot) | - | [`plugin-manager`](../packages/boot/plugin-manager) | - | The dsh launcher supplies data-only profile locations and composition inputs; reload scheduling belongs to dsh-hmr. | | `ctx.connection` | `core` | [`client-connection`](../packages/client/connection) | - | [`api-gateway`](../packages/api/gateway), [`host-frontend-static`](../packages/host/frontend-static) | - | Owns browser authentication and shared HTTP request dispatch; API adapters register endpoints and streams. | +| `ctx.forge` | `seam` | [`experimental-forge`](../packages/experimental/forge) | [`experimental-forge-github`](../packages/experimental/forge-github) | [`experimental-forge-session`](../packages/experimental/forge-session), [`experimental-client-ui-forge`](../packages/experimental/client-ui-forge) | - | Providers map remote and change request URLs, look up change requests by head branch, and read status in batches; watchers of one change request share one polling loop. | +| `ctx.forgeSession` | `service` | [`experimental-forge-session`](../packages/experimental/forge-session) | - | [`experimental-client-ui-forge`](../packages/experimental/client-ui-forge) | - | A Session projection derives bindings from shell results and binding records and publishes their URLs without status to clients; push results trigger head-branch discovery, and Session watchers poll bound change requests through the forge registry. | +| `ctx.forgePanel` | `service` | [`experimental-client-ui-forge`](../packages/experimental/client-ui-forge) | - | - | - | Streams one panel state per followed Session: a read-only Git inspection in the Session sandbox, the recognized code host and its sign-in, and the bound change requests with polled status. | | `ctx.worktrees` | `seam` | [`experimental-worktree`](../packages/experimental/worktree) | [`experimental-worktree`](../packages/experimental/worktree) | [`experimental-tool-worktree`](../packages/experimental/tool-worktree) | - | Explicit experimental creation from a pinned local commit. Existing write permissions govern checkout and shared Git metadata; the working-directory service owns the resulting Session directory. | | `ctx.workingDirectory` | `seam` | [`working-directory`](../packages/session/working-directory) | [`working-directory`](../packages/session/working-directory) | [`tool-working-directory`](../packages/session/tool-working-directory), [`tool-fs`](../packages/fs/tool-fs), [`tool-bash`](../packages/shell/tool-bash), [`tool-pwsh`](../packages/shell/tool-pwsh), [`subagent`](../packages/subagent/subagent), [`sdk-jsonrpc-server`](../packages/sdk/server) | - | One Session projection owns the effective execution directory. The filesystem validates changes; user context reports them while original metadata and write grants stay fixed. | | `ctx.mcpResources` | `seam` | [`mcp-resources`](../packages/mcp/mcp-resources) | [`mcp-client`](../packages/mcp/mcp-client) | [`mcp-resources`](../packages/mcp/mcp-resources) | - | Connection-owned providers serve shared resource tools in the calling agent scope. | diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index 8a2963f3cc..4e517bbab6 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -38,6 +38,13 @@ flowchart LR svc_connection["ctx.connection
Authenticated browser transport"] pkg_api_gateway["api-gateway"] pkg_host_frontend_static["host-frontend-static"] + pkg_experimental_forge["experimental-forge"] + svc_forge["ctx.forge
Code host change requests"] + pkg_experimental_forge_github["experimental-forge-github"] + pkg_experimental_forge_session["experimental-forge-session"] + pkg_experimental_client_ui_forge["experimental-client-ui-forge"] + svc_forgeSession["ctx.forgeSession
Session change request bindings"] + svc_forgePanel["ctx.forgePanel
Forge panel Remote"] pkg_experimental_worktree["experimental-worktree"] svc_worktrees["ctx.worktrees
Git worktree creation"] pkg_experimental_tool_worktree["experimental-tool-worktree"] @@ -358,8 +365,12 @@ flowchart LR pkg_experimental_browser_use_stagehand_native --> svc_browserUse pkg_experimental_claude_code_mods --> svc_claudeCodeMods pkg_experimental_client_ui_cot_translation --> svc_cotTranslation + pkg_experimental_client_ui_forge --> svc_forgePanel pkg_experimental_computer_use_cua_driver_mcp --> svc_computerUse pkg_experimental_computer_use_cua_driver_native --> svc_computerUse + pkg_experimental_forge --> svc_forge + pkg_experimental_forge_github --> svc_forge + pkg_experimental_forge_session --> svc_forgeSession pkg_experimental_ptc_runtime_python --> svc_ptcRuntime pkg_experimental_speech_to_text --> svc_speechToText pkg_experimental_speech_to_text_sensevoice --> svc_speechToText @@ -509,6 +520,9 @@ flowchart LR svc_dynamicCordisRunner --> pkg_tool_cordis svc_fileReferences --> pkg_api_session_controller svc_fileUploads --> pkg_api_session_controller + svc_forge --> pkg_experimental_client_ui_forge + svc_forge --> pkg_experimental_forge_session + svc_forgeSession --> pkg_experimental_client_ui_forge svc_fs --> pkg_tool_fs svc_headlessStartup --> pkg_headless svc_hmr --> pkg_app_boot @@ -644,6 +658,9 @@ flowchart LR | `ctx.pluginMarket` | `core` | [`plugin-market`](../packages/boot/plugin-market) | - | [`client-ui-plugin-market`](../packages/client/ui-plugin-market), [`plugin-market`](../packages/boot/plugin-market) | - | 获取并校验用户配置的索引源;安装通过插件管理器完成。 | | `ctx.profileContext` | `core` | [`app-boot`](../packages/boot/app-boot) | - | [`plugin-manager`](../packages/boot/plugin-manager) | - | dsh launcher 提供纯数据形式的 profile 位置与组合输入;重载调度由 dsh-hmr 负责。 | | `ctx.connection` | `core` | [`client-connection`](../packages/client/connection) | - | [`api-gateway`](../packages/api/gateway), [`host-frontend-static`](../packages/host/frontend-static) | - | 负责浏览器认证与共享 HTTP 请求分发;API 适配器注册端点和流。 | +| `ctx.forge` | `seam` | [`experimental-forge`](../packages/experimental/forge) | [`experimental-forge-github`](../packages/experimental/forge-github) | [`experimental-forge-session`](../packages/experimental/forge-session), [`experimental-client-ui-forge`](../packages/experimental/client-ui-forge) | - | 提供方映射远端与变更请求 URL、按 head 分支查找变更请求并批量读取状态;同一变更请求的监听方共享一个轮询循环。 | +| `ctx.forgeSession` | `service` | [`experimental-forge-session`](../packages/experimental/forge-session) | - | [`experimental-client-ui-forge`](../packages/experimental/client-ui-forge) | - | Session 投影从 shell 结果与绑定记录派生绑定,并把不含状态的 URL 发布给客户端;推送结果触发按 head 分支的发现,Session 监听方通过 forge 注册表轮询已绑定的变更请求。 | +| `ctx.forgePanel` | `service` | [`experimental-client-ui-forge`](../packages/experimental/client-ui-forge) | - | - | - | 为每个被跟随的 Session 推送一份面板状态:在 Session 沙箱中进行的只读 Git 检查、识别出的代码托管平台及其登录状态,以及带轮询状态的已绑定变更请求。 | | `ctx.worktrees` | `seam` | [`experimental-worktree`](../packages/experimental/worktree) | [`experimental-worktree`](../packages/experimental/worktree) | [`experimental-tool-worktree`](../packages/experimental/tool-worktree) | - | 显式实验组合从固定的本地提交创建工作树。检出目录与共享 Git 元数据受现有写权限约束;工作目录服务拥有创建后的 Session 目录。 | | `ctx.workingDirectory` | `seam` | [`working-directory`](../packages/session/working-directory) | [`working-directory`](../packages/session/working-directory) | [`tool-working-directory`](../packages/session/tool-working-directory), [`tool-fs`](../packages/fs/tool-fs), [`tool-bash`](../packages/shell/tool-bash), [`tool-pwsh`](../packages/shell/tool-pwsh), [`subagent`](../packages/subagent/subagent), [`sdk-jsonrpc-server`](../packages/sdk/server) | - | 一个 Session 投影拥有有效执行目录。文件系统验证变更,用户上下文报告目录信息,原始元数据与写入授权保持不变。 | | `ctx.mcpResources` | `seam` | [`mcp-resources`](../packages/mcp/mcp-resources) | [`mcp-client`](../packages/mcp/mcp-client) | [`mcp-resources`](../packages/mcp/mcp-resources) | - | 连接所有者提供的操作在调用 agent 的作用域内服务于共享资源工具。 | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index be0d89caf2..9b0ab3187a 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1027,6 +1027,44 @@ 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:318`](../packages/experimental/client-ui-forge/src/types.ts) + +```ts config-catalog +/** Host settings of the forge panel Remote. */ +export interface Config { + /** Git executable name or absolute path in the Session's execution world. */ + gitCommand?: string + /** Deadline in milliseconds for one Git command, including confinement. */ + gitTimeoutMs?: number + /** Termination grace period in milliseconds for each Git process. */ + graceMs?: number + /** Maximum captured bytes per Git output stream. */ + maxOutputBytes?: number + /** Deadline in milliseconds for one provider sign-in probe. */ + readinessTimeoutMs?: number + /** Most local branches listed as worktree bases. */ + maxBranches?: number + /** + * Prefix of the branch and checkout directory created for a Session worktree; the first eight + * hexadecimal digits of the Session id's UUID, a hyphen, and four random hexadecimal digits follow it. + */ + worktreePrefix?: string + /** Largest diff, or file read to show unchanged lines, in bytes. */ + maxDiffBytes?: number + /** Most untracked files a local diff or the local changes card includes. */ + maxUntrackedFiles?: number + /** Most UTF-8 bytes of change request diffs kept in memory for later reads at the same head commit and base branch. */ + maxCachedDiffBytes?: number +} +``` + + @@ -1066,6 +1104,68 @@ export type Config = CuaConfig ``` + + + +## `@deepseek-ai/dsh-experimental-forge` + +- `source`: [`packages/experimental/forge/src/types.ts:205`](../packages/experimental/forge/src/types.ts) + +```ts config-catalog +/** Poll cadence and fetch deadline for watched change requests. */ +export interface Config { + /** Poll interval in milliseconds while checks run or mergeability is unknown. */ + activeIntervalMs?: number + /** 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 + /** Deadline in milliseconds for one provider fetch. */ + fetchTimeoutMs?: number +} +``` + + + + + +## `@deepseek-ai/dsh-experimental-forge-github` + +- `inject`: `forge` +- `source`: [`packages/experimental/forge-github/src/index.ts:14`](../packages/experimental/forge-github/src/index.ts) + +```ts config-catalog +/** GitHub CLI executable, served hosts, and invocation limits. */ +export interface Config { + /** GitHub CLI executable name or absolute host path. */ + ghCommand?: string + /** Host names this provider serves; list GitHub Enterprise hosts explicitly. */ + hosts?: string[] + /** Deadline in milliseconds for one `gh` invocation. */ + timeoutMs?: number + /** Maximum captured bytes per `gh` output stream. */ + maxOutputBytes?: number +} +``` + + + + + +## `@deepseek-ai/dsh-experimental-forge-session` + +- `inject`: `forge` · `sessions` · `sessionProjections` +- `source`: [`packages/experimental/forge-session/src/types.ts:91`](../packages/experimental/forge-session/src/types.ts) + +```ts config-catalog +/** Deployment settings of `forgeSession`. */ +export interface Config { + /** Deadline in milliseconds for one change request lookup after a push. */ + discoveryTimeoutMs?: number +} +``` + + @@ -1398,7 +1498,7 @@ export type PaidTranslationProvider = 'deepseek-account' | 'deepseek-official' ## `@deepseek-ai/dsh-experimental-worktree` - `inject`: `workingDirectory` · `fs` · `subprocess` · `sandbox` · `sandboxPolicy` -- `source`: [`packages/experimental/worktree/src/index.ts:19`](../packages/experimental/worktree/src/index.ts) +- `source`: [`packages/experimental/worktree/src/index.ts:21`](../packages/experimental/worktree/src/index.ts) ```ts config-catalog /** Worktree location, generated names, executables, and subprocess resource limits. */ @@ -4869,6 +4969,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. | `@deepseek-ai/dsh-experimental-badge-skill-bundle` | — | [`packages/experimental/badge-skill-bundle/src/index.ts`](../packages/experimental/badge-skill-bundle/src/index.ts) | | `@deepseek-ai/dsh-experimental-browser-use-runtime` | — | [`packages/experimental/browser-use-runtime/src/index.ts`](../packages/experimental/browser-use-runtime/src/index.ts) | | `@deepseek-ai/dsh-experimental-cot-translation-bundle` | — | [`packages/experimental/cot-translation-bundle/src/index.ts`](../packages/experimental/cot-translation-bundle/src/index.ts) | +| `@deepseek-ai/dsh-experimental-forge-bundle` | — | [`packages/experimental/forge-bundle/src/index.ts`](../packages/experimental/forge-bundle/src/index.ts) | | `@deepseek-ai/dsh-experimental-inspector-profile` | — | [`packages/experimental/inspector-profile/src/index.ts`](../packages/experimental/inspector-profile/src/index.ts) | | `@deepseek-ai/dsh-experimental-liveshare-protocol` | — | [`packages/experimental/liveshare-protocol/src/index.ts`](../packages/experimental/liveshare-protocol/src/index.ts) | | `@deepseek-ai/dsh-experimental-plugin-market-bundle` | — | [`packages/experimental/plugin-market-bundle/src/index.ts`](../packages/experimental/plugin-market-bundle/src/index.ts) | diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 23165f5f85..0d927cc34b 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -1029,6 +1029,44 @@ 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:318`](../packages/experimental/client-ui-forge/src/types.ts) + +```ts config-catalog +/** Host settings of the forge panel Remote. */ +export interface Config { + /** Git executable name or absolute path in the Session's execution world. */ + gitCommand?: string + /** Deadline in milliseconds for one Git command, including confinement. */ + gitTimeoutMs?: number + /** Termination grace period in milliseconds for each Git process. */ + graceMs?: number + /** Maximum captured bytes per Git output stream. */ + maxOutputBytes?: number + /** Deadline in milliseconds for one provider sign-in probe. */ + readinessTimeoutMs?: number + /** Most local branches listed as worktree bases. */ + maxBranches?: number + /** + * Prefix of the branch and checkout directory created for a Session worktree; the first eight + * hexadecimal digits of the Session id's UUID, a hyphen, and four random hexadecimal digits follow it. + */ + worktreePrefix?: string + /** Largest diff, or file read to show unchanged lines, in bytes. */ + maxDiffBytes?: number + /** Most untracked files a local diff or the local changes card includes. */ + maxUntrackedFiles?: number + /** Most UTF-8 bytes of change request diffs kept in memory for later reads at the same head commit and base branch. */ + maxCachedDiffBytes?: number +} +``` + + @@ -1068,6 +1106,68 @@ export type Config = CuaConfig ``` + + + +## `@deepseek-ai/dsh-experimental-forge` + +- `source`: [`packages/experimental/forge/src/types.ts:205`](../packages/experimental/forge/src/types.ts) + +```ts config-catalog +/** Poll cadence and fetch deadline for watched change requests. */ +export interface Config { + /** Poll interval in milliseconds while checks run or mergeability is unknown. */ + activeIntervalMs?: number + /** 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 + /** Deadline in milliseconds for one provider fetch. */ + fetchTimeoutMs?: number +} +``` + + + + + +## `@deepseek-ai/dsh-experimental-forge-github` + +- `inject`: `forge` +- `source`: [`packages/experimental/forge-github/src/index.ts:14`](../packages/experimental/forge-github/src/index.ts) + +```ts config-catalog +/** GitHub CLI executable, served hosts, and invocation limits. */ +export interface Config { + /** GitHub CLI executable name or absolute host path. */ + ghCommand?: string + /** Host names this provider serves; list GitHub Enterprise hosts explicitly. */ + hosts?: string[] + /** Deadline in milliseconds for one `gh` invocation. */ + timeoutMs?: number + /** Maximum captured bytes per `gh` output stream. */ + maxOutputBytes?: number +} +``` + + + + + +## `@deepseek-ai/dsh-experimental-forge-session` + +- `inject`: `forge` · `sessions` · `sessionProjections` +- `source`: [`packages/experimental/forge-session/src/types.ts:91`](../packages/experimental/forge-session/src/types.ts) + +```ts config-catalog +/** Deployment settings of `forgeSession`. */ +export interface Config { + /** Deadline in milliseconds for one change request lookup after a push. */ + discoveryTimeoutMs?: number +} +``` + + @@ -1400,7 +1500,7 @@ export type PaidTranslationProvider = 'deepseek-account' | 'deepseek-official' ## `@deepseek-ai/dsh-experimental-worktree` - `inject`: `workingDirectory` · `fs` · `subprocess` · `sandbox` · `sandboxPolicy` -- `source`: [`packages/experimental/worktree/src/index.ts:19`](../packages/experimental/worktree/src/index.ts) +- `source`: [`packages/experimental/worktree/src/index.ts:21`](../packages/experimental/worktree/src/index.ts) ```ts config-catalog /** Worktree location, generated names, executables, and subprocess resource limits. */ @@ -4871,6 +4971,7 @@ export interface Config { | `@deepseek-ai/dsh-experimental-badge-skill-bundle` | — | [`packages/experimental/badge-skill-bundle/src/index.ts`](../packages/experimental/badge-skill-bundle/src/index.ts) | | `@deepseek-ai/dsh-experimental-browser-use-runtime` | — | [`packages/experimental/browser-use-runtime/src/index.ts`](../packages/experimental/browser-use-runtime/src/index.ts) | | `@deepseek-ai/dsh-experimental-cot-translation-bundle` | — | [`packages/experimental/cot-translation-bundle/src/index.ts`](../packages/experimental/cot-translation-bundle/src/index.ts) | +| `@deepseek-ai/dsh-experimental-forge-bundle` | — | [`packages/experimental/forge-bundle/src/index.ts`](../packages/experimental/forge-bundle/src/index.ts) | | `@deepseek-ai/dsh-experimental-inspector-profile` | — | [`packages/experimental/inspector-profile/src/index.ts`](../packages/experimental/inspector-profile/src/index.ts) | | `@deepseek-ai/dsh-experimental-liveshare-protocol` | — | [`packages/experimental/liveshare-protocol/src/index.ts`](../packages/experimental/liveshare-protocol/src/index.ts) | | `@deepseek-ai/dsh-experimental-plugin-market-bundle` | — | [`packages/experimental/plugin-market-bundle/src/index.ts`](../packages/experimental/plugin-market-bundle/src/index.ts) | diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 4ae036085f..51396898e2 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -18,8 +18,8 @@ Application listeners are outside this package matrix. Desktop Host subscribes t | `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:429`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`deepseek-account`](../packages/credentials/deepseek-account), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), `server`, `session-controller`, [`session-telemetry`](../packages/session/session-telemetry), [`subagent`](../packages/subagent/subagent) | | `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:328`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs), [`user-questions`](../packages/interaction/user-questions) | | `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:336`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`user-questions`](../packages/interaction/user-questions) | -| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:317`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | -| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:350`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`action-cards`](../packages/experimental/action-cards), [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `session-controller`, [`session-reference`](../packages/context/session-reference), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill) | +| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:317`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | `client-ui-forge`, [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | +| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:350`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`action-cards`](../packages/experimental/action-cards), [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, `client-ui-forge`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `session-controller`, [`session-reference`](../packages/context/session-reference), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill) | | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:389`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:309`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:417`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) | @@ -47,6 +47,7 @@ Application listeners are outside this package matrix. Desktop Host subscribes t | `deepseek-account/signed-out` | `emit` | [`packages/credentials/deepseek-account/src/index.ts:12`](../packages/credentials/deepseek-account/src/index.ts) | [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform) (`emit`) | [`deepseek-account`](../packages/credentials/deepseek-account) | | `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | `workspace-controller` | | `feedback/committed` | `parallel` | [`packages/feedback/message-feedback/src/index.ts:58`](../packages/feedback/message-feedback/src/index.ts) | [`message-feedback`](../packages/feedback/message-feedback) (`parallel`) | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | +| `forge/providers-changed` | `parallel` | [`packages/experimental/forge/src/index.ts:27`](../packages/experimental/forge/src/index.ts) | `forge` (`parallel`) | `forge-session` | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:67`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`workspace-changes`](../packages/deliverables/workspace-changes) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:77`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`skill-filesystem`](../packages/skill/skill-filesystem), `workspace-files` | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:59`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`workspace-changes`](../packages/deliverables/workspace-changes) | @@ -67,8 +68,8 @@ Application listeners are outside this package matrix. Desktop Host subscribes t | `schedule/resolve-agent` | `waterfall` | [`packages/schedule/schedule/src/index.ts:74`](../packages/schedule/schedule/src/index.ts) | [`schedule`](../packages/schedule/schedule) (`waterfall`) | `server` | | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:44`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | | `session/created` | `emit` | [`packages/core/session/src/index.ts:56`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`schedule`](../packages/schedule/schedule), `server`, `session-controller`, [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry) | -| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:65`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:76`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `agent-team`, `claude-code-mods`, [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`loader-smoke`](../packages/test-support/loader-smoke), `product-analytics`, [`ptc-runtime-node`](../packages/ptc-runtime/ptc-runtime-node), `server`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter), [`tool-executor-ptc`](../packages/core/tool-executor-ptc), [`tool-jobs`](../packages/jobs/tool-jobs), [`user-questions`](../packages/interaction/user-questions), [`workspace-changes`](../packages/deliverables/workspace-changes) | +| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:65`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `forge-session`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`workspace-changes`](../packages/deliverables/workspace-changes) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:76`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `agent-team`, `claude-code-mods`, `client-ui-forge`, [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, `forge-session`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`loader-smoke`](../packages/test-support/loader-smoke), `product-analytics`, [`ptc-runtime-node`](../packages/ptc-runtime/ptc-runtime-node), `server`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter), [`tool-executor-ptc`](../packages/core/tool-executor-ptc), [`tool-jobs`](../packages/jobs/tool-jobs), [`user-questions`](../packages/interaction/user-questions), [`workspace-changes`](../packages/deliverables/workspace-changes) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:84`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) | | `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:75`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`emit`) | `remotes` | | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:296`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - | @@ -83,7 +84,7 @@ Application listeners are outside this package matrix. Desktop Host subscribes t | `tools/post-execute` | `waterfall` | [`packages/core/tools/src/events.ts:41`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `claude-code-mods`, [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) | | `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/events.ts:18`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `auto-review`, [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`tool-jobs`](../packages/jobs/tool-jobs), [`workspace-changes`](../packages/deliverables/workspace-changes) | | `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tool-executor-ptc/src/runtime-types.ts:46`](../packages/core/tool-executor-ptc/src/runtime-types.ts) | [`tool-executor-ptc`](../packages/core/tool-executor-ptc) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | -| `tools/result` | `emit` | [`packages/core/tools/src/events.ts:49`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`action-cards`](../packages/experimental/action-cards), [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, [`subagent`](../packages/subagent/subagent), [`tool-present`](../packages/deliverables/tool-present) | +| `tools/result` | `emit` | [`packages/core/tools/src/events.ts:49`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`action-cards`](../packages/experimental/action-cards), [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, `forge-session`, [`subagent`](../packages/subagent/subagent), [`tool-present`](../packages/deliverables/tool-present) | | `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:174`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes`, `server` | | `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:44`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `remote-access`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` | | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) | @@ -105,7 +106,7 @@ Application listeners are outside this package matrix. Desktop Host subscribes t | `internal/config` | - | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | | `internal/dispatch` | - | [`terminal-bash`](../packages/terminal/terminal-bash) | | `internal/plugin` | - | `computer-use-cua-driver-native`, `inspector`, `loader`, [`lsp-stdio`](../packages/lsp/lsp-stdio), [`mcp-client`](../packages/mcp/mcp-client), `modules` | -| `internal/service` | - | `gateway` | +| `internal/service` | - | `client-ui-forge`, `gateway` | | `internal/status` | - | [`agent`](../packages/core/agent), `inspector`, `translator`, [`web`](../packages/web/web) | | `internal/update` | - | [`app-boot`](../packages/boot/app-boot) | | `loader/volatile-update` | - | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-deepseek-api-key`](../packages/llm/llm-deepseek-api-key), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`plugin-market`](../packages/boot/plugin-market), `product-analytics`, `speech-to-text` | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index a385c1f64a..1ecaf2151f 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -20,8 +20,8 @@ | `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:429`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`deepseek-account`](../packages/credentials/deepseek-account), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), `server`, `session-controller`, [`session-telemetry`](../packages/session/session-telemetry), [`subagent`](../packages/subagent/subagent) | | `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:328`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs), [`user-questions`](../packages/interaction/user-questions) | | `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:336`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`user-questions`](../packages/interaction/user-questions) | -| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:317`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | -| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:350`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`action-cards`](../packages/experimental/action-cards), [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `session-controller`, [`session-reference`](../packages/context/session-reference), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill) | +| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:317`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | `client-ui-forge`, [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | +| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:350`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`action-cards`](../packages/experimental/action-cards), [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, `client-ui-forge`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `session-controller`, [`session-reference`](../packages/context/session-reference), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill) | | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:389`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:309`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:417`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) | @@ -49,6 +49,7 @@ | `deepseek-account/signed-out` | `emit` | [`packages/credentials/deepseek-account/src/index.ts:12`](../packages/credentials/deepseek-account/src/index.ts) | [`deepseek-account-platform`](../packages/credentials/deepseek-account-platform) (`emit`) | [`deepseek-account`](../packages/credentials/deepseek-account) | | `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | `workspace-controller` | | `feedback/committed` | `parallel` | [`packages/feedback/message-feedback/src/index.ts:58`](../packages/feedback/message-feedback/src/index.ts) | [`message-feedback`](../packages/feedback/message-feedback) (`parallel`) | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | +| `forge/providers-changed` | `parallel` | [`packages/experimental/forge/src/index.ts:27`](../packages/experimental/forge/src/index.ts) | `forge` (`parallel`) | `forge-session` | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:67`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`workspace-changes`](../packages/deliverables/workspace-changes) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:77`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`skill-filesystem`](../packages/skill/skill-filesystem), `workspace-files` | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:59`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`workspace-changes`](../packages/deliverables/workspace-changes) | @@ -69,8 +70,8 @@ | `schedule/resolve-agent` | `waterfall` | [`packages/schedule/schedule/src/index.ts:74`](../packages/schedule/schedule/src/index.ts) | [`schedule`](../packages/schedule/schedule) (`waterfall`) | `server` | | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:44`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | | `session/created` | `emit` | [`packages/core/session/src/index.ts:56`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`schedule`](../packages/schedule/schedule), `server`, `session-controller`, [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry) | -| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:65`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:76`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `agent-team`, `claude-code-mods`, [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`loader-smoke`](../packages/test-support/loader-smoke), `product-analytics`, [`ptc-runtime-node`](../packages/ptc-runtime/ptc-runtime-node), `server`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter), [`tool-executor-ptc`](../packages/core/tool-executor-ptc), [`tool-jobs`](../packages/jobs/tool-jobs), [`user-questions`](../packages/interaction/user-questions), [`workspace-changes`](../packages/deliverables/workspace-changes) | +| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:65`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `forge-session`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`workspace-changes`](../packages/deliverables/workspace-changes) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:76`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `agent-team`, `claude-code-mods`, `client-ui-forge`, [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, `forge-session`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`loader-smoke`](../packages/test-support/loader-smoke), `product-analytics`, [`ptc-runtime-node`](../packages/ptc-runtime/ptc-runtime-node), `server`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter), [`tool-executor-ptc`](../packages/core/tool-executor-ptc), [`tool-jobs`](../packages/jobs/tool-jobs), [`user-questions`](../packages/interaction/user-questions), [`workspace-changes`](../packages/deliverables/workspace-changes) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:84`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) | | `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:75`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`emit`) | `remotes` | | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:296`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - | @@ -85,7 +86,7 @@ | `tools/post-execute` | `waterfall` | [`packages/core/tools/src/events.ts:41`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `claude-code-mods`, [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) | | `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/events.ts:18`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `auto-review`, [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`tool-jobs`](../packages/jobs/tool-jobs), [`workspace-changes`](../packages/deliverables/workspace-changes) | | `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tool-executor-ptc/src/runtime-types.ts:46`](../packages/core/tool-executor-ptc/src/runtime-types.ts) | [`tool-executor-ptc`](../packages/core/tool-executor-ptc) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | -| `tools/result` | `emit` | [`packages/core/tools/src/events.ts:49`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`action-cards`](../packages/experimental/action-cards), [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, [`subagent`](../packages/subagent/subagent), [`tool-present`](../packages/deliverables/tool-present) | +| `tools/result` | `emit` | [`packages/core/tools/src/events.ts:49`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`action-cards`](../packages/experimental/action-cards), [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, `forge-session`, [`subagent`](../packages/subagent/subagent), [`tool-present`](../packages/deliverables/tool-present) | | `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:174`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes`, `server` | | `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:44`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `remote-access`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` | | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) | @@ -107,7 +108,7 @@ | `internal/config` | - | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | | `internal/dispatch` | - | [`terminal-bash`](../packages/terminal/terminal-bash) | | `internal/plugin` | - | `computer-use-cua-driver-native`, `inspector`, `loader`, [`lsp-stdio`](../packages/lsp/lsp-stdio), [`mcp-client`](../packages/mcp/mcp-client), `modules` | -| `internal/service` | - | `gateway` | +| `internal/service` | - | `client-ui-forge`, `gateway` | | `internal/status` | - | [`agent`](../packages/core/agent), `inspector`, `translator`, [`web`](../packages/web/web) | | `internal/update` | - | [`app-boot`](../packages/boot/app-boot) | | `loader/volatile-update` | - | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-deepseek-api-key`](../packages/llm/llm-deepseek-api-key), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`plugin-market`](../packages/boot/plugin-market), `product-analytics`, `speech-to-text` | diff --git a/docs/experimental-persistence-catalog.i18n.yaml b/docs/experimental-persistence-catalog.i18n.yaml index 7dd24c2aa4..4a5c0e237a 100644 --- a/docs/experimental-persistence-catalog.i18n.yaml +++ b/docs/experimental-persistence-catalog.i18n.yaml @@ -6,5 +6,5 @@ en: a11f106978c9dce7 zh: a11f106978c9dce7 /experimental-plugin-record-catalog: - en: cb931db3b4bcbe33 - zh: aca9cfecfdee34c0 + en: 2e4c95aeb435f35e + zh: 669270a7e95ad6f2 diff --git a/docs/experimental-persistence-catalog.md b/docs/experimental-persistence-catalog.md index b6d3a73de4..902cf83798 100644 --- a/docs/experimental-persistence-catalog.md +++ b/docs/experimental-persistence-catalog.md @@ -14,5 +14,7 @@ Shared Session event declarations and resolved schemas appear in the [Session pe | Record | Owner | Purpose | Payload and source | |---|---|---|---| | `plugin:action-cards/decision` | `@deepseek-ai/dsh-action-cards` | Dismissals and kind-defined progress of action cards. | ActionCardDecision · [`packages/experimental/action-cards/src/types.ts`](../packages/experimental/action-cards/src/types.ts) | +| `plugin:client-ui-forge/worktree` | `@deepseek-ai/dsh-experimental-client-ui-forge` | A request, withdrawal, or creation of the worktree a Session starts in. | WorktreeRecord · [`packages/experimental/client-ui-forge/src/types.ts`](../packages/experimental/client-ui-forge/src/types.ts) | +| `plugin:forge-session/binding` | `@deepseek-ai/dsh-experimental-forge-session` | A caller or discovery action that binds, restores, or hides one change request. | BindingRecord · [`packages/experimental/forge-session/src/types.ts`](../packages/experimental/forge-session/src/types.ts) | | `plugin:translator/request` | `@deepseek-ai/dsh-experimental-translator` | Provider-independent translation input, retained before dispatch. | TranslationRequestRecord · [`packages/experimental/translator/src/types.ts`](../packages/experimental/translator/src/types.ts) | | `plugin:translator/result` | `@deepseek-ai/dsh-experimental-translator` | Successful translation, retained before it is returned to the consumer. | TranslationResultRecord · [`packages/experimental/translator/src/types.ts`](../packages/experimental/translator/src/types.ts) | diff --git a/docs/experimental-persistence-catalog.zh.md b/docs/experimental-persistence-catalog.zh.md index c32cdcabd0..d36c9541ca 100644 --- a/docs/experimental-persistence-catalog.zh.md +++ b/docs/experimental-persistence-catalog.zh.md @@ -14,5 +14,7 @@ | 记录 | 所属包 | 用途 | 载荷与源码 | |---|---|---|---| | `plugin:action-cards/decision` | `@deepseek-ai/dsh-action-cards` | Dismissals and kind-defined progress of action cards. | ActionCardDecision · [`packages/experimental/action-cards/src/types.ts`](../packages/experimental/action-cards/src/types.ts) | +| `plugin:client-ui-forge/worktree` | `@deepseek-ai/dsh-experimental-client-ui-forge` | A request, withdrawal, or creation of the worktree a Session starts in. | WorktreeRecord · [`packages/experimental/client-ui-forge/src/types.ts`](../packages/experimental/client-ui-forge/src/types.ts) | +| `plugin:forge-session/binding` | `@deepseek-ai/dsh-experimental-forge-session` | A caller or discovery action that binds, restores, or hides one change request. | BindingRecord · [`packages/experimental/forge-session/src/types.ts`](../packages/experimental/forge-session/src/types.ts) | | `plugin:translator/request` | `@deepseek-ai/dsh-experimental-translator` | Provider-independent translation input, retained before dispatch. | TranslationRequestRecord · [`packages/experimental/translator/src/types.ts`](../packages/experimental/translator/src/types.ts) | | `plugin:translator/result` | `@deepseek-ai/dsh-experimental-translator` | Successful translation, retained before it is returned to the consumer. | TranslationResultRecord · [`packages/experimental/translator/src/types.ts`](../packages/experimental/translator/src/types.ts) | diff --git a/docs/module-graph.md b/docs/module-graph.md index d42fb2e3c4..d4e25a9bec 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -274,12 +274,17 @@ flowchart TD pkg_experimental_client_ui_agent_team["experimental-client-ui-agent-team"] pkg_experimental_client_ui_claude_code_mods["experimental-client-ui-claude-code-mods"] pkg_experimental_client_ui_cot_translation["experimental-client-ui-cot-translation"] + pkg_experimental_client_ui_forge["experimental-client-ui-forge"] pkg_experimental_client_ui_stats["experimental-client-ui-stats"] pkg_experimental_client_ui_voice_input["experimental-client-ui-voice-input"] pkg_experimental_computer_use_cua_driver_mcp["experimental-computer-use-cua-driver-mcp"] pkg_experimental_computer_use_cua_driver_native["experimental-computer-use-cua-driver-native"] pkg_experimental_cot_translation_bundle["experimental-cot-translation-bundle"] pkg_experimental_cua_macos["experimental-cua-macos"] + pkg_experimental_forge["experimental-forge"] + pkg_experimental_forge_bundle["experimental-forge-bundle"] + pkg_experimental_forge_github["experimental-forge-github"] + pkg_experimental_forge_session["experimental-forge-session"] pkg_experimental_inspector["experimental-inspector"] pkg_experimental_inspector_profile["experimental-inspector-profile"] pkg_experimental_liveshare_protocol["experimental-liveshare-protocol"] @@ -521,6 +526,8 @@ flowchart TD pkg_hmr --> pkg_cmdline pkg_deliverable_file_references --> pkg_system_prompt pkg_experimental_computer_use_cua_driver_mcp --> pkg_computer_use + pkg_experimental_forge_github --> pkg_experimental_forge + pkg_experimental_forge_github --> pkg_subprocess pkg_experimental_webworker_runtime --> pkg_client_connection pkg_experimental_webworker_runtime --> pkg_client_modules pkg_experimental_webworker_runtime --> pkg_host_webserver @@ -959,6 +966,11 @@ flowchart TD pkg_experimental_browser_use_stagehand_native --> pkg_browser_use pkg_experimental_browser_use_stagehand_native --> pkg_system_prompt pkg_experimental_browser_use_stagehand_native --> pkg_tools + pkg_experimental_client_ui_forge --> pkg_experimental_forge + pkg_experimental_client_ui_forge --> pkg_experimental_worktree + pkg_experimental_client_ui_forge --> pkg_fs + pkg_experimental_client_ui_forge --> pkg_session + pkg_experimental_client_ui_forge --> pkg_timeout pkg_experimental_computer_use_cua_driver_native --> pkg_computer_use pkg_experimental_computer_use_cua_driver_native --> pkg_system_prompt pkg_experimental_computer_use_cua_driver_native --> pkg_tools @@ -1116,6 +1128,13 @@ flowchart TD pkg_experimental_auto_review --> pkg_session pkg_experimental_auto_review --> pkg_tools pkg_experimental_auto_review --> pkg_working_directory + pkg_experimental_forge_session --> pkg_experimental_forge + pkg_experimental_forge_session --> pkg_sandbox_policy + pkg_experimental_forge_session --> pkg_session + pkg_experimental_forge_session --> pkg_session_projection + pkg_experimental_forge_session --> pkg_shell + pkg_experimental_forge_session --> pkg_shell_env + pkg_experimental_forge_session --> pkg_tools pkg_webhook --> pkg_agent pkg_webhook --> pkg_agent_default_model pkg_webhook --> pkg_agent_preset_registry @@ -1591,6 +1610,8 @@ flowchart TD | [`experimental-client-ui-stats`](../packages/experimental/client-ui-stats) | `experimental` | — | | [`experimental-client-ui-voice-input`](../packages/experimental/client-ui-voice-input) | `experimental` | — | | [`experimental-cot-translation-bundle`](../packages/experimental/cot-translation-bundle) | `experimental` | — | +| [`experimental-forge`](../packages/experimental/forge) | `experimental` | — | +| [`experimental-forge-bundle`](../packages/experimental/forge-bundle) | `experimental` | — | | [`experimental-inspector`](../packages/experimental/inspector) | `experimental` | — | | [`experimental-inspector-profile`](../packages/experimental/inspector-profile) | `experimental` | — | | [`experimental-liveshare-protocol`](../packages/experimental/liveshare-protocol) | `experimental` | — | @@ -1653,6 +1674,7 @@ flowchart TD | [`hmr`](../packages/boot/hmr) | `boot` | [`app-boot`](../packages/boot/app-boot), [`cmdline`](../packages/boot/cmdline) | | [`deliverable-file-references`](../packages/deliverables/deliverable-file-references) | `deliverables` | [`system-prompt`](../packages/core/system-prompt) | | [`experimental-computer-use-cua-driver-mcp`](../packages/experimental/computer-use-cua-driver-mcp) | `experimental` | [`computer-use`](../packages/computer-use/computer-use) | +| [`experimental-forge-github`](../packages/experimental/forge-github) | `experimental` | [`experimental-forge`](../packages/experimental/forge), [`subprocess`](../packages/subprocess/subprocess) | | [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver) | | [`plugin-recommendations`](../packages/experimental/plugin-recommendations) | `experimental` | [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`typert-protocol`](../packages/typert/protocol) | | [`skill-badge`](../packages/experimental/skill-badge) | `experimental` | [`skill`](../packages/skill/skill) | @@ -1762,6 +1784,7 @@ flowchart TD | [`experimental-browser-use-chrome-devtools-mcp`](../packages/experimental/browser-use-chrome-devtools-mcp) | `experimental` | [`agent`](../packages/core/agent), [`browser-use`](../packages/browser-use/browser-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`experimental-browser-use-playwright-mcp`](../packages/experimental/browser-use-playwright-mcp) | `experimental` | [`agent`](../packages/core/agent), [`browser-use`](../packages/browser-use/browser-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`experimental-browser-use-stagehand-native`](../packages/experimental/browser-use-stagehand-native) | `experimental` | [`agent`](../packages/core/agent), [`browser-use`](../packages/browser-use/browser-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`experimental-client-ui-forge`](../packages/experimental/client-ui-forge) | `experimental` | [`experimental-forge`](../packages/experimental/forge), [`experimental-worktree`](../packages/experimental/worktree), [`fs`](../packages/fs/fs), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`experimental-computer-use-cua-driver-native`](../packages/experimental/computer-use-cua-driver-native) | `experimental` | [`computer-use`](../packages/computer-use/computer-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`experimental-cua-macos`](../packages/experimental/cua-macos) | `experimental` | [`agent`](../packages/core/agent), [`computer-use`](../packages/computer-use/computer-use), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-questions`](../packages/interaction/user-questions) | | [`experimental-tool-worktree`](../packages/experimental/tool-worktree) | `experimental` | [`experimental-worktree`](../packages/experimental/worktree), [`tools`](../packages/core/tools) | @@ -1793,6 +1816,7 @@ flowchart TD | [`api-settings-controller`](../packages/api/settings-controller) | `api` | [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`credentials`](../packages/credentials/credentials), [`native-command`](../packages/util/native-command), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`typert-protocol`](../packages/typert/protocol) | | [`web-app`](../packages/bundle/web-app) | `bundle` | [`shell-env`](../packages/shell/shell-env) | | [`experimental-auto-review`](../packages/experimental/auto-review) | `experimental` | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | +| [`experimental-forge-session`](../packages/experimental/forge-session) | `experimental` | [`experimental-forge`](../packages/experimental/forge), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`tools`](../packages/core/tools) | | [`webhook`](../packages/experimental/webhook) | `experimental` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`workspace`](../packages/workspace/workspace) | | [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`app-boot`](../packages/boot/app-boot), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`tools`](../packages/core/tools) | | [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`brand`](../packages/util/brand), [`typert-protocol`](../packages/typert/protocol) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 29ae06cdd7..289e407bb8 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -276,12 +276,17 @@ flowchart TD pkg_experimental_client_ui_agent_team["experimental-client-ui-agent-team"] pkg_experimental_client_ui_claude_code_mods["experimental-client-ui-claude-code-mods"] pkg_experimental_client_ui_cot_translation["experimental-client-ui-cot-translation"] + pkg_experimental_client_ui_forge["experimental-client-ui-forge"] pkg_experimental_client_ui_stats["experimental-client-ui-stats"] pkg_experimental_client_ui_voice_input["experimental-client-ui-voice-input"] pkg_experimental_computer_use_cua_driver_mcp["experimental-computer-use-cua-driver-mcp"] pkg_experimental_computer_use_cua_driver_native["experimental-computer-use-cua-driver-native"] pkg_experimental_cot_translation_bundle["experimental-cot-translation-bundle"] pkg_experimental_cua_macos["experimental-cua-macos"] + pkg_experimental_forge["experimental-forge"] + pkg_experimental_forge_bundle["experimental-forge-bundle"] + pkg_experimental_forge_github["experimental-forge-github"] + pkg_experimental_forge_session["experimental-forge-session"] pkg_experimental_inspector["experimental-inspector"] pkg_experimental_inspector_profile["experimental-inspector-profile"] pkg_experimental_liveshare_protocol["experimental-liveshare-protocol"] @@ -523,6 +528,8 @@ flowchart TD pkg_hmr --> pkg_cmdline pkg_deliverable_file_references --> pkg_system_prompt pkg_experimental_computer_use_cua_driver_mcp --> pkg_computer_use + pkg_experimental_forge_github --> pkg_experimental_forge + pkg_experimental_forge_github --> pkg_subprocess pkg_experimental_webworker_runtime --> pkg_client_connection pkg_experimental_webworker_runtime --> pkg_client_modules pkg_experimental_webworker_runtime --> pkg_host_webserver @@ -961,6 +968,11 @@ flowchart TD pkg_experimental_browser_use_stagehand_native --> pkg_browser_use pkg_experimental_browser_use_stagehand_native --> pkg_system_prompt pkg_experimental_browser_use_stagehand_native --> pkg_tools + pkg_experimental_client_ui_forge --> pkg_experimental_forge + pkg_experimental_client_ui_forge --> pkg_experimental_worktree + pkg_experimental_client_ui_forge --> pkg_fs + pkg_experimental_client_ui_forge --> pkg_session + pkg_experimental_client_ui_forge --> pkg_timeout pkg_experimental_computer_use_cua_driver_native --> pkg_computer_use pkg_experimental_computer_use_cua_driver_native --> pkg_system_prompt pkg_experimental_computer_use_cua_driver_native --> pkg_tools @@ -1118,6 +1130,13 @@ flowchart TD pkg_experimental_auto_review --> pkg_session pkg_experimental_auto_review --> pkg_tools pkg_experimental_auto_review --> pkg_working_directory + pkg_experimental_forge_session --> pkg_experimental_forge + pkg_experimental_forge_session --> pkg_sandbox_policy + pkg_experimental_forge_session --> pkg_session + pkg_experimental_forge_session --> pkg_session_projection + pkg_experimental_forge_session --> pkg_shell + pkg_experimental_forge_session --> pkg_shell_env + pkg_experimental_forge_session --> pkg_tools pkg_webhook --> pkg_agent pkg_webhook --> pkg_agent_default_model pkg_webhook --> pkg_agent_preset_registry @@ -1593,6 +1612,8 @@ flowchart TD | [`experimental-client-ui-stats`](../packages/experimental/client-ui-stats) | `experimental` | — | | [`experimental-client-ui-voice-input`](../packages/experimental/client-ui-voice-input) | `experimental` | — | | [`experimental-cot-translation-bundle`](../packages/experimental/cot-translation-bundle) | `experimental` | — | +| [`experimental-forge`](../packages/experimental/forge) | `experimental` | — | +| [`experimental-forge-bundle`](../packages/experimental/forge-bundle) | `experimental` | — | | [`experimental-inspector`](../packages/experimental/inspector) | `experimental` | — | | [`experimental-inspector-profile`](../packages/experimental/inspector-profile) | `experimental` | — | | [`experimental-liveshare-protocol`](../packages/experimental/liveshare-protocol) | `experimental` | — | @@ -1655,6 +1676,7 @@ flowchart TD | [`hmr`](../packages/boot/hmr) | `boot` | [`app-boot`](../packages/boot/app-boot), [`cmdline`](../packages/boot/cmdline) | | [`deliverable-file-references`](../packages/deliverables/deliverable-file-references) | `deliverables` | [`system-prompt`](../packages/core/system-prompt) | | [`experimental-computer-use-cua-driver-mcp`](../packages/experimental/computer-use-cua-driver-mcp) | `experimental` | [`computer-use`](../packages/computer-use/computer-use) | +| [`experimental-forge-github`](../packages/experimental/forge-github) | `experimental` | [`experimental-forge`](../packages/experimental/forge), [`subprocess`](../packages/subprocess/subprocess) | | [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver) | | [`plugin-recommendations`](../packages/experimental/plugin-recommendations) | `experimental` | [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`typert-protocol`](../packages/typert/protocol) | | [`skill-badge`](../packages/experimental/skill-badge) | `experimental` | [`skill`](../packages/skill/skill) | @@ -1764,6 +1786,7 @@ flowchart TD | [`experimental-browser-use-chrome-devtools-mcp`](../packages/experimental/browser-use-chrome-devtools-mcp) | `experimental` | [`agent`](../packages/core/agent), [`browser-use`](../packages/browser-use/browser-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`experimental-browser-use-playwright-mcp`](../packages/experimental/browser-use-playwright-mcp) | `experimental` | [`agent`](../packages/core/agent), [`browser-use`](../packages/browser-use/browser-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`experimental-browser-use-stagehand-native`](../packages/experimental/browser-use-stagehand-native) | `experimental` | [`agent`](../packages/core/agent), [`browser-use`](../packages/browser-use/browser-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`experimental-client-ui-forge`](../packages/experimental/client-ui-forge) | `experimental` | [`experimental-forge`](../packages/experimental/forge), [`experimental-worktree`](../packages/experimental/worktree), [`fs`](../packages/fs/fs), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`experimental-computer-use-cua-driver-native`](../packages/experimental/computer-use-cua-driver-native) | `experimental` | [`computer-use`](../packages/computer-use/computer-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`experimental-cua-macos`](../packages/experimental/cua-macos) | `experimental` | [`agent`](../packages/core/agent), [`computer-use`](../packages/computer-use/computer-use), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-questions`](../packages/interaction/user-questions) | | [`experimental-tool-worktree`](../packages/experimental/tool-worktree) | `experimental` | [`experimental-worktree`](../packages/experimental/worktree), [`tools`](../packages/core/tools) | @@ -1795,6 +1818,7 @@ flowchart TD | [`api-settings-controller`](../packages/api/settings-controller) | `api` | [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`credentials`](../packages/credentials/credentials), [`native-command`](../packages/util/native-command), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`typert-protocol`](../packages/typert/protocol) | | [`web-app`](../packages/bundle/web-app) | `bundle` | [`shell-env`](../packages/shell/shell-env) | | [`experimental-auto-review`](../packages/experimental/auto-review) | `experimental` | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | +| [`experimental-forge-session`](../packages/experimental/forge-session) | `experimental` | [`experimental-forge`](../packages/experimental/forge), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`tools`](../packages/core/tools) | | [`webhook`](../packages/experimental/webhook) | `experimental` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`workspace`](../packages/workspace/workspace) | | [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`app-boot`](../packages/boot/app-boot), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`tools`](../packages/core/tools) | | [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`brand`](../packages/util/brand), [`typert-protocol`](../packages/typert/protocol) | diff --git a/docs/subsystems/README.i18n.yaml b/docs/subsystems/README.i18n.yaml index ec9d159bbb..7f6b7de64c 100644 --- a/docs/subsystems/README.i18n.yaml +++ b/docs/subsystems/README.i18n.yaml @@ -3,5 +3,5 @@ # After editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/README.md /subsystems: - en: 1d4818dbb07391a7 - zh: f1e27ce66c181295 + en: dcbb2f834453e166 + zh: d73f32a7baeec42d diff --git a/docs/subsystems/README.md b/docs/subsystems/README.md index c2a47fb70b..e14535c2f6 100644 --- a/docs/subsystems/README.md +++ b/docs/subsystems/README.md @@ -20,6 +20,7 @@ One page per subsystem of the DeepSeek Harness: what it is, the data structures | [session.md](session.md) | the full `SessionEventMap` variant catalog, `TurnEndReason`, `deriveMessages()`, execution enclosure, and standalone events | | [working-directory.md](working-directory.md) | Session execution directories, durable changes, recovery, and user context | | [worktrees.md](worktrees.md) | Experimental Git branch and checkout creation under existing permissions | +| [forge.md](forge.md) | Experimental code host change requests: provider contract, shared status polling, and Session bindings | | [persistence.md](persistence.md) | the durability seam: `SessionPersistence`, the JSONL provider, `session/flush`, crash recovery, `SessionHeader` | | [settings.md](settings.md) | the user-settings seam: `SettingsNamespace` registration, layered resolution (defaults → composition `base` → user document), owner scopes, hot commits | | [credentials.md](credentials.md) | the credential seam: `CredentialRef` references (never values) in configuration, per-operation resolution, UI-safe `CredentialInfo`, provider source layers | diff --git a/docs/subsystems/README.zh.md b/docs/subsystems/README.zh.md index a1380713bc..6239e5f2c0 100644 --- a/docs/subsystems/README.zh.md +++ b/docs/subsystems/README.zh.md @@ -20,6 +20,7 @@ | [session.md](session.zh.md) | 完整的 `SessionEventMap` 变体目录、`TurnEndReason`、`deriveMessages()`、执行封闭与独立事件 | | [working-directory.md](working-directory.zh.md) | Session 执行目录、持久化变更、恢复与用户上下文 | | [worktrees.md](worktrees.zh.md) | 现有权限下的实验性 Git 分支与检出目录创建 | +| [forge.md](forge.zh.md) | 实验性代码托管变更请求:提供方契约、共享状态轮询与 Session 绑定 | | [persistence.md](persistence.zh.md) | 持久性 seam:`SessionPersistence`、JSONL 提供方、`session/flush`、崩溃恢复、`SessionHeader` | | [settings.md](settings.zh.md) | 用户设置 seam:`SettingsNamespace` 注册、分层解析(默认值 → 组合 `base` → 用户文档)、owner scope、热提交 | | [credentials.md](credentials.zh.md) | 凭据 seam:配置中的 `CredentialRef` 引用(绝不含值)、按操作解析、对 UI 安全的 `CredentialInfo`、提供方来源层 | diff --git a/docs/subsystems/forge.i18n.yaml b/docs/subsystems/forge.i18n.yaml new file mode 100644 index 0000000000..53a7964033 --- /dev/null +++ b/docs/subsystems/forge.i18n.yaml @@ -0,0 +1,22 @@ +# Bilingual-pair consistency record for forge.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/subsystems/forge.md +/forges: + en: 1b51f9d385dafe76 + zh: 4bb73627aa69d652 +/forges/providers-and-change-requests: + en: 4ec7d1892b4067db + zh: 6bb70d21ae3d7a89 +/forges/status-polling: + en: 26ccfa2e342e3e67 + zh: fe6cc2a622757c48 +/forges/change-request-actions: + en: 00e4490e416099d2 + zh: 5724d08b6ae230f7 +/forges/session-bindings: + en: 13dfd36f2166bba0 + zh: a369434f52f04d57 +/forges/panel: + en: 9a84a26d97cb46f0 + zh: 5e1e03b7714bcbae diff --git a/docs/subsystems/forge.md b/docs/subsystems/forge.md new file mode 100644 index 0000000000..4bb2251a52 --- /dev/null +++ b/docs/subsystems/forge.md @@ -0,0 +1,1105 @@ +# Forges + +English | [中文](forge.zh.md) + +The experimental forge capability seam reads change requests, meaning pull requests and merge requests, from code hosts. [`dsh-experimental-forge`](../../packages/experimental/forge/README.md) is the Service Definition and provides `ctx.forge`; [`dsh-experimental-forge-github`](../../packages/experimental/forge-github/README.md) is the Service Provider for GitHub hosts; [`dsh-experimental-forge-session`](../../packages/experimental/forge-session/README.md) is the Consumer that binds change requests to Sessions and provides `ctx.forgeSession`; [`dsh-experimental-client-ui-forge`](../../packages/experimental/client-ui-forge/README.md) is the GUI Consumer that provides `ctx.forgePanel`. The default-disabled [forge bundle](../../packages/experimental/forge-bundle/README.md) composes all four, with an isolated [worktree service](../../packages/experimental/worktree/README.md) for the panel, from the Plugins page; otherwise they load only through explicit composition. + +## Providers and change requests + +A provider serves one forge family, such as `github`. It maps Git remote URLs to repositories and change request URLs to references, builds canonical change request URLs, finds open change requests by head branch, and reads status in batches. `changeRequestKey` derives one case-insensitive key from a reference, so watchers of differently spelled URLs share one polling loop. + +```ts type-equiv +/** One repository on a code host, identified the way its provider addresses it. */ +interface ForgeRepository { + /** Provider family that serves the repository, such as `github`. */ + readonly forge: string + /** Lowercase host name, such as `github.com`. */ + readonly host: string + /** Owner or namespace path as the host spells it. */ + readonly owner: string + /** Repository name as the host spells it. */ + readonly name: string +} +``` + +```ts type-equiv +/** One change request (pull request or merge request) in a repository. */ +interface ChangeRequestRef { + /** Repository that holds the change request. */ + readonly repository: ForgeRepository + /** Host-assigned change request number. */ + readonly number: number +} +``` + +```ts type-equiv +/** Canonical identity of a change request across providers; built by `changeRequestKey`. */ +type ChangeRequestKey = Branded<'ChangeRequestKey'> +``` + +```ts type-equiv +/** + * Host-specific access registered with `ctx.forge`. One provider serves one + * forge family; methods that take a signal must stop work when it aborts. + */ +interface ForgeProvider { + /** Forge family name stored in every repository this provider returns. */ + readonly forge: string + /** Map a Git remote URL to its repository, or undefined when this provider does not serve it. */ + repositoryFromRemote(url: string): ForgeRepository | undefined + /** Map a change request web URL to its reference, or undefined when this provider does not serve it. */ + changeRequestFromUrl(url: string): ChangeRequestRef | undefined + /** Build the canonical web URL of a change request this provider serves. */ + changeRequestUrl(ref: ChangeRequestRef): string + /** Read the status of several change requests, returning one result per requested key. */ + fetch( + refs: readonly ChangeRequestRef[], + signal: AbortSignal, + ): Promise> + /** Find open change requests whose head is `branch` in `repository`, excluding the default branch and forks. */ + findByHead(repository: ForgeRepository, branch: string, signal: AbortSignal): Promise + /** Report whether the provider can reach `host` with current credentials. */ + readiness(host: string, signal: AbortSignal): Promise + /** Mark a draft change request ready for review, resolving to undefined once the host accepted it. */ + markReady(ref: ChangeRequestRef, signal: AbortSignal): Promise + /** Merge a change request while its head is still `request.headSha`, resolving to undefined once merged. */ + merge(ref: ChangeRequestRef, request: MergeRequest, signal: AbortSignal): Promise + /** Read the changes of a change request as the host computes them, as one unified patch with `a/` and `b/` path prefixes. */ + diff(ref: ChangeRequestRef, signal: AbortSignal): Promise + /** Read the text of `path` at `commit` in the change request's repository. */ + fileText(ref: ChangeRequestRef, commit: string, path: string, signal: AbortSignal): Promise + /** Build the web page of `path` at `commit` in the change request's repository. */ + fileUrl(ref: ChangeRequestRef, commit: string, path: string): string +} +``` + +```ts type-equiv +/** Whether a provider can reach one host with the current credentials. */ +interface ForgeReadiness { + /** `ready`, `unauthenticated` (sign-in needed), or `unavailable` (tool missing or host unreachable). */ + readonly state: 'ready' | 'unauthenticated' | 'unavailable' + /** Human-readable detail, or null when ready. */ + readonly message: string | null +} +``` + +## Status polling + +`ctx.forge.watch` shares one polling loop among the watchers of a change request and delivers every result as an update. Each forge has at most one fetch in flight. The [registry README](../../packages/experimental/forge/README.md#use-this-package) owns the poll cadence, backoff, and timeout rules. + +```ts type-equiv +/** Lifecycle state of a change request. */ +type ChangeRequestState = 'open' | 'closed' | 'merged' +``` + +```ts type-equiv +/** Normalized outcome of one status check. */ +type CheckState = 'pending' | 'success' | 'failure' | 'neutral' | 'skipped' | 'cancelled' +``` + +```ts type-equiv +/** One status check reported for the head commit of a change request. */ +interface ChangeRequestCheck { + /** Check name as the host displays it. */ + readonly name: string + /** Normalized check outcome. */ + readonly state: CheckState + /** Web page for the check, or null when the host reports none. */ + readonly url: string | null + /** Workflow that produced the check, or null when the host reports none. */ + readonly workflow: string | null + /** ISO 8601 time the check started, or null before it starts and when the host reports none. */ + readonly startedAt: string | null + /** ISO 8601 time the check completed, or null while it runs and when the host reports none. */ + readonly completedAt: string | null +} +``` + +```ts type-equiv +/** Combined outcome of all checks on the head commit. */ +type ChecksRollup = 'none' | 'pending' | 'success' | 'failure' +``` + +```ts type-equiv +/** Whether the change request can merge into its base without conflicts. */ +type Mergeability = 'mergeable' | 'conflicting' | 'unknown' +``` + +```ts type-equiv +/** Aggregate review outcome required by the host's merge rules. */ +type ReviewDecision = 'approved' | 'changes-requested' | 'review-required' | 'none' +``` + +```ts type-equiv +/** Status of one change request at the time its provider read it. */ +interface ChangeRequestSnapshot { + /** Change request the snapshot describes. */ + readonly ref: ChangeRequestRef + /** Web page of the change request. */ + readonly url: string + /** Title as the author wrote it. */ + readonly title: string + /** Lifecycle state. */ + readonly state: ChangeRequestState + /** Whether the author marked the change request as a draft. */ + readonly draft: boolean + /** Author login, or null when the host hides it. */ + readonly author: string | null + /** Branch that carries the proposed commits. */ + readonly headBranch: string + /** Commit object name at the head of the change request. */ + readonly headSha: string + /** Branch the change request merges into. */ + readonly baseBranch: string + /** Lines the change request adds. */ + readonly additions: number + /** Lines the change request deletes. */ + readonly deletions: number + /** Merge conflict status; `unknown` while the host computes it. */ + readonly mergeability: Mergeability + /** + * Whether the host would merge the change request now for the signed-in + * user: open, not a draft, and meeting the base branch's merge rules. + */ + readonly mergeReady: boolean + /** Merge methods the repository accepts, the signed-in user's usual one first. */ + readonly mergeMethods: readonly MergeMethod[] + /** Aggregate review outcome. */ + readonly reviewDecision: ReviewDecision + /** Checks on the head commit in host order. */ + readonly checks: readonly ChangeRequestCheck[] + /** Combined check outcome. */ + readonly checksRollup: ChecksRollup + /** Whether the host reported more checks than `checks` contains. */ + readonly checksTruncated: boolean + /** Number of review threads that are not resolved. */ + readonly unresolvedThreads: number + /** ISO 8601 time of the host's last update to the change request. */ + readonly updatedAt: string + /** ISO 8601 time the change request was merged or closed, or null while it is open. */ + readonly closedAt: string | null +} +``` + +```ts type-equiv +/** Why a provider could not read a change request. */ +interface ForgeFailure { + /** + * `not-found`: the host does not expose it; `unauthenticated`: credentials are missing or rejected; + * `too-large`: the host or the provider refuses an answer over its size limit, so reading again fails + * the same way; `unavailable`: any other failure. + */ + readonly code: 'not-found' | 'unauthenticated' | 'too-large' | 'unavailable' + /** Human-readable cause. */ + readonly message: string +} +``` + +```ts type-equiv +/** Latest polling result delivered to watchers of one change request. */ +type ChangeRequestUpdate = { + /** Canonical identity of the watched change request. */ + readonly key: ChangeRequestKey + /** Epoch milliseconds when the result settled. */ + readonly fetchedAt: number +} & ({ + /** Status read from the host. */ + readonly snapshot: ChangeRequestSnapshot +} | { + /** Reason the status could not be read. */ + readonly failure: ForgeFailure +}) +``` + +```ts type-equiv +/** + * Receives every polling result for one watched change request. + * @param update - the settled result. + */ +type ChangeRequestListener = (update: ChangeRequestUpdate) => void +``` + +## Change request actions + +`ctx.forge.markReady` marks a draft ready for review, and `ctx.forge.merge` merges a change request only while its head is still the commit the user confirmed. Both resolve to a `ForgeActionFailure` instead of throwing when the host refuses, and poll a watched change request at once afterwards. A snapshot's `mergeReady` and `mergeMethods` tell a consumer when to offer a merge and with which methods; the [GitHub provider README](../../packages/experimental/forge-github/README.md#use-this-package) owns how GitHub answers both. + +```ts type-equiv +/** A merge the user confirmed for the head commit they saw. */ +interface MergeRequest { + /** How the commits enter the base branch. */ + readonly method: MergeMethod + /** Commit object name the head must still have; the merge is refused once the head moves. */ + readonly headSha: string +} +``` + +```ts type-equiv +/** Why a provider could not change a change request. */ +interface ForgeActionFailure { + /** + * `unauthenticated`: credentials are missing or rejected; `stale`: the head + * moved past the confirmed commit; `failed`: the host refused or could not be reached. + */ + readonly code: 'unauthenticated' | 'stale' | 'failed' + /** Human-readable cause, usually the host's own message. */ + readonly message: string +} +``` + +## Session bindings + +`ctx.forgeSession` derives each Session's bindings from its own log: `gh pr create` results bind the URLs they print, `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 +/** + * How a change request became bound to a Session: `created` when a + * `gh pr create` result printed its URL, `referenced` when a `gh pr view`, + * `gh pr edit`, or `gh pr checkout` call named it or its result printed its + * own URL, `discovered` when a lookup found it for a branch the Session pushed + * or works on, `tracked` when a caller bound it by URL. + */ +type BindingOrigin = 'created' | 'referenced' | 'discovered' | 'tracked' +``` + +```ts type-equiv +/** Payload of the `plugin:forge-session/binding` record. */ +interface BindingRecord { + /** + * `track` binds or restores, `dismiss` hides, `discover` binds a branch lookup, + * and `reference` binds a CLI selector lookup without restoring dismissed entries. + */ + readonly action: 'track' | 'dismiss' | 'discover' | 'reference' + /** Web URL of the change request. */ + readonly url: string +} +``` + +```ts type-equiv +/** One change request bound to a Session and not dismissed, with its latest polling result. */ +interface SessionChangeRequest { + /** Web URL as first recorded. */ + readonly url: string + /** How the change request became bound. */ + readonly origin: BindingOrigin + /** Parsed reference, or null when no registered provider serves the URL. */ + readonly ref: ChangeRequestRef | null + /** Latest polling result, or null before the first poll and without a provider. */ + readonly update: ChangeRequestUpdate | null +} +``` + +```ts type-equiv +/** + * Receives the complete visible list after a binding or provider change, or a polling result. + * @param changeRequests - every visible binding of the watched Session, in first-recorded order. + */ +type SessionChangeRequestsListener = (changeRequests: readonly SessionChangeRequest[]) => void +``` + +## Panel + +`ctx.forgePanel` is the Host Remote of the [forge panel](../../packages/experimental/client-ui-forge/README.md#use-this-package). Following a Session streams a `ForgePanelState`: a read-only Git inspection of the Session's current directory in its execution world, the code host recognized from the upstream remote or `origin`, the sign-in state of that host, the Session's bindings with their polled status and last snapshot, and its worktree request. Its actions list base branches, bind and hide URLs, refresh, mark a bound draft ready or merge a bound change request, request or withdraw a worktree, stop a running creation, and restore a removed worktree or leave it for the source checkout. A request is a `plugin:client-ui-forge/worktree` record. When a message enters a Session's inbox while its Agent is idle, the panel holds the message's turn with maintenance of the [Agent](../../packages/core/agent/README.md), started before the delivery wakes the Agent: it creates the worktree of an open request through `ctx.worktrees` and, after a failed or stopped attempt, waits for another attempt or the request's withdrawal; for a Session that works in its created worktree it checks that worktree and, when it is gone, waits until it is restored or left. The panel state names what a message waits for and the last failed attempt. The first step proposal creates the worktree for a message no hold took. Requests after the first turn starts are ignored. Changes to a Session's checkout run as maintenance of its Agent, so a message from another client waits for them. + +```ts type-equiv +/** Git facts for the checkout that contains the Session's current directory. */ +interface ForgeCheckout { + /** Top-level directory of the checkout. */ + readonly root: string + /** Checked-out branch, or null when HEAD is detached. */ + readonly branch: string | null + /** Whether the checkout is a linked worktree of another checkout's repository. */ + readonly linked: boolean + /** Abbreviated commit HEAD names, or null while the checked-out branch has no commit. */ + readonly commit: string | null +} +``` + +```ts type-equiv +/** Inspection result for the Session's current directory. */ +type ForgeCheckoutState = + /** The first inspection has not finished. */ + | { readonly state: 'pending' } + /** The current directory is not inside a Git checkout. */ + | { readonly state: 'none' } + /** Git could not run or reported an error. */ + | { readonly state: 'failed'; readonly message: string } + /** The checkout facts. */ + | { readonly state: 'ready'; readonly checkout: ForgeCheckout } +``` + +```ts type-equiv +/** The worktree the panel created and entered for a Session before its first step. */ +interface ForgeSessionWorktree { + /** Canonical checkout directory. */ + readonly path: string + /** Branch created for the checkout. */ + readonly branch: string + /** Requested base branch, or null when the checkout started from HEAD of the source checkout. */ + readonly from: string | null + /** Commit object name the branch started from. */ + readonly baseCommit: string + /** Canonical root of the source checkout. */ + readonly repositoryRoot: string +} +``` + +```ts type-equiv +/** Payload of the `plugin:client-ui-forge/worktree` record. */ +type WorktreeRecord = + /** Create a worktree before the Session's first step, starting from `from`, or from HEAD when it is null. */ + | { readonly action: 'request'; readonly from: string | null } + /** Withdraw the open request. */ + | { readonly action: 'cancel' } + /** The requested worktree was created and became the Session's working directory. */ + | { readonly action: 'created'; readonly worktree: ForgeSessionWorktree } + /** Restoration started; the checkout stays unavailable until restoration completes. */ + | { readonly action: 'restoring' } + /** The recorded worktree was completely restored. */ + | { readonly action: 'restored' } +``` + +```ts type-equiv +/** One open worktree request. */ +interface ForgeWorktreeRequest { + /** Base branch, or null for HEAD of the source checkout. */ + readonly from: string | null +} +``` + +```ts type-equiv +/** Why the Session's checkout cannot host a new worktree. */ +type ForgeWorktreeBlocker = + /** The current directory is not inside a Git checkout. */ + | 'not-git' + /** The Session's sandbox cannot write the checkout or its Git directory, as for a linked worktree outside the writable root. */ + | 'outside-writable-root' + /** The Session runs read-only. */ + | 'read-only' +``` + +```ts type-equiv +/** One running worktree creation. */ +interface ForgeWorktreeCreation { + /** Running step: `prepare` validates and creates the branch, then `checkout` writes the files. */ + readonly phase: 'prepare' | 'checkout' + /** Branch the attempt creates. */ + readonly branch: string + /** Base branch, or null for HEAD. */ + readonly from: string | null + /** + * Whether a message waits for the creation before its turn starts. Only + * such a creation can be stopped, after which the message keeps waiting; a + * creation at the first step serves a message the Session already holds. + */ + readonly held: boolean +} +``` + +```ts type-equiv +/** One running restoration of the removed worktree a Session works in. */ +interface ForgeWorktreeRestoration { + /** Running step: `prepare` registers the checkout again, creating a missing branch, then `checkout` writes the files. */ + readonly phase: 'prepare' | 'checkout' +} +``` + +```ts type-equiv +/** The last attempt to create the requested worktree for a message, which failed. */ +interface ForgeWorktreeFailure { + /** Branch the attempt created, or null when it failed before naming one. */ + readonly branch: string | null + /** Base branch of the attempt, or null for HEAD. */ + readonly from: string | null + /** Step that failed. */ + readonly phase: 'prepare' | 'checkout' + /** Failure text. */ + readonly message: string +} +``` + +```ts type-equiv +/** + * What a message waits for before its turn starts: the creation of the + * requested worktree, or the restoration or leaving of the removed worktree + * the Session works in. + */ +type ForgeWorktreeWaiting = 'creation' | 'restoration' +``` + +```ts type-equiv +/** The recorded worktree whose directory is missing, invalid, or incompletely restored. */ +interface ForgeMissingWorktree { + /** Whether its branch exists, or null when an incomplete restoration cannot inspect it. */ + readonly branchExists: boolean | null +} +``` + +```ts type-equiv +/** Worktree facts of one Session in the panel state. */ +interface ForgePanelWorktree { + /** + * Whether the Session can still request or withdraw a worktree: a worktree + * service is mounted, no turn has started, and none was created. + */ + readonly editable: boolean + /** Why the checkout cannot host a worktree, or null when it can, before the first inspection, and after a failed one. */ + readonly blocker: ForgeWorktreeBlocker | null + /** Open request, or null without one. */ + readonly request: ForgeWorktreeRequest | null + /** Created worktree, or null before creation. */ + readonly created: ForgeSessionWorktree | null + /** Running creation, or null. */ + readonly creation: ForgeWorktreeCreation | null + /** + * The last failed attempt of the creation a message waits for, or null. It + * clears when another attempt starts, when the request changes or is + * withdrawn, and when the wait ends. + */ + readonly failure: ForgeWorktreeFailure | null + /** The created worktree the Session works in when its directory is gone, or null. */ + readonly missing: ForgeMissingWorktree | null + /** Running restoration of the removed worktree, or null. */ + readonly restoration: ForgeWorktreeRestoration | null + /** What a message of the Session waits for before its turn starts, or null while none waits. */ + readonly waiting: ForgeWorktreeWaiting | null +} +``` + +```ts type-equiv +/** One bound change request in the panel state. */ +interface ForgePanelChangeRequest extends SessionChangeRequest { + /** Latest snapshot a poll returned, kept while later polls fail, or null before the first successful poll. */ + readonly snapshot: ChangeRequestSnapshot | null +} +``` + +```ts type-equiv +/** Everything the panel shows for one Session. */ +interface ForgePanelState { + /** Current working directory of the Session. */ + readonly directory: string + /** Inspection result for `directory`. */ + readonly checkout: ForgeCheckoutState + /** Code host repository of the checkout's upstream remote or `origin`, or null when no provider serves them. */ + readonly repository: ForgeRepository | null + /** Sign-in state for the repository's host, or null before the first probe and without a repository. */ + readonly readiness: ForgeReadiness | null + /** Visible change request bindings with their latest polling results, in first-recorded order. */ + readonly changeRequests: readonly ForgePanelChangeRequest[] + /** Worktree request and creation state. */ + readonly worktree: ForgePanelWorktree + /** + * Files the `branch` scope changes, or null before the first inspection, + * outside a checkout, and without a fork point. + */ + readonly local: ForgeLocalChanges | null +} +``` + +```ts type-equiv +/** Local branches offered as the base of a new worktree. */ +interface ForgeBranches { + /** Checked-out branch, or null when HEAD is detached. */ + readonly current: string | null + /** Local branch names, `current` first and then most recently committed first, at most `maxBranches`. */ + readonly branches: readonly string[] +} +``` + +The 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 +/** Which local changes a diff covers; each reads the Session's checkout through Git. */ +type ForgeLocalScope = + /** + * The working tree and untracked files against the fork point: the base commit of the worktree the + * Session created, else the merge base of HEAD with the default branch of the code host remote. + */ + | { readonly kind: 'branch' } + /** The working tree and untracked files against HEAD. */ + | { readonly kind: 'uncommitted' } + /** The working tree and untracked files against the index. */ + | { readonly kind: 'unstaged' } + /** The index against HEAD. */ + | { readonly kind: 'staged' } +``` + +```ts type-equiv +/** One hunk of a file diff. */ +interface ForgeDiffHunk { + /** First old line the hunk covers, or the old line it comes before when it covers none. */ + readonly oldStart: number + /** Old lines the hunk covers. */ + readonly oldLines: number + /** First new line the hunk covers, or the new line it comes before when it covers none. */ + readonly newStart: number + /** New lines the hunk covers. */ + readonly newLines: number + /** Lines in patch form: a leading space for context, `+` for added, `-` for deleted, and `\` for a missing final newline. */ + readonly lines: readonly string[] +} +``` + +```ts type-equiv +/** How one file changed. */ +type ForgeDiffStatus = 'added' | 'deleted' | 'modified' | 'renamed' +``` + +```ts type-equiv +/** One changed file of a diff. */ +interface ForgeDiffFile { + /** Path relative to the repository root after the change, or before it for a deleted file. */ + readonly path: string + /** Path before a rename, else null. */ + readonly previousPath: string | null + /** How the file changed. */ + readonly status: ForgeDiffStatus + /** Whether the content is binary, which carries no hunks. */ + readonly binary: boolean + /** Whether an untracked file exceeds `maxDiffBytes`, so it carries no hunks. */ + readonly oversized: boolean + /** Whether Git does not track the file yet; only local diffs list such files. */ + readonly untracked: boolean + /** Lines added. */ + readonly additions: number + /** Lines deleted. */ + readonly deletions: number + /** Hunks in file order; none for a binary file or a change of mode or name only. */ + readonly hunks: readonly ForgeDiffHunk[] +} +``` + +```ts type-equiv +/** Local changes of the Session's checkout in one scope. */ +interface ForgeLocalDiff { + /** Ref or abbreviated commit the `branch` scope compares with, such as `origin/master`; null for other scopes. */ + readonly base: string | null + /** Changed files in Git's path order, untracked files last. */ + readonly files: readonly ForgeDiffFile[] + /** Untracked files left out beyond `maxUntrackedFiles`. */ + readonly omitted: number +} +``` + +```ts type-equiv +/** Changes of a change request as its code host computes them. */ +interface ForgeChangeRequestDiff { + /** Head commit the diff describes. */ + readonly headSha: string + /** Changed files in the host's order. */ + readonly files: readonly ForgeDiffFile[] + /** + * Commits the checkout's branch has beyond the head when that branch is the change request's head + * branch and the head commit is present locally; null otherwise. + */ + readonly localAhead: number | null + /** Web page of each changed file at the head commit, by path. */ + readonly fileUrls: Readonly> +} +``` + +```ts type-equiv +/** One file the `branch` scope changes, without its hunks. */ +interface ForgeChangedFile { + /** Path relative to the repository root after the change. */ + readonly path: string + /** Lines added, or 0 for a binary file. */ + readonly additions: number + /** Lines deleted, or 0 for a binary file. */ + readonly deletions: number + /** Whether the content is binary. */ + readonly binary: boolean +} +``` + +```ts type-equiv +/** Size of the `branch` scope, which the panel's local changes card shows. */ +interface ForgeLocalChanges { + /** Ref or abbreviated commit the scope compares with. */ + readonly base: string + /** Changed files in Git's path order, untracked files last, at most `maxUntrackedFiles` of those. */ + readonly files: readonly ForgeChangedFile[] +} +``` + + + + + +## Cordis API + +Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). + + + +### `ctx.forge` — `ForgeService` + +Registry of forge providers. Watchers of one change request share a single polling loop; each forge has at most one fetch in flight, which batches every due change request of that forge. + +```ts cordis-catalog +/** + * Register the provider of one forge family as an effect on the calling + * fiber. Watches of that forge receive an `unavailable` failure when the + * registration ends. Unregistering cancels and awaits its in-flight calls; + * results from the withdrawn registration are discarded. + * @param provider - provider to serve `provider.forge`. + * @returns the disposer that unregisters the provider and waits for its calls to finish. + * @throws when another provider already serves the same forge. + */ +register(provider: ForgeProvider): () => Promise + +/** + * Map a Git remote URL to a repository through the first provider that serves it. + * @param url - fetch or push URL as Git reports it. + * @returns the repository, or undefined when no provider serves the URL. + */ +repositoryFromRemote(url: string): ForgeRepository | undefined + +/** + * Map a change request web URL through the first provider that serves it. + * @param url - web URL of a pull or merge request. + * @returns the change request, or undefined when no provider serves the URL. + */ +changeRequestFromUrl(url: string): ChangeRequestRef | undefined + +/** + * Build the canonical web URL of a change request. + * @param ref - change request served by a registered provider. + * @returns the provider's canonical URL. + * @throws when no provider serves the change request's forge. + */ +changeRequestUrl(ref: ChangeRequestRef): string + +/** + * Find open change requests whose head is `branch`, excluding the default branch and forks. + * @param repository - repository served by a registered provider. + * @param branch - head branch name on the host. + * @param signal - cancellation for the lookup. + * @returns matching change requests, newest first as the provider reports them. + * @throws when no provider serves the repository's forge or the lookup fails. + */ +async findByHead(repository: ForgeRepository, branch: string, signal: AbortSignal): Promise + +/** + * Report whether the provider can reach the repository's host with current credentials. + * @param repository - repository served by a registered provider. + * @param signal - cancellation for the probe. + * @returns the provider's readiness for the host. + * @throws when no provider serves the repository's forge. + */ +async readiness(repository: ForgeRepository, signal: AbortSignal): Promise + +/** + * Mark a draft change request ready for review, then poll it at once when it is watched. + * @param ref - change request served by a registered provider. + * @param signal - cancellation of the request. + * @returns undefined once the host accepted the change, or why it did not. + * @throws when no provider serves the change request's forge, or with `signal.reason` when it aborts. + */ +async markReady(ref: ChangeRequestRef, signal: AbortSignal): Promise + +/** + * Merge a change request while its head is still `request.headSha`, then + * poll it at once when it is watched. + * @param ref - change request served by a registered provider. + * @param request - merge method and the head commit the user confirmed. + * @param signal - cancellation of the request. + * @returns undefined once the host merged it, or why it did not. + * @throws when no provider serves the change request's forge, or with `signal.reason` when it aborts. + */ +async merge(ref: ChangeRequestRef, request: MergeRequest, signal: AbortSignal): Promise + +/** + * Read the changes of a change request as its host computes them. + * @param ref - change request served by a registered provider. + * @param signal - cancellation of the read. + * @returns one unified patch with `a/` and `b/` path prefixes, or why the host did not provide it. + * @throws when no provider serves the change request's forge, or with `signal.reason` when it aborts. + */ +async diff(ref: ChangeRequestRef, signal: AbortSignal): Promise + +/** + * Read the text of one file at a commit of a change request's repository. + * @param ref - change request served by a registered provider. + * @param commit - commit object name. + * @param path - path relative to the repository root. + * @param signal - cancellation of the read. + * @returns the text, or why the host did not provide it. + * @throws when no provider serves the change request's forge, or with `signal.reason` when it aborts. + */ +async fileText(ref: ChangeRequestRef, commit: string, path: string, signal: AbortSignal): Promise + +/** + * Build the web page of one file at a commit of a change request's repository. + * @param ref - change request served by a registered provider. + * @param commit - commit object name. + * @param path - path relative to the repository root. + * @returns the provider's URL. + * @throws when no provider serves the change request's forge. + */ +fileUrl(ref: ChangeRequestRef, commit: string, path: string): string + +/** + * Watch one change request as an effect on the calling fiber. The first + * watcher starts polling at once; listeners receive every later result, + * and `latest` returns the current one. Polling stops when the last watcher + * leaves. Listeners must not throw; a thrown error is logged and skipped. + * @param ref - change request to poll. + * @param listener - receives each polling result. + * @returns the disposer that stops this watch. + */ +watch(ref: ChangeRequestRef, listener: ChangeRequestListener): () => void + +/** + * Poll a watched change request at once, including after a terminal state. + * A fetch already in flight is followed immediately by another fetch; an + * unwatched change request is unchanged. + * @param ref - change request to poll. + */ +refresh(ref: ChangeRequestRef): void + +/** + * Read the latest polling result of a watched change request. + * @param ref - change request to read. + * @returns the latest result, or undefined before the first result or without a watch. + */ +latest(ref: ChangeRequestRef): ChangeRequestUpdate | undefined +``` + +Source: [`packages/experimental/forge/src/index.ts`](../../packages/experimental/forge/src/index.ts) + + + +### `ctx.forgePanel` — `ForgePanelController` + +Serves the forge panel. Following a Session inspects the checkout of its current directory read-only in the Session's execution world, recognizes the code host of the upstream remote or `origin`, probes sign-in for that host, and polls the Session's bound change requests through `ctx.forge` until the last follower of that Session leaves. A worktree request makes the panel create a branch and checkout through an optional `ctx.worktrees` and enter it while the Session's first message waits in the inbox, or at the first step proposal of a message no wait took. After a failed or stopped attempt the message keeps waiting for another attempt or for the request to be withdrawn. A message also waits while the worktree the Session works in is gone, until that worktree is checked out again or left for the source checkout. Changes to a Session's checkout run as Agent maintenance, so a message that arrives meanwhile waits for them. + +```ts cordis-catalog +/** + * Stream the panel state of a Session: the current state first, then each + * later state. Intermediate states may be skipped when the reader is slower + * than the updates. The stream ends when `signal` aborts or the Remote stops. + * @param session - Session to follow. + * @param signal - ends the stream. + * @returns panel states in publication order. + */ +@Remote({ mode: 'stream' }) async *follow(session: Session, signal: AbortSignal): AsyncIterable + +/** + * 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 + +/** + * List the local branches that can start a worktree of the Session's current checkout. + * @param session - Session whose current directory is listed. + * @param signal - request cancellation. + * @returns the checked-out branch and at most `maxBranches` local branches, the checked-out one first. + * @throws when the current directory is not inside a checkout or Git fails. + */ +@Remote async branches(session: Session, signal: AbortSignal): Promise + +/** + * Bind a change request to a Session by URL, or restore a dismissed binding. + * @param session - Session that receives the binding. + * @param url - web URL of the change request. + * @param signal - request cancellation. + * @throws `forgePanel/unrecognized-url` when no registered provider serves the URL. + */ +@Remote async track(session: Session, url: string, signal: AbortSignal): Promise + +/** + * Hide a visible binding of a Session; other URLs leave the Session unchanged. + * @param session - Session that owns the binding. + * @param url - web URL of the change request. + * @param signal - request cancellation. + */ +@Remote async dismiss(session: Session, url: string, signal: AbortSignal): Promise + +/** + * Reinspect a followed Session's checkout, probe sign-in again, and poll its + * change requests at once; an unfollowed Session is unchanged. + * @param session - Session whose panel refreshes. + * @param signal - request cancellation. + * @returns settles once the inspection and the sign-in probe have ended; poll results reach the panel stream later. + */ +@Remote async refresh(session: Session, signal: AbortSignal): Promise + +/** + * Read the changes one scope covers in the checkout of the Session's + * current directory, with Git confined to the Session's read-only sandbox. + * @param session - Session whose checkout is read. + * @param scope - which changes to read. + * @param signal - request cancellation. + * @returns the files, the base of the `branch` scope, and the untracked files left out. + * @throws `forgePanel/not-a-checkout` outside a checkout; `forgePanel/no-fork-point` for the `branch` + * scope of a checkout without one; `forgePanel/diff-too-large` over `maxDiffBytes`; the Git error when Git fails. + */ +@Remote async localDiff(session: Session, scope: ForgeLocalScope, signal: AbortSignal): Promise + +/** + * Read the new side of one changed file in a scope, which supplies the + * unchanged lines between its hunks: the index for `staged`, else the working tree. + * @param session - Session whose checkout is read. + * @param scope - the scope the diff was read in. + * @param path - path relative to the checkout's top-level directory, as the diff names it. + * @param signal - request cancellation. + * @returns the text. + * @throws `forgePanel/not-a-checkout` outside a checkout; `forgePanel/diff-too-large` over `maxDiffBytes`; + * an error for a path outside the checkout, binary content, or a failed read. + */ +@Remote async localText(session: Session, scope: ForgeLocalScope, path: string, signal: AbortSignal): Promise + +/** + * Read the changes of a change request of a Session as its code host + * computes them, at the head commit of its latest status. A patch already + * read at the same head commit and base branch is reused from memory, for + * any Session, until `maxCachedDiffBytes` evicts it. + * @param session - Session the change request is bound to. + * @param url - binding URL of the change request. + * @param signal - request cancellation. + * @returns the files, the head commit, each file's web page, and the commits the checkout has beyond the head. + * @throws `forgePanel/not-bound` unless the URL is a visible binding of the Session that a provider serves; + * `forgePanel/diff-too-large` when the host or its provider refuses the diff over a size limit; + * `forgePanel/diff-failed` before its status was read and when the host does not provide the diff otherwise. + */ +@Remote async changeRequestDiff(session: Session, url: string, signal: AbortSignal): Promise + +/** + * Read one file of a change request's repository at a commit, which + * supplies the unchanged lines between the hunks of its diff. + * @param session - Session the change request is bound to. + * @param url - binding URL of the change request. + * @param commit - commit object name, the head commit of the diff. + * @param path - path relative to the repository root. + * @param signal - request cancellation. + * @returns the text. + * @throws `forgePanel/not-bound` unless the URL is a visible binding of the Session that a provider serves; + * `forgePanel/diff-too-large` when the host or its provider refuses the file over a size limit; + * `forgePanel/diff-failed` when the host does not provide the file otherwise. + */ +@Remote async changeRequestText(session: Session, url: string, commit: string, path: string, signal: AbortSignal): Promise + +/** + * Mark a draft change request of a Session ready for review; the change + * request is polled again once the host answers. + * @param session - Session the change request is bound to. + * @param url - binding URL of the change request. + * @param signal - request cancellation. + * @throws `forgePanel/not-bound` unless the URL is a visible binding of the Session that a provider serves; + * `forgePanel/action-failed` with the host's reason when the host refuses or cannot be reached. + */ +@Remote async markReady(session: Session, url: string, signal: AbortSignal): Promise + +/** + * Merge a change request of a Session while its head is still `headSha`; + * the change request is polled again once the host answers. + * @param session - Session the change request is bound to. + * @param url - binding URL of the change request. + * @param method - how the commits enter the base branch. + * @param headSha - head commit the user confirmed. + * @param signal - request cancellation. + * @throws `forgePanel/not-bound` unless the URL is a visible binding of the Session that a provider serves; + * `forgePanel/stale-head` when the head moved past `headSha`; `forgePanel/action-failed` with the host's + * reason when the host refuses otherwise or cannot be reached. + */ +@Remote async merge(session: Session, url: string, method: MergeMethod, headSha: string, signal: AbortSignal): Promise + +/** + * Ask for a new worktree before the Session's first turn, replacing an open + * request and forgetting a failed attempt. While the next message waits in + * the inbox, or else at the first step proposal, the panel creates a branch + * from `from`, or from HEAD of the current checkout when it is null, and + * enters its checkout. A message that already waits after a failed or + * stopped attempt keeps waiting for {@link ForgePanelController.retryWorktree}. + * @param session - Session without a started turn. + * @param from - local base branch, or null for HEAD. + * @param signal - request cancellation. + * @throws `forgePanel/invalid-branch` when `from` is empty; `forgePanel/worktrees-unavailable` + * without a worktree service; `forgePanel/session-started` after a turn started or the worktree was + * created; `forgePanel/session-busy` while the worktree is being created. + */ +@Remote async requestWorktree(session: Session, from: string | null, signal: AbortSignal): Promise + +/** + * Withdraw the open worktree request of a Session with its failed attempt, + * which lets a waiting message go on in the current checkout; without a + * request the Session is unchanged. Withdrawing needs no worktree service. + * @param session - Session without a started turn. + * @param signal - request cancellation. + * @throws `forgePanel/session-started` after a turn started or the worktree was created; + * `forgePanel/session-busy` while the worktree is being created. + */ +@Remote async cancelWorktree(session: Session, signal: AbortSignal): Promise + +/** + * Stop the creation a message of the Session waits for; the message keeps + * waiting with the request still open. Without such a creation the Session + * is unchanged. + * @param session - Session whose creation stops. + * @param signal - request cancellation. + */ +@Remote async stopCreation(session: Session, signal: AbortSignal): Promise + +/** + * Make another attempt, with a new branch name, at the worktree a message + * of the Session waits for after a failed or stopped attempt. + * @param session - Session whose message waits. + * @param signal - request cancellation. + * @throws `forgePanel/not-waiting` when no message waits or an attempt is running. + */ +@Remote async retryWorktree(session: Session, signal: AbortSignal): Promise + +/** + * Check out the created worktree of a Session again at its recorded + * directory when that directory is gone: its branch when the branch still + * exists, otherwise a new branch of the same name at the base commit. Changes + * that were never committed are not recovered. Runs as Agent maintenance, + * inside the wait of a message that waits for the restoration, which then + * goes on; without a missing directory the Session is unchanged. + * @param session - Session whose worktree directory is gone. + * @param signal - request cancellation, which aborts the restoration. + * @throws `forgePanel/no-worktree` without a created worktree; `forgePanel/worktrees-unavailable` + * without a worktree service; `forgePanel/session-busy` while the Session's Agent is not loaded or + * has other work; `forgePanel/restore-failed` when Git fails. + */ +@Remote async restoreWorktree(session: Session, signal: AbortSignal): Promise + +/** + * Move a Session from its created worktree to the source checkout the + * worktree came from; the Session no longer works in the worktree. Runs as + * Agent maintenance, inside the wait of a message that waits for the removed + * worktree, which then goes on. + * @param session - Session with a created worktree. + * @param signal - request cancellation. + * @throws `forgePanel/no-worktree` without a created worktree; `forgePanel/session-busy` while the + * Session's Agent is not loaded or has other work; the working-directory service's error when the + * source checkout no longer exists. + */ +@Remote async continueInProject(session: Session, signal: AbortSignal): Promise +``` + +Types: [Session](session.md) + +Source: [`packages/experimental/client-ui-forge/src/index.ts`](../../packages/experimental/client-ui-forge/src/index.ts) + + + +### `ctx.forgeSession` — `ForgeSessionService` + +Session-scoped change request bindings. The `forgeChangeRequests` projection derives them from `gh pr create`, `gh pr view`, `gh pr edit`, `gh pr checkout`, and `git push` results and from `plugin:forge-session/binding` records. After each push result, and when a caller reports the branch a Session works on, the service looks up open change requests whose head is that branch and records the unbound ones as discovered. Watching a Session polls its visible change requests through `ctx.forge`. + +```ts cordis-catalog +/** + * List the change requests bound to a Session and not dismissed. + * @param session - Session whose bindings are read. + * @returns visible bindings in first-recorded order, each with its latest polling result. + */ +changeRequests(session: Session): SessionChangeRequest[] + +/** + * Bind a change request to a Session by URL, or restore a dismissed binding. + * Appends a `track` record with the provider's canonical URL unless the + * change request is already visible. + * @param session - Session that receives the binding. + * @param url - web URL of the change request. + * @throws when no registered provider recognizes the URL. + */ +track(session: Session, url: string): void + +/** + * Look up open change requests whose head is a branch the Session works on, + * and record the unbound ones as discovered, as after a push of that branch. + * A remote that no provider recognizes changes nothing; a failed lookup is + * logged and changes nothing. + * @param session - Session that receives the bindings. + * @param remote - URL of the remote that holds the branch. + * @param branch - branch name on that remote. + * @returns resolves once the lookup has recorded its bindings or failed. + */ +discoverBranch(session: Session, remote: string, branch: string): Promise + +/** + * Hide a visible binding. Appends a `dismiss` record; a URL that is not + * visibly bound leaves the log unchanged. Discovery never restores a + * dismissed binding; `track` does. + * @param session - Session that owns the binding. + * @param url - web URL of the change request. + */ +dismiss(session: Session, url: string): void + +/** + * Watch a Session's visible change requests as an effect on the calling + * fiber. Every visible change request that a provider recognizes is polled + * through `ctx.forge` while the Session has a watcher. Listeners receive the + * complete list after each later binding or provider change, or polling result; read the + * current list with {@link changeRequests}. Listeners must not throw; a + * thrown error is logged and skipped. + * @param session - Session to watch. + * @param listener - receives the complete visible list. + * @returns the disposer that stops this watch. + */ +watch(session: Session, listener: SessionChangeRequestsListener): () => void +``` + +Types: [Session](session.md) + +Source: [`packages/experimental/forge-session/src/index.ts`](../../packages/experimental/forge-session/src/index.ts) + + + +### `forge/*` events + + + +#### `forge/providers-changed` — parallel + +Announces a provider registration or withdrawal after the registry changes. Listener failures are logged without preventing other listeners from running. + +```ts cordis-catalog +/** + * Announces a provider registration or withdrawal after the registry changes. + * Listener failures are logged without preventing other listeners from running. + * @param forge - family whose provider changed. + * @mode parallel + */ +'forge/providers-changed'(forge: string): void | Promise +``` + +Source: [`packages/experimental/forge/src/index.ts`](../../packages/experimental/forge/src/index.ts) + diff --git a/docs/subsystems/forge.zh.md b/docs/subsystems/forge.zh.md new file mode 100644 index 0000000000..0819fa9f6c --- /dev/null +++ b/docs/subsystems/forge.zh.md @@ -0,0 +1,1105 @@ +# Forges + +[English](forge.md) | 中文 + +实验性的 forge 能力 seam 从代码托管平台读取变更请求(change request,即 pull request 与 merge request)。[`dsh-experimental-forge`](../../packages/experimental/forge/README.zh.md) 是 Service Definition,提供 `ctx.forge`;[`dsh-experimental-forge-github`](../../packages/experimental/forge-github/README.zh.md) 是服务 GitHub 主机的 Service Provider;[`dsh-experimental-forge-session`](../../packages/experimental/forge-session/README.zh.md) 是把变更请求绑定到 Session 的 Consumer,提供 `ctx.forgeSession`;[`dsh-experimental-client-ui-forge`](../../packages/experimental/client-ui-forge/README.zh.md) 是提供 `ctx.forgePanel` 的 GUI Consumer。默认禁用的 [forge Bundle](../../packages/experimental/forge-bundle/README.zh.md) 可从插件页面组合这四个包,并为面板附带一个隔离的 [worktree 服务](../../packages/experimental/worktree/README.zh.md);除此之外,它们只能通过显式组合加载。 + +## 提供方与变更请求 + +一个提供方服务一个 forge 家族,例如 `github`。它把 Git 远端 URL 映射为仓库,把变更请求 URL 映射为引用,构造规范的变更请求 URL,按 head 分支查找打开状态的变更请求,并批量读取状态。`changeRequestKey` 从引用派生一个不区分大小写的键,因此 URL 拼写不同的监听方共享同一个轮询循环。 + +```ts type-equiv +/** One repository on a code host, identified the way its provider addresses it. */ +interface ForgeRepository { + /** Provider family that serves the repository, such as `github`. */ + readonly forge: string + /** Lowercase host name, such as `github.com`. */ + readonly host: string + /** Owner or namespace path as the host spells it. */ + readonly owner: string + /** Repository name as the host spells it. */ + readonly name: string +} +``` + +```ts type-equiv +/** One change request (pull request or merge request) in a repository. */ +interface ChangeRequestRef { + /** Repository that holds the change request. */ + readonly repository: ForgeRepository + /** Host-assigned change request number. */ + readonly number: number +} +``` + +```ts type-equiv +/** Canonical identity of a change request across providers; built by `changeRequestKey`. */ +type ChangeRequestKey = Branded<'ChangeRequestKey'> +``` + +```ts type-equiv +/** + * Host-specific access registered with `ctx.forge`. One provider serves one + * forge family; methods that take a signal must stop work when it aborts. + */ +interface ForgeProvider { + /** Forge family name stored in every repository this provider returns. */ + readonly forge: string + /** Map a Git remote URL to its repository, or undefined when this provider does not serve it. */ + repositoryFromRemote(url: string): ForgeRepository | undefined + /** Map a change request web URL to its reference, or undefined when this provider does not serve it. */ + changeRequestFromUrl(url: string): ChangeRequestRef | undefined + /** Build the canonical web URL of a change request this provider serves. */ + changeRequestUrl(ref: ChangeRequestRef): string + /** Read the status of several change requests, returning one result per requested key. */ + fetch( + refs: readonly ChangeRequestRef[], + signal: AbortSignal, + ): Promise> + /** Find open change requests whose head is `branch` in `repository`, excluding the default branch and forks. */ + findByHead(repository: ForgeRepository, branch: string, signal: AbortSignal): Promise + /** Report whether the provider can reach `host` with current credentials. */ + readiness(host: string, signal: AbortSignal): Promise + /** Mark a draft change request ready for review, resolving to undefined once the host accepted it. */ + markReady(ref: ChangeRequestRef, signal: AbortSignal): Promise + /** Merge a change request while its head is still `request.headSha`, resolving to undefined once merged. */ + merge(ref: ChangeRequestRef, request: MergeRequest, signal: AbortSignal): Promise + /** Read the changes of a change request as the host computes them, as one unified patch with `a/` and `b/` path prefixes. */ + diff(ref: ChangeRequestRef, signal: AbortSignal): Promise + /** Read the text of `path` at `commit` in the change request's repository. */ + fileText(ref: ChangeRequestRef, commit: string, path: string, signal: AbortSignal): Promise + /** Build the web page of `path` at `commit` in the change request's repository. */ + fileUrl(ref: ChangeRequestRef, commit: string, path: string): string +} +``` + +```ts type-equiv +/** Whether a provider can reach one host with the current credentials. */ +interface ForgeReadiness { + /** `ready`, `unauthenticated` (sign-in needed), or `unavailable` (tool missing or host unreachable). */ + readonly state: 'ready' | 'unauthenticated' | 'unavailable' + /** Human-readable detail, or null when ready. */ + readonly message: string | null +} +``` + +## 状态轮询 + +`ctx.forge.watch` 让同一变更请求的所有监听方共享一个轮询循环,并把每次结果作为更新送达。每个 forge 同时最多只有一次读取在进行。轮询节奏、退避与超时规则见[注册表 README](../../packages/experimental/forge/README.zh.md#use-this-package)。 + +```ts type-equiv +/** Lifecycle state of a change request. */ +type ChangeRequestState = 'open' | 'closed' | 'merged' +``` + +```ts type-equiv +/** Normalized outcome of one status check. */ +type CheckState = 'pending' | 'success' | 'failure' | 'neutral' | 'skipped' | 'cancelled' +``` + +```ts type-equiv +/** One status check reported for the head commit of a change request. */ +interface ChangeRequestCheck { + /** Check name as the host displays it. */ + readonly name: string + /** Normalized check outcome. */ + readonly state: CheckState + /** Web page for the check, or null when the host reports none. */ + readonly url: string | null + /** Workflow that produced the check, or null when the host reports none. */ + readonly workflow: string | null + /** ISO 8601 time the check started, or null before it starts and when the host reports none. */ + readonly startedAt: string | null + /** ISO 8601 time the check completed, or null while it runs and when the host reports none. */ + readonly completedAt: string | null +} +``` + +```ts type-equiv +/** Combined outcome of all checks on the head commit. */ +type ChecksRollup = 'none' | 'pending' | 'success' | 'failure' +``` + +```ts type-equiv +/** Whether the change request can merge into its base without conflicts. */ +type Mergeability = 'mergeable' | 'conflicting' | 'unknown' +``` + +```ts type-equiv +/** Aggregate review outcome required by the host's merge rules. */ +type ReviewDecision = 'approved' | 'changes-requested' | 'review-required' | 'none' +``` + +```ts type-equiv +/** Status of one change request at the time its provider read it. */ +interface ChangeRequestSnapshot { + /** Change request the snapshot describes. */ + readonly ref: ChangeRequestRef + /** Web page of the change request. */ + readonly url: string + /** Title as the author wrote it. */ + readonly title: string + /** Lifecycle state. */ + readonly state: ChangeRequestState + /** Whether the author marked the change request as a draft. */ + readonly draft: boolean + /** Author login, or null when the host hides it. */ + readonly author: string | null + /** Branch that carries the proposed commits. */ + readonly headBranch: string + /** Commit object name at the head of the change request. */ + readonly headSha: string + /** Branch the change request merges into. */ + readonly baseBranch: string + /** Lines the change request adds. */ + readonly additions: number + /** Lines the change request deletes. */ + readonly deletions: number + /** Merge conflict status; `unknown` while the host computes it. */ + readonly mergeability: Mergeability + /** + * Whether the host would merge the change request now for the signed-in + * user: open, not a draft, and meeting the base branch's merge rules. + */ + readonly mergeReady: boolean + /** Merge methods the repository accepts, the signed-in user's usual one first. */ + readonly mergeMethods: readonly MergeMethod[] + /** Aggregate review outcome. */ + readonly reviewDecision: ReviewDecision + /** Checks on the head commit in host order. */ + readonly checks: readonly ChangeRequestCheck[] + /** Combined check outcome. */ + readonly checksRollup: ChecksRollup + /** Whether the host reported more checks than `checks` contains. */ + readonly checksTruncated: boolean + /** Number of review threads that are not resolved. */ + readonly unresolvedThreads: number + /** ISO 8601 time of the host's last update to the change request. */ + readonly updatedAt: string + /** ISO 8601 time the change request was merged or closed, or null while it is open. */ + readonly closedAt: string | null +} +``` + +```ts type-equiv +/** Why a provider could not read a change request. */ +interface ForgeFailure { + /** + * `not-found`: the host does not expose it; `unauthenticated`: credentials are missing or rejected; + * `too-large`: the host or the provider refuses an answer over its size limit, so reading again fails + * the same way; `unavailable`: any other failure. + */ + readonly code: 'not-found' | 'unauthenticated' | 'too-large' | 'unavailable' + /** Human-readable cause. */ + readonly message: string +} +``` + +```ts type-equiv +/** Latest polling result delivered to watchers of one change request. */ +type ChangeRequestUpdate = { + /** Canonical identity of the watched change request. */ + readonly key: ChangeRequestKey + /** Epoch milliseconds when the result settled. */ + readonly fetchedAt: number +} & ({ + /** Status read from the host. */ + readonly snapshot: ChangeRequestSnapshot +} | { + /** Reason the status could not be read. */ + readonly failure: ForgeFailure +}) +``` + +```ts type-equiv +/** + * Receives every polling result for one watched change request. + * @param update - the settled result. + */ +type ChangeRequestListener = (update: ChangeRequestUpdate) => void +``` + +## 变更请求操作 + +`ctx.forge.markReady` 把草稿标记为可评审,`ctx.forge.merge` 只在 head 仍是用户确认的提交时合并变更请求。主机拒绝时,两者都 resolve 为 `ForgeActionFailure` 而不是抛错,并在结束后立即轮询被监听的变更请求。快照的 `mergeReady` 与 `mergeMethods` 告诉消费方何时提供合并、可用哪些合并方式;GitHub 如何给出这两项由 [GitHub 提供方 README](../../packages/experimental/forge-github/README.zh.md#use-this-package) 说明。 + +```ts type-equiv +/** A merge the user confirmed for the head commit they saw. */ +interface MergeRequest { + /** How the commits enter the base branch. */ + readonly method: MergeMethod + /** Commit object name the head must still have; the merge is refused once the head moves. */ + readonly headSha: string +} +``` + +```ts type-equiv +/** Why a provider could not change a change request. */ +interface ForgeActionFailure { + /** + * `unauthenticated`: credentials are missing or rejected; `stale`: the head + * moved past the confirmed commit; `failed`: the host refused or could not be reached. + */ + readonly code: 'unauthenticated' | 'stale' | 'failed' + /** Human-readable cause, usually the host's own message. */ + readonly message: string +} +``` + +## Session 绑定 + +`ctx.forgeSession` 从每个 Session 自己的日志派生其绑定:`gh pr create` 的结果绑定其输出的 URL,`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 +/** + * How a change request became bound to a Session: `created` when a + * `gh pr create` result printed its URL, `referenced` when a `gh pr view`, + * `gh pr edit`, or `gh pr checkout` call named it or its result printed its + * own URL, `discovered` when a lookup found it for a branch the Session pushed + * or works on, `tracked` when a caller bound it by URL. + */ +type BindingOrigin = 'created' | 'referenced' | 'discovered' | 'tracked' +``` + +```ts type-equiv +/** Payload of the `plugin:forge-session/binding` record. */ +interface BindingRecord { + /** + * `track` binds or restores, `dismiss` hides, `discover` binds a branch lookup, + * and `reference` binds a CLI selector lookup without restoring dismissed entries. + */ + readonly action: 'track' | 'dismiss' | 'discover' | 'reference' + /** Web URL of the change request. */ + readonly url: string +} +``` + +```ts type-equiv +/** One change request bound to a Session and not dismissed, with its latest polling result. */ +interface SessionChangeRequest { + /** Web URL as first recorded. */ + readonly url: string + /** How the change request became bound. */ + readonly origin: BindingOrigin + /** Parsed reference, or null when no registered provider serves the URL. */ + readonly ref: ChangeRequestRef | null + /** Latest polling result, or null before the first poll and without a provider. */ + readonly update: ChangeRequestUpdate | null +} +``` + +```ts type-equiv +/** + * Receives the complete visible list after a binding or provider change, or a polling result. + * @param changeRequests - every visible binding of the watched Session, in first-recorded order. + */ +type SessionChangeRequestsListener = (changeRequests: readonly SessionChangeRequest[]) => void +``` + +## 面板 + +`ctx.forgePanel` 是 [forge 面板](../../packages/experimental/client-ui-forge/README.zh.md#use-this-package)的宿主 Remote。跟随一个 Session 时,它以流的形式推送 `ForgePanelState`:在 Session 执行环境中对其当前目录进行的只读 Git 检查、从上游远端或 `origin` 识别出的代码托管平台、该平台的登录状态、Session 的绑定及其轮询状态与最近的快照,以及它的 worktree 请求。它的操作可以列出基准分支、绑定和隐藏 URL、刷新、把已绑定的草稿标记为可评审或合并已绑定的变更请求、请求或撤回 worktree、停止正在进行的创建,以及恢复已删除的 worktree 或离开它回到源检出。请求是一条 `plugin:client-ui-forge/worktree` 记录。消息在 Session 的 Agent 空闲时进入其收件箱,面板会在这次投递唤醒 [Agent](../../packages/core/agent/README.zh.md) 之前启动它的维护任务,暂留这条消息的这一轮:对未完成的请求,它通过 `ctx.worktrees` 创建 worktree,尝试失败或被停止后等待下一次尝试或请求被撤回;对在所创建 worktree 中工作的 Session,它检查该 worktree,已被删除时等待它被恢复或离开。面板状态说明消息在等待什么,以及最近一次失败的尝试。对于没有被暂留的消息,由第一个步骤提议创建 worktree。第一轮开始之后的请求会被忽略。对 Session 检出的改动以其 Agent 的维护任务运行,因此其他客户端发来的消息会等它们完成。 + +```ts type-equiv +/** Git facts for the checkout that contains the Session's current directory. */ +interface ForgeCheckout { + /** Top-level directory of the checkout. */ + readonly root: string + /** Checked-out branch, or null when HEAD is detached. */ + readonly branch: string | null + /** Whether the checkout is a linked worktree of another checkout's repository. */ + readonly linked: boolean + /** Abbreviated commit HEAD names, or null while the checked-out branch has no commit. */ + readonly commit: string | null +} +``` + +```ts type-equiv +/** Inspection result for the Session's current directory. */ +type ForgeCheckoutState = + /** The first inspection has not finished. */ + | { readonly state: 'pending' } + /** The current directory is not inside a Git checkout. */ + | { readonly state: 'none' } + /** Git could not run or reported an error. */ + | { readonly state: 'failed'; readonly message: string } + /** The checkout facts. */ + | { readonly state: 'ready'; readonly checkout: ForgeCheckout } +``` + +```ts type-equiv +/** The worktree the panel created and entered for a Session before its first step. */ +interface ForgeSessionWorktree { + /** Canonical checkout directory. */ + readonly path: string + /** Branch created for the checkout. */ + readonly branch: string + /** Requested base branch, or null when the checkout started from HEAD of the source checkout. */ + readonly from: string | null + /** Commit object name the branch started from. */ + readonly baseCommit: string + /** Canonical root of the source checkout. */ + readonly repositoryRoot: string +} +``` + +```ts type-equiv +/** Payload of the `plugin:client-ui-forge/worktree` record. */ +type WorktreeRecord = + /** Create a worktree before the Session's first step, starting from `from`, or from HEAD when it is null. */ + | { readonly action: 'request'; readonly from: string | null } + /** Withdraw the open request. */ + | { readonly action: 'cancel' } + /** The requested worktree was created and became the Session's working directory. */ + | { readonly action: 'created'; readonly worktree: ForgeSessionWorktree } + /** Restoration started; the checkout stays unavailable until restoration completes. */ + | { readonly action: 'restoring' } + /** The recorded worktree was completely restored. */ + | { readonly action: 'restored' } +``` + +```ts type-equiv +/** One open worktree request. */ +interface ForgeWorktreeRequest { + /** Base branch, or null for HEAD of the source checkout. */ + readonly from: string | null +} +``` + +```ts type-equiv +/** Why the Session's checkout cannot host a new worktree. */ +type ForgeWorktreeBlocker = + /** The current directory is not inside a Git checkout. */ + | 'not-git' + /** The Session's sandbox cannot write the checkout or its Git directory, as for a linked worktree outside the writable root. */ + | 'outside-writable-root' + /** The Session runs read-only. */ + | 'read-only' +``` + +```ts type-equiv +/** One running worktree creation. */ +interface ForgeWorktreeCreation { + /** Running step: `prepare` validates and creates the branch, then `checkout` writes the files. */ + readonly phase: 'prepare' | 'checkout' + /** Branch the attempt creates. */ + readonly branch: string + /** Base branch, or null for HEAD. */ + readonly from: string | null + /** + * Whether a message waits for the creation before its turn starts. Only + * such a creation can be stopped, after which the message keeps waiting; a + * creation at the first step serves a message the Session already holds. + */ + readonly held: boolean +} +``` + +```ts type-equiv +/** One running restoration of the removed worktree a Session works in. */ +interface ForgeWorktreeRestoration { + /** Running step: `prepare` registers the checkout again, creating a missing branch, then `checkout` writes the files. */ + readonly phase: 'prepare' | 'checkout' +} +``` + +```ts type-equiv +/** The last attempt to create the requested worktree for a message, which failed. */ +interface ForgeWorktreeFailure { + /** Branch the attempt created, or null when it failed before naming one. */ + readonly branch: string | null + /** Base branch of the attempt, or null for HEAD. */ + readonly from: string | null + /** Step that failed. */ + readonly phase: 'prepare' | 'checkout' + /** Failure text. */ + readonly message: string +} +``` + +```ts type-equiv +/** + * What a message waits for before its turn starts: the creation of the + * requested worktree, or the restoration or leaving of the removed worktree + * the Session works in. + */ +type ForgeWorktreeWaiting = 'creation' | 'restoration' +``` + +```ts type-equiv +/** The recorded worktree whose directory is missing, invalid, or incompletely restored. */ +interface ForgeMissingWorktree { + /** Whether its branch exists, or null when an incomplete restoration cannot inspect it. */ + readonly branchExists: boolean | null +} +``` + +```ts type-equiv +/** Worktree facts of one Session in the panel state. */ +interface ForgePanelWorktree { + /** + * Whether the Session can still request or withdraw a worktree: a worktree + * service is mounted, no turn has started, and none was created. + */ + readonly editable: boolean + /** Why the checkout cannot host a worktree, or null when it can, before the first inspection, and after a failed one. */ + readonly blocker: ForgeWorktreeBlocker | null + /** Open request, or null without one. */ + readonly request: ForgeWorktreeRequest | null + /** Created worktree, or null before creation. */ + readonly created: ForgeSessionWorktree | null + /** Running creation, or null. */ + readonly creation: ForgeWorktreeCreation | null + /** + * The last failed attempt of the creation a message waits for, or null. It + * clears when another attempt starts, when the request changes or is + * withdrawn, and when the wait ends. + */ + readonly failure: ForgeWorktreeFailure | null + /** The created worktree the Session works in when its directory is gone, or null. */ + readonly missing: ForgeMissingWorktree | null + /** Running restoration of the removed worktree, or null. */ + readonly restoration: ForgeWorktreeRestoration | null + /** What a message of the Session waits for before its turn starts, or null while none waits. */ + readonly waiting: ForgeWorktreeWaiting | null +} +``` + +```ts type-equiv +/** One bound change request in the panel state. */ +interface ForgePanelChangeRequest extends SessionChangeRequest { + /** Latest snapshot a poll returned, kept while later polls fail, or null before the first successful poll. */ + readonly snapshot: ChangeRequestSnapshot | null +} +``` + +```ts type-equiv +/** Everything the panel shows for one Session. */ +interface ForgePanelState { + /** Current working directory of the Session. */ + readonly directory: string + /** Inspection result for `directory`. */ + readonly checkout: ForgeCheckoutState + /** Code host repository of the checkout's upstream remote or `origin`, or null when no provider serves them. */ + readonly repository: ForgeRepository | null + /** Sign-in state for the repository's host, or null before the first probe and without a repository. */ + readonly readiness: ForgeReadiness | null + /** Visible change request bindings with their latest polling results, in first-recorded order. */ + readonly changeRequests: readonly ForgePanelChangeRequest[] + /** Worktree request and creation state. */ + readonly worktree: ForgePanelWorktree + /** + * Files the `branch` scope changes, or null before the first inspection, + * outside a checkout, and without a fork point. + */ + readonly local: ForgeLocalChanges | null +} +``` + +```ts type-equiv +/** Local branches offered as the base of a new worktree. */ +interface ForgeBranches { + /** Checked-out branch, or null when HEAD is detached. */ + readonly current: string | null + /** Local branch names, `current` first and then most recently committed first, at most `maxBranches`. */ + readonly branches: readonly string[] +} +``` + +会话列表经同一个 Remote 读取。`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 +/** Which local changes a diff covers; each reads the Session's checkout through Git. */ +type ForgeLocalScope = + /** + * The working tree and untracked files against the fork point: the base commit of the worktree the + * Session created, else the merge base of HEAD with the default branch of the code host remote. + */ + | { readonly kind: 'branch' } + /** The working tree and untracked files against HEAD. */ + | { readonly kind: 'uncommitted' } + /** The working tree and untracked files against the index. */ + | { readonly kind: 'unstaged' } + /** The index against HEAD. */ + | { readonly kind: 'staged' } +``` + +```ts type-equiv +/** One hunk of a file diff. */ +interface ForgeDiffHunk { + /** First old line the hunk covers, or the old line it comes before when it covers none. */ + readonly oldStart: number + /** Old lines the hunk covers. */ + readonly oldLines: number + /** First new line the hunk covers, or the new line it comes before when it covers none. */ + readonly newStart: number + /** New lines the hunk covers. */ + readonly newLines: number + /** Lines in patch form: a leading space for context, `+` for added, `-` for deleted, and `\` for a missing final newline. */ + readonly lines: readonly string[] +} +``` + +```ts type-equiv +/** How one file changed. */ +type ForgeDiffStatus = 'added' | 'deleted' | 'modified' | 'renamed' +``` + +```ts type-equiv +/** One changed file of a diff. */ +interface ForgeDiffFile { + /** Path relative to the repository root after the change, or before it for a deleted file. */ + readonly path: string + /** Path before a rename, else null. */ + readonly previousPath: string | null + /** How the file changed. */ + readonly status: ForgeDiffStatus + /** Whether the content is binary, which carries no hunks. */ + readonly binary: boolean + /** Whether an untracked file exceeds `maxDiffBytes`, so it carries no hunks. */ + readonly oversized: boolean + /** Whether Git does not track the file yet; only local diffs list such files. */ + readonly untracked: boolean + /** Lines added. */ + readonly additions: number + /** Lines deleted. */ + readonly deletions: number + /** Hunks in file order; none for a binary file or a change of mode or name only. */ + readonly hunks: readonly ForgeDiffHunk[] +} +``` + +```ts type-equiv +/** Local changes of the Session's checkout in one scope. */ +interface ForgeLocalDiff { + /** Ref or abbreviated commit the `branch` scope compares with, such as `origin/master`; null for other scopes. */ + readonly base: string | null + /** Changed files in Git's path order, untracked files last. */ + readonly files: readonly ForgeDiffFile[] + /** Untracked files left out beyond `maxUntrackedFiles`. */ + readonly omitted: number +} +``` + +```ts type-equiv +/** Changes of a change request as its code host computes them. */ +interface ForgeChangeRequestDiff { + /** Head commit the diff describes. */ + readonly headSha: string + /** Changed files in the host's order. */ + readonly files: readonly ForgeDiffFile[] + /** + * Commits the checkout's branch has beyond the head when that branch is the change request's head + * branch and the head commit is present locally; null otherwise. + */ + readonly localAhead: number | null + /** Web page of each changed file at the head commit, by path. */ + readonly fileUrls: Readonly> +} +``` + +```ts type-equiv +/** One file the `branch` scope changes, without its hunks. */ +interface ForgeChangedFile { + /** Path relative to the repository root after the change. */ + readonly path: string + /** Lines added, or 0 for a binary file. */ + readonly additions: number + /** Lines deleted, or 0 for a binary file. */ + readonly deletions: number + /** Whether the content is binary. */ + readonly binary: boolean +} +``` + +```ts type-equiv +/** Size of the `branch` scope, which the panel's local changes card shows. */ +interface ForgeLocalChanges { + /** Ref or abbreviated commit the scope compares with. */ + readonly base: string + /** Changed files in Git's path order, untracked files last, at most `maxUntrackedFiles` of those. */ + readonly files: readonly ForgeChangedFile[] +} +``` + + + + + +## Cordis API + +Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.zh.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). + + + +### `ctx.forge` — `ForgeService` + +Registry of forge providers. Watchers of one change request share a single polling loop; each forge has at most one fetch in flight, which batches every due change request of that forge. + +```ts cordis-catalog +/** + * Register the provider of one forge family as an effect on the calling + * fiber. Watches of that forge receive an `unavailable` failure when the + * registration ends. Unregistering cancels and awaits its in-flight calls; + * results from the withdrawn registration are discarded. + * @param provider - provider to serve `provider.forge`. + * @returns the disposer that unregisters the provider and waits for its calls to finish. + * @throws when another provider already serves the same forge. + */ +register(provider: ForgeProvider): () => Promise + +/** + * Map a Git remote URL to a repository through the first provider that serves it. + * @param url - fetch or push URL as Git reports it. + * @returns the repository, or undefined when no provider serves the URL. + */ +repositoryFromRemote(url: string): ForgeRepository | undefined + +/** + * Map a change request web URL through the first provider that serves it. + * @param url - web URL of a pull or merge request. + * @returns the change request, or undefined when no provider serves the URL. + */ +changeRequestFromUrl(url: string): ChangeRequestRef | undefined + +/** + * Build the canonical web URL of a change request. + * @param ref - change request served by a registered provider. + * @returns the provider's canonical URL. + * @throws when no provider serves the change request's forge. + */ +changeRequestUrl(ref: ChangeRequestRef): string + +/** + * Find open change requests whose head is `branch`, excluding the default branch and forks. + * @param repository - repository served by a registered provider. + * @param branch - head branch name on the host. + * @param signal - cancellation for the lookup. + * @returns matching change requests, newest first as the provider reports them. + * @throws when no provider serves the repository's forge or the lookup fails. + */ +async findByHead(repository: ForgeRepository, branch: string, signal: AbortSignal): Promise + +/** + * Report whether the provider can reach the repository's host with current credentials. + * @param repository - repository served by a registered provider. + * @param signal - cancellation for the probe. + * @returns the provider's readiness for the host. + * @throws when no provider serves the repository's forge. + */ +async readiness(repository: ForgeRepository, signal: AbortSignal): Promise + +/** + * Mark a draft change request ready for review, then poll it at once when it is watched. + * @param ref - change request served by a registered provider. + * @param signal - cancellation of the request. + * @returns undefined once the host accepted the change, or why it did not. + * @throws when no provider serves the change request's forge, or with `signal.reason` when it aborts. + */ +async markReady(ref: ChangeRequestRef, signal: AbortSignal): Promise + +/** + * Merge a change request while its head is still `request.headSha`, then + * poll it at once when it is watched. + * @param ref - change request served by a registered provider. + * @param request - merge method and the head commit the user confirmed. + * @param signal - cancellation of the request. + * @returns undefined once the host merged it, or why it did not. + * @throws when no provider serves the change request's forge, or with `signal.reason` when it aborts. + */ +async merge(ref: ChangeRequestRef, request: MergeRequest, signal: AbortSignal): Promise + +/** + * Read the changes of a change request as its host computes them. + * @param ref - change request served by a registered provider. + * @param signal - cancellation of the read. + * @returns one unified patch with `a/` and `b/` path prefixes, or why the host did not provide it. + * @throws when no provider serves the change request's forge, or with `signal.reason` when it aborts. + */ +async diff(ref: ChangeRequestRef, signal: AbortSignal): Promise + +/** + * Read the text of one file at a commit of a change request's repository. + * @param ref - change request served by a registered provider. + * @param commit - commit object name. + * @param path - path relative to the repository root. + * @param signal - cancellation of the read. + * @returns the text, or why the host did not provide it. + * @throws when no provider serves the change request's forge, or with `signal.reason` when it aborts. + */ +async fileText(ref: ChangeRequestRef, commit: string, path: string, signal: AbortSignal): Promise + +/** + * Build the web page of one file at a commit of a change request's repository. + * @param ref - change request served by a registered provider. + * @param commit - commit object name. + * @param path - path relative to the repository root. + * @returns the provider's URL. + * @throws when no provider serves the change request's forge. + */ +fileUrl(ref: ChangeRequestRef, commit: string, path: string): string + +/** + * Watch one change request as an effect on the calling fiber. The first + * watcher starts polling at once; listeners receive every later result, + * and `latest` returns the current one. Polling stops when the last watcher + * leaves. Listeners must not throw; a thrown error is logged and skipped. + * @param ref - change request to poll. + * @param listener - receives each polling result. + * @returns the disposer that stops this watch. + */ +watch(ref: ChangeRequestRef, listener: ChangeRequestListener): () => void + +/** + * Poll a watched change request at once, including after a terminal state. + * A fetch already in flight is followed immediately by another fetch; an + * unwatched change request is unchanged. + * @param ref - change request to poll. + */ +refresh(ref: ChangeRequestRef): void + +/** + * Read the latest polling result of a watched change request. + * @param ref - change request to read. + * @returns the latest result, or undefined before the first result or without a watch. + */ +latest(ref: ChangeRequestRef): ChangeRequestUpdate | undefined +``` + +Source: [`packages/experimental/forge/src/index.ts`](../../packages/experimental/forge/src/index.ts) + + + +### `ctx.forgePanel` — `ForgePanelController` + +Serves the forge panel. Following a Session inspects the checkout of its current directory read-only in the Session's execution world, recognizes the code host of the upstream remote or `origin`, probes sign-in for that host, and polls the Session's bound change requests through `ctx.forge` until the last follower of that Session leaves. A worktree request makes the panel create a branch and checkout through an optional `ctx.worktrees` and enter it while the Session's first message waits in the inbox, or at the first step proposal of a message no wait took. After a failed or stopped attempt the message keeps waiting for another attempt or for the request to be withdrawn. A message also waits while the worktree the Session works in is gone, until that worktree is checked out again or left for the source checkout. Changes to a Session's checkout run as Agent maintenance, so a message that arrives meanwhile waits for them. + +```ts cordis-catalog +/** + * Stream the panel state of a Session: the current state first, then each + * later state. Intermediate states may be skipped when the reader is slower + * than the updates. The stream ends when `signal` aborts or the Remote stops. + * @param session - Session to follow. + * @param signal - ends the stream. + * @returns panel states in publication order. + */ +@Remote({ mode: 'stream' }) async *follow(session: Session, signal: AbortSignal): AsyncIterable + +/** + * 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 + +/** + * List the local branches that can start a worktree of the Session's current checkout. + * @param session - Session whose current directory is listed. + * @param signal - request cancellation. + * @returns the checked-out branch and at most `maxBranches` local branches, the checked-out one first. + * @throws when the current directory is not inside a checkout or Git fails. + */ +@Remote async branches(session: Session, signal: AbortSignal): Promise + +/** + * Bind a change request to a Session by URL, or restore a dismissed binding. + * @param session - Session that receives the binding. + * @param url - web URL of the change request. + * @param signal - request cancellation. + * @throws `forgePanel/unrecognized-url` when no registered provider serves the URL. + */ +@Remote async track(session: Session, url: string, signal: AbortSignal): Promise + +/** + * Hide a visible binding of a Session; other URLs leave the Session unchanged. + * @param session - Session that owns the binding. + * @param url - web URL of the change request. + * @param signal - request cancellation. + */ +@Remote async dismiss(session: Session, url: string, signal: AbortSignal): Promise + +/** + * Reinspect a followed Session's checkout, probe sign-in again, and poll its + * change requests at once; an unfollowed Session is unchanged. + * @param session - Session whose panel refreshes. + * @param signal - request cancellation. + * @returns settles once the inspection and the sign-in probe have ended; poll results reach the panel stream later. + */ +@Remote async refresh(session: Session, signal: AbortSignal): Promise + +/** + * Read the changes one scope covers in the checkout of the Session's + * current directory, with Git confined to the Session's read-only sandbox. + * @param session - Session whose checkout is read. + * @param scope - which changes to read. + * @param signal - request cancellation. + * @returns the files, the base of the `branch` scope, and the untracked files left out. + * @throws `forgePanel/not-a-checkout` outside a checkout; `forgePanel/no-fork-point` for the `branch` + * scope of a checkout without one; `forgePanel/diff-too-large` over `maxDiffBytes`; the Git error when Git fails. + */ +@Remote async localDiff(session: Session, scope: ForgeLocalScope, signal: AbortSignal): Promise + +/** + * Read the new side of one changed file in a scope, which supplies the + * unchanged lines between its hunks: the index for `staged`, else the working tree. + * @param session - Session whose checkout is read. + * @param scope - the scope the diff was read in. + * @param path - path relative to the checkout's top-level directory, as the diff names it. + * @param signal - request cancellation. + * @returns the text. + * @throws `forgePanel/not-a-checkout` outside a checkout; `forgePanel/diff-too-large` over `maxDiffBytes`; + * an error for a path outside the checkout, binary content, or a failed read. + */ +@Remote async localText(session: Session, scope: ForgeLocalScope, path: string, signal: AbortSignal): Promise + +/** + * Read the changes of a change request of a Session as its code host + * computes them, at the head commit of its latest status. A patch already + * read at the same head commit and base branch is reused from memory, for + * any Session, until `maxCachedDiffBytes` evicts it. + * @param session - Session the change request is bound to. + * @param url - binding URL of the change request. + * @param signal - request cancellation. + * @returns the files, the head commit, each file's web page, and the commits the checkout has beyond the head. + * @throws `forgePanel/not-bound` unless the URL is a visible binding of the Session that a provider serves; + * `forgePanel/diff-too-large` when the host or its provider refuses the diff over a size limit; + * `forgePanel/diff-failed` before its status was read and when the host does not provide the diff otherwise. + */ +@Remote async changeRequestDiff(session: Session, url: string, signal: AbortSignal): Promise + +/** + * Read one file of a change request's repository at a commit, which + * supplies the unchanged lines between the hunks of its diff. + * @param session - Session the change request is bound to. + * @param url - binding URL of the change request. + * @param commit - commit object name, the head commit of the diff. + * @param path - path relative to the repository root. + * @param signal - request cancellation. + * @returns the text. + * @throws `forgePanel/not-bound` unless the URL is a visible binding of the Session that a provider serves; + * `forgePanel/diff-too-large` when the host or its provider refuses the file over a size limit; + * `forgePanel/diff-failed` when the host does not provide the file otherwise. + */ +@Remote async changeRequestText(session: Session, url: string, commit: string, path: string, signal: AbortSignal): Promise + +/** + * Mark a draft change request of a Session ready for review; the change + * request is polled again once the host answers. + * @param session - Session the change request is bound to. + * @param url - binding URL of the change request. + * @param signal - request cancellation. + * @throws `forgePanel/not-bound` unless the URL is a visible binding of the Session that a provider serves; + * `forgePanel/action-failed` with the host's reason when the host refuses or cannot be reached. + */ +@Remote async markReady(session: Session, url: string, signal: AbortSignal): Promise + +/** + * Merge a change request of a Session while its head is still `headSha`; + * the change request is polled again once the host answers. + * @param session - Session the change request is bound to. + * @param url - binding URL of the change request. + * @param method - how the commits enter the base branch. + * @param headSha - head commit the user confirmed. + * @param signal - request cancellation. + * @throws `forgePanel/not-bound` unless the URL is a visible binding of the Session that a provider serves; + * `forgePanel/stale-head` when the head moved past `headSha`; `forgePanel/action-failed` with the host's + * reason when the host refuses otherwise or cannot be reached. + */ +@Remote async merge(session: Session, url: string, method: MergeMethod, headSha: string, signal: AbortSignal): Promise + +/** + * Ask for a new worktree before the Session's first turn, replacing an open + * request and forgetting a failed attempt. While the next message waits in + * the inbox, or else at the first step proposal, the panel creates a branch + * from `from`, or from HEAD of the current checkout when it is null, and + * enters its checkout. A message that already waits after a failed or + * stopped attempt keeps waiting for {@link ForgePanelController.retryWorktree}. + * @param session - Session without a started turn. + * @param from - local base branch, or null for HEAD. + * @param signal - request cancellation. + * @throws `forgePanel/invalid-branch` when `from` is empty; `forgePanel/worktrees-unavailable` + * without a worktree service; `forgePanel/session-started` after a turn started or the worktree was + * created; `forgePanel/session-busy` while the worktree is being created. + */ +@Remote async requestWorktree(session: Session, from: string | null, signal: AbortSignal): Promise + +/** + * Withdraw the open worktree request of a Session with its failed attempt, + * which lets a waiting message go on in the current checkout; without a + * request the Session is unchanged. Withdrawing needs no worktree service. + * @param session - Session without a started turn. + * @param signal - request cancellation. + * @throws `forgePanel/session-started` after a turn started or the worktree was created; + * `forgePanel/session-busy` while the worktree is being created. + */ +@Remote async cancelWorktree(session: Session, signal: AbortSignal): Promise + +/** + * Stop the creation a message of the Session waits for; the message keeps + * waiting with the request still open. Without such a creation the Session + * is unchanged. + * @param session - Session whose creation stops. + * @param signal - request cancellation. + */ +@Remote async stopCreation(session: Session, signal: AbortSignal): Promise + +/** + * Make another attempt, with a new branch name, at the worktree a message + * of the Session waits for after a failed or stopped attempt. + * @param session - Session whose message waits. + * @param signal - request cancellation. + * @throws `forgePanel/not-waiting` when no message waits or an attempt is running. + */ +@Remote async retryWorktree(session: Session, signal: AbortSignal): Promise + +/** + * Check out the created worktree of a Session again at its recorded + * directory when that directory is gone: its branch when the branch still + * exists, otherwise a new branch of the same name at the base commit. Changes + * that were never committed are not recovered. Runs as Agent maintenance, + * inside the wait of a message that waits for the restoration, which then + * goes on; without a missing directory the Session is unchanged. + * @param session - Session whose worktree directory is gone. + * @param signal - request cancellation, which aborts the restoration. + * @throws `forgePanel/no-worktree` without a created worktree; `forgePanel/worktrees-unavailable` + * without a worktree service; `forgePanel/session-busy` while the Session's Agent is not loaded or + * has other work; `forgePanel/restore-failed` when Git fails. + */ +@Remote async restoreWorktree(session: Session, signal: AbortSignal): Promise + +/** + * Move a Session from its created worktree to the source checkout the + * worktree came from; the Session no longer works in the worktree. Runs as + * Agent maintenance, inside the wait of a message that waits for the removed + * worktree, which then goes on. + * @param session - Session with a created worktree. + * @param signal - request cancellation. + * @throws `forgePanel/no-worktree` without a created worktree; `forgePanel/session-busy` while the + * Session's Agent is not loaded or has other work; the working-directory service's error when the + * source checkout no longer exists. + */ +@Remote async continueInProject(session: Session, signal: AbortSignal): Promise +``` + +Types: [Session](session.zh.md) + +Source: [`packages/experimental/client-ui-forge/src/index.ts`](../../packages/experimental/client-ui-forge/src/index.ts) + + + +### `ctx.forgeSession` — `ForgeSessionService` + +Session-scoped change request bindings. The `forgeChangeRequests` projection derives them from `gh pr create`, `gh pr view`, `gh pr edit`, `gh pr checkout`, and `git push` results and from `plugin:forge-session/binding` records. After each push result, and when a caller reports the branch a Session works on, the service looks up open change requests whose head is that branch and records the unbound ones as discovered. Watching a Session polls its visible change requests through `ctx.forge`. + +```ts cordis-catalog +/** + * List the change requests bound to a Session and not dismissed. + * @param session - Session whose bindings are read. + * @returns visible bindings in first-recorded order, each with its latest polling result. + */ +changeRequests(session: Session): SessionChangeRequest[] + +/** + * Bind a change request to a Session by URL, or restore a dismissed binding. + * Appends a `track` record with the provider's canonical URL unless the + * change request is already visible. + * @param session - Session that receives the binding. + * @param url - web URL of the change request. + * @throws when no registered provider recognizes the URL. + */ +track(session: Session, url: string): void + +/** + * Look up open change requests whose head is a branch the Session works on, + * and record the unbound ones as discovered, as after a push of that branch. + * A remote that no provider recognizes changes nothing; a failed lookup is + * logged and changes nothing. + * @param session - Session that receives the bindings. + * @param remote - URL of the remote that holds the branch. + * @param branch - branch name on that remote. + * @returns resolves once the lookup has recorded its bindings or failed. + */ +discoverBranch(session: Session, remote: string, branch: string): Promise + +/** + * Hide a visible binding. Appends a `dismiss` record; a URL that is not + * visibly bound leaves the log unchanged. Discovery never restores a + * dismissed binding; `track` does. + * @param session - Session that owns the binding. + * @param url - web URL of the change request. + */ +dismiss(session: Session, url: string): void + +/** + * Watch a Session's visible change requests as an effect on the calling + * fiber. Every visible change request that a provider recognizes is polled + * through `ctx.forge` while the Session has a watcher. Listeners receive the + * complete list after each later binding or provider change, or polling result; read the + * current list with {@link changeRequests}. Listeners must not throw; a + * thrown error is logged and skipped. + * @param session - Session to watch. + * @param listener - receives the complete visible list. + * @returns the disposer that stops this watch. + */ +watch(session: Session, listener: SessionChangeRequestsListener): () => void +``` + +Types: [Session](session.zh.md) + +Source: [`packages/experimental/forge-session/src/index.ts`](../../packages/experimental/forge-session/src/index.ts) + + + +### `forge/*` events + + + +#### `forge/providers-changed` — parallel + +Announces a provider registration or withdrawal after the registry changes. Listener failures are logged without preventing other listeners from running. + +```ts cordis-catalog +/** + * Announces a provider registration or withdrawal after the registry changes. + * Listener failures are logged without preventing other listeners from running. + * @param forge - family whose provider changed. + * @mode parallel + */ +'forge/providers-changed'(forge: string): void | Promise +``` + +Source: [`packages/experimental/forge/src/index.ts`](../../packages/experimental/forge/src/index.ts) + diff --git a/docs/subsystems/sidebar-right.i18n.yaml b/docs/subsystems/sidebar-right.i18n.yaml index 83cd761e68..01662fe377 100644 --- a/docs/subsystems/sidebar-right.i18n.yaml +++ b/docs/subsystems/sidebar-right.i18n.yaml @@ -15,8 +15,8 @@ en: b7b8a7b411aee94d zh: 7298c20000945d97 /right-sidebar/navigation-ctx-sidebarright: - en: 0f80b5364cbad804 - zh: db81652a5374fad1 + en: c695e9aca92271f2 + zh: 4b4d820c5a16bed1 /right-sidebar/slots-and-owner-props: en: f58dc6ff6b0027a7 zh: 707722fcbd096efc diff --git a/docs/subsystems/sidebar-right.md b/docs/subsystems/sidebar-right.md index 1342a7f938..78bff4d40c 100644 --- a/docs/subsystems/sidebar-right.md +++ b/docs/subsystems/sidebar-right.md @@ -88,7 +88,7 @@ Two opens are the navigation controller, and every way into the column calls one Placement is the caller's option, never a type's property. The conversation calls `openResource(fileAddressFor(sessionId, cwd, path))` and, from a `read` tool row, adds `{ params: { line } }` from the call's 1-based `offset`; a guide entry box calls `tab.actions.openTab(entry.kind, { replaceTab: true })`; a file-tree row calls `tab.actions.openResource(address)`; the strip's add control calls `openTab('guide', { paneId, revealIfOpened: false })`. -`close(tabId)` closes a tab; `active()` returns the active pane's active tab; `isExpanded()` and `toggleExpanded()` read and flip the column, the flip recorded in the sequence. Reads answer for the no-Session case with `undefined` or `false`; writes need a mounted Session surface and throw without one rather than write into a surface nobody draws. +`close(tabId)` closes a tab; `hide(tabId)` closes a floating tab or collapses its docked column; `active()` returns the active pane's active tab; `isExpanded()` and `toggleExpanded()` read and flip the column, the flip recorded in the sequence. Reads answer for the no-Session case with `undefined` or `false`; writes need a mounted Session surface and throw without one rather than write into a surface nobody draws. `focus(tabId)` makes a tab its pane's active tab; `split(paneId?)` splits the active docked pane, or the named one, and returns the new pane's id — or `undefined`, recording nothing, when the pane budget or the column's width forbids a split; `float(tabId, rect?)` lifts a tab into a floating pane; `dock(paneId)` returns a floating pane to the docked area. All four run the store's existing actions and record one history entry each; a target that does not exist or is already in the requested state is a no-op, and like `open` they throw without a mounted Session surface. `TabId`, `PaneId`, `TabRecord`, and `FloatRect` are re-exported from the package's `/client` entry so a caller needs no dockkit import. diff --git a/docs/subsystems/sidebar-right.zh.md b/docs/subsystems/sidebar-right.zh.md index e7df97d11c..a7ba493452 100644 --- a/docs/subsystems/sidebar-right.zh.md +++ b/docs/subsystems/sidebar-right.zh.md @@ -88,7 +88,7 @@ export function apply(ctx: Context): void { 落位是调用方的选项,从不是类型的属性。会话区调 `openResource(fileAddressFor(sessionId, cwd, path))`,`read` 工具行另加 `{ params: { line } }`(来自调用的 1 起 `offset`);引导页入口框调 `tab.actions.openTab(entry.kind, { replaceTab: true })`;文件树的行调 `tab.actions.openResource(address)`;tab 条的新增控件调 `openTab('guide', { paneId, revealIfOpened: false })`。 -`close(tabId)` 关闭一个 tab;`active()` 返回活动 pane 的活动 tab;`isExpanded()` 与 `toggleExpanded()` 读取与翻转这一列,翻转记入序列。无会话时读操作返回 `undefined` 或 `false`;写操作需要已挂载的会话面,没有时抛错而不是写进没人绘制的面。 +`close(tabId)` 关闭一个 tab;`hide(tabId)` 关闭浮动 tab 或收起其停靠列;`active()` 返回活动 pane 的活动 tab;`isExpanded()` 与 `toggleExpanded()` 读取与翻转这一列,翻转记入序列。无会话时读操作返回 `undefined` 或 `false`;写操作需要已挂载的会话面,没有时抛错而不是写进没人绘制的面。 `focus(tabId)` 让一个 tab 成为其 pane 的活动 tab;`split(paneId?)` 分割活动的停靠 pane 或点名的 pane,返回新 pane 的 id——pane 数预算或列宽不允许时返回 `undefined` 且不记账;`float(tabId, rect?)` 把一个 tab 浮出为浮窗 pane;`dock(paneId)` 把浮窗 pane 收回停靠区。四者都走 store 既有动作、各记一条历史;目标不存在或已处于目标状态时是空操作,与 `open` 一样在没有已挂载会话面时抛错。`TabId`、`PaneId`、`TabRecord`、`FloatRect` 自本包 `/client` 入口再导出,调用方无需引 dockkit。 diff --git a/docs/subsystems/worktrees.i18n.yaml b/docs/subsystems/worktrees.i18n.yaml index 03544fbc81..f771dd3cf2 100644 --- a/docs/subsystems/worktrees.i18n.yaml +++ b/docs/subsystems/worktrees.i18n.yaml @@ -9,5 +9,5 @@ en: 53f643c5d30e00b2 zh: ba9e52efc62c7656 /git-worktrees/request-and-result: - en: 8391b091607600eb - zh: 6c735a337d60d566 + en: fbab866bf824d6e0 + zh: 73d2a1ee350e1434 diff --git a/docs/subsystems/worktrees.md b/docs/subsystems/worktrees.md index 14db6711bc..8d4fec853a 100644 --- a/docs/subsystems/worktrees.md +++ b/docs/subsystems/worktrees.md @@ -14,7 +14,7 @@ Leaving through `working_directory({ cd: path })` retains the checkout and branc ## Request and result -The request selects only the new name and local revision. The result identifies the created checkout and exact commit; it does not change the Session's original project or write permissions. +The request selects only the new name and local revision. The result identifies the created checkout and exact commit; it does not change the Session's original project or write permissions. An optional callback receives each stage of the creation before it begins. A restoration checks a removed checkout out again at its recorded directory, from its branch or, when the branch is gone, from its base commit. ```ts type-equiv /** A new checkout from a locally available Git commit, branch, or tag. */ @@ -40,6 +40,40 @@ interface CreatedWorktree { } ``` +```ts type-equiv +/** + * Stage of one creation or restoration, reported before it begins: `prepare` + * validates the request, allocates the checkout directory, and creates the + * branch, which a restoration creates again only when it is gone; `checkout` + * writes the files and, for a creation, enters the checkout. + */ +type CreateWorktreePhase = 'prepare' | 'checkout' +``` + +```ts type-equiv +/** A removed checkout to check out again at its recorded directory. */ +interface RestoreWorktreeRequest { + /** Canonical checkout directory the worktree had. */ + path: string + /** Branch the worktree had checked out. */ + branch: string + /** Commit the branch started from; a branch that no longer exists is created again here. */ + baseCommit: string + /** Canonical root of the source checkout. */ + repositoryRoot: string +} +``` + +```ts type-equiv +/** Outcome of one restoration. */ +interface RestoredWorktree { + /** Checkout directory, present again. */ + path: string + /** Whether the branch no longer existed and was created again at the base commit, without the commits it had gained. */ + branchRecreated: boolean +} +``` + @@ -65,9 +99,26 @@ Creates retained Git worktrees under the mounted filesystem and sandbox provider * @param agent - caller whose current directory selects the source repository and file policy. * @param request - optional new name and local revision; defaults are generated name and HEAD. * @param signal - cancellation of lookup, creation, and working-directory publication. + * @param onPhase - receives each stage before it begins; it must not throw. * @returns canonical checkout path, branch name, pinned commit, and source repository root. */ -async create(agent: Agent, request: CreateWorktreeRequest = {}, signal?: AbortSignal): Promise +async create( agent: Agent, request: CreateWorktreeRequest = {}, signal?: AbortSignal, onPhase?: (phase: CreateWorktreePhase) => void, ): Promise + +/** + * Check out a removed worktree again at its recorded directory: its branch + * when the branch still exists, otherwise a new branch of the same name at + * `baseCommit`. Only the missing target's unlocked registration is removed, and an + * existing directory fails. Changes that were never committed are not + * recovered. Checkout disables configured clean, smudge, and process filters, + * as creation does. The calling Session's working directory does not change. + * Failures may retain newly allocated Git/filesystem artifacts. + * @param agent - caller whose sandbox policy confines the commands. + * @param request - recorded directory, branch, base commit, and source repository root. + * @param signal - cancellation of the restoration. + * @param onPhase - receives each stage before it begins; it must not throw. + * @returns the directory and whether the branch was created again. + */ +async restore( agent: Agent, request: RestoreWorktreeRequest, signal?: AbortSignal, onPhase?: (phase: CreateWorktreePhase) => void, ): Promise ``` Types: [Agent](core.md) diff --git a/docs/subsystems/worktrees.zh.md b/docs/subsystems/worktrees.zh.md index 719d99266b..e6a7c71ecc 100644 --- a/docs/subsystems/worktrees.zh.md +++ b/docs/subsystems/worktrees.zh.md @@ -14,7 +14,7 @@ ## 请求与结果 -请求只选择新名称与本地版本。结果标识创建的检出目录与确切提交;它不会改变 Session 的原始项目或写权限。 +请求只选择新名称与本地版本。结果标识创建的检出目录与确切提交;它不会改变 Session 的原始项目或写权限。可选的回调会在创建的每个阶段开始之前收到该阶段。恢复会在记录的目录重新检出已删除的检出目录:从它的分支检出;分支也已删除时,从它的基准提交检出。 ```ts type-equiv /** A new checkout from a locally available Git commit, branch, or tag. */ @@ -40,6 +40,40 @@ interface CreatedWorktree { } ``` +```ts type-equiv +/** + * Stage of one creation or restoration, reported before it begins: `prepare` + * validates the request, allocates the checkout directory, and creates the + * branch, which a restoration creates again only when it is gone; `checkout` + * writes the files and, for a creation, enters the checkout. + */ +type CreateWorktreePhase = 'prepare' | 'checkout' +``` + +```ts type-equiv +/** A removed checkout to check out again at its recorded directory. */ +interface RestoreWorktreeRequest { + /** Canonical checkout directory the worktree had. */ + path: string + /** Branch the worktree had checked out. */ + branch: string + /** Commit the branch started from; a branch that no longer exists is created again here. */ + baseCommit: string + /** Canonical root of the source checkout. */ + repositoryRoot: string +} +``` + +```ts type-equiv +/** Outcome of one restoration. */ +interface RestoredWorktree { + /** Checkout directory, present again. */ + path: string + /** Whether the branch no longer existed and was created again at the base commit, without the commits it had gained. */ + branchRecreated: boolean +} +``` + @@ -65,9 +99,26 @@ Creates retained Git worktrees under the mounted filesystem and sandbox provider * @param agent - caller whose current directory selects the source repository and file policy. * @param request - optional new name and local revision; defaults are generated name and HEAD. * @param signal - cancellation of lookup, creation, and working-directory publication. + * @param onPhase - receives each stage before it begins; it must not throw. * @returns canonical checkout path, branch name, pinned commit, and source repository root. */ -async create(agent: Agent, request: CreateWorktreeRequest = {}, signal?: AbortSignal): Promise +async create( agent: Agent, request: CreateWorktreeRequest = {}, signal?: AbortSignal, onPhase?: (phase: CreateWorktreePhase) => void, ): Promise + +/** + * Check out a removed worktree again at its recorded directory: its branch + * when the branch still exists, otherwise a new branch of the same name at + * `baseCommit`. Only the missing target's unlocked registration is removed, and an + * existing directory fails. Changes that were never committed are not + * recovered. Checkout disables configured clean, smudge, and process filters, + * as creation does. The calling Session's working directory does not change. + * Failures may retain newly allocated Git/filesystem artifacts. + * @param agent - caller whose sandbox policy confines the commands. + * @param request - recorded directory, branch, base commit, and source repository root. + * @param signal - cancellation of the restoration. + * @param onPhase - receives each stage before it begins; it must not throw. + * @returns the directory and whether the branch was created again. + */ +async restore( agent: Agent, request: RestoreWorktreeRequest, signal?: AbortSignal, onPhase?: (phase: CreateWorktreePhase) => void, ): Promise ``` Types: [Agent](core.zh.md) diff --git a/docs/tool-catalog.i18n.yaml b/docs/tool-catalog.i18n.yaml index 6cfa137a71..a41bd4c859 100644 --- a/docs/tool-catalog.i18n.yaml +++ b/docs/tool-catalog.i18n.yaml @@ -318,8 +318,8 @@ en: d5f5679abe86e316 zh: d5f5679abe86e316 /tool-schema-catalog/deepseek-ai-dsh-experimental-tool-worktree/create-worktree: - en: 3cf89e31f011ae9b - zh: 28e78b4fae2d075b + en: d8fe3c566ba2147c + zh: baf372cb9242670c /tool-schema-catalog/deepseek-ai-dsh-tool-workflow: en: bb38a9d29f46563c zh: bb38a9d29f46563c diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 9b466762d3..f3961a622e 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -2728,7 +2728,7 @@ Reads or changes the calling Session directory. Existing shells and processes re ### `create_worktree` -Create a new Git branch and worktree from a local commit, branch, or tag, then change this session's working directory to it. Defaults to HEAD and a generated name. Uncommitted files stay in the source checkout. Existing names fail. Use working_directory with cd to leave; the checkout and branch remain. +Create a new Git branch and worktree from a local commit, branch, or tag, then change this session's working directory to it. Uncommitted files stay in the source checkout. Existing names fail. Use working_directory with cd to leave; the checkout and branch remain. ```json { @@ -2736,7 +2736,7 @@ Create a new Git branch and worktree from a local commit, branch, or tag, then c "properties": { "name": { "type": "string", - "description": "New branch name, also used as the checkout directory. Omit to generate a unique name." + "description": "New branch name, also used as the checkout directory: a few lowercase words joined by hyphens that describe the task, such as fix-login-retry. Omit to generate a unique name." }, "from": { "type": "string", diff --git a/docs/tool-catalog.zh.md b/docs/tool-catalog.zh.md index 092c2b2a10..eeb607a067 100644 --- a/docs/tool-catalog.zh.md +++ b/docs/tool-catalog.zh.md @@ -2735,7 +2735,7 @@ todo_write 是会话所有的状态;UI 将最新的 todo/write 事件渲染为 ### `create_worktree` -从本地提交、分支或标签创建新的 Git 分支与工作树,再将本 Session 的工作目录切换到该位置。默认使用 HEAD 和自动生成的名称。未提交文件保留在源检出目录中。名称已存在时失败。使用 working_directory 的 cd 参数离开;检出目录与分支仍然保留。 +从本地提交、分支或标签创建新的 Git 分支与工作树,再将本 Session 的工作目录切换到该位置。未提交文件保留在源检出目录中。名称已存在时失败。使用 working_directory 的 cd 参数离开;检出目录与分支仍然保留。 ```json { @@ -2743,7 +2743,7 @@ todo_write 是会话所有的状态;UI 将最新的 todo/write 事件渲染为 "properties": { "name": { "type": "string", - "description": "New branch name, also used as the checkout directory. Omit to generate a unique name." + "description": "New branch name, also used as the checkout directory: a few lowercase words joined by hyphens that describe the task, such as fix-login-retry. Omit to generate a unique name." }, "from": { "type": "string", diff --git a/docs/upgrade-guide/v0.2.1-rc.3/generated-worktree-names/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-rc.3/generated-worktree-names/guide.i18n.yaml new file mode 100644 index 0000000000..9fc991fbd3 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.3/generated-worktree-names/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-rc.3/generated-worktree-names/guide.md +/: + en: c799b94bab92d848 + zh: 1e1f9cd572fc8665 +/update-consumers-of-generated-worktree-names: + en: 210bc140e21cc68e + zh: dccc592eec81f483 +/update-consumers-of-generated-worktree-names/change: + en: 7a7d682dde36dd7e + zh: b15e84c570d11ee2 +/update-consumers-of-generated-worktree-names/migration: + en: f87101bee9c48150 + zh: 3a79159711475af3 diff --git a/docs/upgrade-guide/v0.2.1-rc.3/generated-worktree-names/guide.md b/docs/upgrade-guide/v0.2.1-rc.3/generated-worktree-names/guide.md new file mode 100644 index 0000000000..6534639f18 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.3/generated-worktree-names/guide.md @@ -0,0 +1,20 @@ +--- +kind: upgrade-guide +description: "Generated worktree names replace the worktree- prefix and full UUID suffix with dsh- and Session-derived names." +--- + +# Update consumers of generated worktree names + +English | [中文](guide.zh.md) + +## Change + +In v0.2.1-rc.3, omitting `name` from `create_worktree` or `ctx.worktrees.create` generates `worktree-` by default. The next release generates `dsh--`, such as `dsh-6b1f02c4-9a3e`. A Session id without a UUID gets `dsh-`. + +Scripts and ignore rules that require the `worktree-` prefix or a full UUID suffix need updating. Explicit names, the configured checkout pool, and existing checkout directories and branches are unchanged. The `namePrefix` setting remains available, but it controls only the prefix, not the suffix format. + +## Migration + +1. Update scripts and ignore rules that recognize generated names. Prefer the returned `path` and `branch` fields over reconstructing them from the old pattern; the [worktree service reference](../../../../packages/experimental/worktree/README.md#use-this-package) describes these fields. +2. To retain the old prefix, set `config.namePrefix` to `worktree-` in the existing `@deepseek-ai/dsh-experimental-worktree` configuration row. This does not restore the full UUID suffix. Supply an explicit `name` when an integration requires a particular complete name. +3. Create a worktree with `name` omitted, inspect its returned `branch` and `path`, and confirm that the updated automation recognizes it. Existing worktrees require no renaming or data migration. diff --git a/docs/upgrade-guide/v0.2.1-rc.3/generated-worktree-names/guide.zh.md b/docs/upgrade-guide/v0.2.1-rc.3/generated-worktree-names/guide.zh.md new file mode 100644 index 0000000000..f754b316eb --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.3/generated-worktree-names/guide.zh.md @@ -0,0 +1,20 @@ +--- +kind: upgrade-guide +description: "自动生成的 worktree 名称将 worktree- 前缀和完整 UUID 后缀改为 dsh- 前缀与基于 Session 的名称。" +--- + +# 更新依赖自动生成 worktree 名称的使用方 + +[English](guide.md) | 中文 + +## 变更 + +在 v0.2.1-rc.3 中,调用 `create_worktree` 或 `ctx.worktrees.create` 时省略 `name`,默认生成 `worktree-`。下一版本默认生成 `dsh--<四位随机十六进制数>`,例如 `dsh-6b1f02c4-9a3e`。Session id 不含 UUID 时,生成 `dsh-<八位随机十六进制数>`。 + +依赖 `worktree-` 前缀或完整 UUID 后缀的脚本和忽略规则需要更新。显式名称、配置的检出父目录以及已有的检出目录和分支保持不变。`namePrefix` 配置项仍然可用,但只控制前缀,不控制后缀格式。 + +## 迁移 + +1. 更新识别自动生成名称的脚本和忽略规则。优先使用返回的 `path` 与 `branch` 字段,不要按旧模式重新拼接;[worktree 服务参考](../../../../packages/experimental/worktree/README.zh.md#use-this-package)说明了这些字段。 +2. 如需保留旧前缀,在现有的 `@deepseek-ai/dsh-experimental-worktree` 配置行中将 `config.namePrefix` 设为 `worktree-`。这不会恢复完整 UUID 后缀;集成要求特定完整名称时,请显式传入 `name`。 +3. 创建一个省略 `name` 的 worktree,检查返回的 `branch` 与 `path`,确认更新后的自动化能识别它。已有 worktree 无需改名或迁移数据。 diff --git a/docs/upgrade-guide/v0.2.1-rc.3/ptc-caller-cancellation/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-rc.3/ptc-caller-cancellation/guide.i18n.yaml new file mode 100644 index 0000000000..50d54a9371 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.3/ptc-caller-cancellation/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-rc.3/ptc-caller-cancellation/guide.md +/: + en: 6deb73f8bd3b1b2a + zh: 8bafa6e8c6277923 +/ptc-caller-cancellation-uses-the-standard-tool-error-code: + en: c647ac85178ef0ea + zh: a6e5b76d53bbb6ef +/ptc-caller-cancellation-uses-the-standard-tool-error-code/change: + en: 1a9a455bf55e6b81 + zh: ec925abc44d7684f +/ptc-caller-cancellation-uses-the-standard-tool-error-code/migration: + en: e3bbbebda24059c0 + zh: 926e91f3ce5a8a72 diff --git a/docs/upgrade-guide/v0.2.1-rc.3/ptc-caller-cancellation/guide.md b/docs/upgrade-guide/v0.2.1-rc.3/ptc-caller-cancellation/guide.md new file mode 100644 index 0000000000..a53b061808 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.3/ptc-caller-cancellation/guide.md @@ -0,0 +1,20 @@ +--- +kind: upgrade-guide +description: "Caller-cancelled run_code results use ABORTED instead of CODE_RUN_FAILED." +--- + +# PTC caller cancellation uses the standard tool error code + +English | [中文](guide.zh.md) + +## Change + +When a running `run_code` call is cancelled by its caller and the runtime reports `abort`, its `tool/result` error changes from `CodeRunFailedError` / `CODE_RUN_FAILED` to `AbortError` / `ABORTED`. The result content is `Error: tool call aborted`, replacing the runtime diagnostic, captured output and sandbox summary. SDK clients and Session consumers that classify these results are affected. + +The Agent continues to record the specific cancellation cause in `turn/end`, including a hook's `reason`. Runtime failures such as timeout and cleanup failure, and runtime-local cancellation without caller cancellation, retain `CODE_RUN_FAILED`. Cancellation still waits for cleanup; it does not roll back completed tool effects, and retained runtime variables may be cleared. + +## Migration + +1. In consumers of new `tool/result` events, classify caller cancellation by `data.error.code === 'ABORTED'`. Read the Agent cancellation cause from the corresponding `turn/end` event's `data.reason.reason` when `data.reason.kind === 'aborted'`. +2. Keep support for `CODE_RUN_FAILED` when reading historical PTC cancellation results. Existing Session logs are not rewritten. +3. Cancel a running `run_code` call and confirm its result has code `ABORTED`, followed by a `turn/end` event containing the original Agent cancellation cause. Confirm ordinary runtime failures still report `CODE_RUN_FAILED`. diff --git a/docs/upgrade-guide/v0.2.1-rc.3/ptc-caller-cancellation/guide.zh.md b/docs/upgrade-guide/v0.2.1-rc.3/ptc-caller-cancellation/guide.zh.md new file mode 100644 index 0000000000..fbd17773ec --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.3/ptc-caller-cancellation/guide.zh.md @@ -0,0 +1,20 @@ +--- +kind: upgrade-guide +description: "调用方取消的 run_code 结果使用 ABORTED,取代 CODE_RUN_FAILED。" +--- + +# PTC 调用方取消使用标准工具错误码 + +[English](guide.md) | 中文 + +## 变更 + +运行中的 `run_code` 被调用方取消且运行时报告 `abort` 时,其 `tool/result` 错误从 `CodeRunFailedError` / `CODE_RUN_FAILED` 变为 `AbortError` / `ABORTED`。结果内容为 `Error: tool call aborted`,取代运行时诊断、捕获输出和沙箱摘要。按这些结果分类的 SDK 客户端和 Session 消费方受到影响。 + +Agent 继续在 `turn/end` 中记录具体取消原因,包括 hook 的 `reason`。超时、清理失败等运行时失败,以及没有调用方取消的运行时内部取消,保留 `CODE_RUN_FAILED`。取消仍等待清理完成;它不会回滚已完成的工具操作,保留的运行时变量可能被清空。 + +## 迁移 + +1. 在新 `tool/result` 事件的消费方中,通过 `data.error.code === 'ABORTED'` 识别调用方取消。当对应 `turn/end` 事件的 `data.reason.kind === 'aborted'` 时,从 `data.reason.reason` 读取 Agent 取消原因。 +2. 读取历史 PTC 取消结果时,继续支持 `CODE_RUN_FAILED`。已有 Session 日志不会被重写。 +3. 取消运行中的 `run_code`,确认其结果错误码为 `ABORTED`,随后的 `turn/end` 事件包含原始 Agent 取消原因。确认普通运行时失败仍报告 `CODE_RUN_FAILED`。 diff --git a/package.json b/package.json index 0c92ce16a5..229ba8880c 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "upload:latest:mac:arm64": "pnpm --filter @deepseek-ai/dsh-desktop run upload:latest:mac:arm64", "upload:latest:mac:x64": "pnpm --filter @deepseek-ai/dsh-desktop run upload:latest:mac:x64", "upload:latest:win:x64": "pnpm --filter @deepseek-ai/dsh-desktop run upload:latest:win:x64", + "purge:latest": "pnpm --filter @deepseek-ai/dsh-desktop run purge:latest", "clean": "tsx scripts/clean.ts", "change-scope": "tsx scripts/change-scope.ts", "typecheck": "tsx scripts/build.ts --scope typecheck", @@ -301,6 +302,9 @@ "@deepseek-ai/dsh-experimental-subagent-oar": "workspace:*", "@deepseek-ai/dsh-experimental-tool-subagent-oar": "workspace:*", "@deepseek-ai/dsh-experimental-cua-macos": "workspace:*", + "@deepseek-ai/dsh-experimental-forge": "workspace:*", + "@deepseek-ai/dsh-experimental-forge-github": "workspace:*", + "@deepseek-ai/dsh-experimental-forge-session": "workspace:*", "@deepseek-ai/dsh-action-cards": "workspace:*" } } diff --git a/packages/client/ui-conversation/src/client/input/decorations.ts b/packages/client/ui-conversation/src/client/input/decorations.ts index d71c4fb252..637c6874a1 100644 --- a/packages/client/ui-conversation/src/client/input/decorations.ts +++ b/packages/client/ui-conversation/src/client/input/decorations.ts @@ -2,7 +2,7 @@ * Plain-text reference scan (the plain-text-reference decision; * see .agents/notes/archived/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md): * a `/name` or `@name` token whose name is on the trigger's lexicon, and - * syntax-recognizable `@dir/` folder tokens. Pure derivation — the editor's + * syntax-recognizable `@path` file and folder tokens. Pure derivation — the editor's * text-ref entity transform consumes these ranges; editing the text out of * match shape simply drops the range next scan. */ @@ -10,9 +10,9 @@ /** * One plain-text reference range (the plain-text-reference decision; * see .agents/notes/archived/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md): - * a `/name` or `@name` token - * whose name is on the trigger's lexicon. Pure derivation — editing the text - * out of match shape simply drops the range next scan. + * a `/name` or `@name` token whose name is on the trigger's lexicon, or an + * `@path` file or folder token. Pure derivation — editing the text out of + * match shape simply drops the range next scan. */ export interface TextRefRange { readonly start: number @@ -22,7 +22,42 @@ export interface TextRefRange { /** Token matcher: a trigger char at line start or after whitespace, then a word-ish name (never crosses \n). */ const TEXT_REF_RE = /(^|\s)([/@])([\w-]+)/g -const FOLDER_REF_RE = /(^|\s)(@(?:"[^"\n]*\/|[^\s"]+\/))/g +/** One `@` path token: a quoted path through its closing quote or line end, or an unquoted run. */ +const PATH_TOKEN_RE = /(^|\s)(@(?:"[^"\n]*"?|[^\s"]\S*))/g +/** + * Punctuation that ends prose rather than a path: the characters the + * sent-message file and folder rules in ui-reference strip from the end of an + * unquoted token. + */ +const TRAILING_PUNCTUATION = new Set('.,;:!?,。;:!?') +/** An unquoted token names a path only with a separator or an extension; a bare `@name` stays plain until a lexicon claims it. */ +const PATH_LIKE_RE = /^@.*[/\\.]/u + +/** + * End of the whole `@path` file or folder token at `start`, so a path is never + * cut at its directory prefix. A closed quote ends at its quote and an + * unquoted token with a separator or extension drops trailing punctuation, + * the spans the sent-message rules render. An open quote (a folder drill in + * progress) ends at its last slash when only trailing punctuation and + * whitespace or the draft end follow it. + * @param draft - draft text. + * @param start - offset of the token's `@`. + * @param token - the path token at `start`. + * @returns the token's end offset, or undefined when it names no path. + */ +function pathEnd(draft: string, start: number, token: string): number | undefined { + if (!token.startsWith('@"')) { + let length = token.length + while (TRAILING_PUNCTUATION.has(token.charAt(length - 1))) length -= 1 + return PATH_LIKE_RE.test(token.slice(0, length)) ? start + length : undefined + } + if (token.length > 3 && token.endsWith('"')) return start + token.length + const slash = token.lastIndexOf('/') + if (slash < 0) return undefined + let next = start + slash + 1 + while (TRAILING_PUNCTUATION.has(draft.charAt(next))) next += 1 + return next === draft.length || /\s/u.test(draft.charAt(next)) ? start + slash + 1 : undefined +} /** * What may follow a `/name` token: whitespace or the draft end, the boundary * the host skill gesture (`dsh-tool-skill`) requires, so `/nfs-hg/xxx`, @@ -56,13 +91,12 @@ export function scanTextRefs( out.push({ start, end: start + 1 + name.length, trigger }) } } - FOLDER_REF_RE.lastIndex = 0 - let folder: RegExpExecArray | null - while ((folder = FOLDER_REF_RE.exec(draft)) !== null) { - const token = folder[2] ?? '' - const start = folder.index + (folder[1]?.length ?? 0) - const end = start + token.length - if (!out.some(range => range.start < end && range.end > start)) { + PATH_TOKEN_RE.lastIndex = 0 + let path: RegExpExecArray | null + while ((path = PATH_TOKEN_RE.exec(draft)) !== null) { + const start = path.index + (path[1]?.length ?? 0) + const end = pathEnd(draft, start, path[2] ?? '') + if (end !== undefined && !out.some(range => range.start < end && range.end > start)) { out.push({ start, end, trigger: '@' }) } } diff --git a/packages/client/ui-conversation/tests/draft-document.client.spec.ts b/packages/client/ui-conversation/tests/draft-document.client.spec.ts index 81cf3de056..8e50e1ad3a 100644 --- a/packages/client/ui-conversation/tests/draft-document.client.spec.ts +++ b/packages/client/ui-conversation/tests/draft-document.client.spec.ts @@ -844,4 +844,15 @@ describe('plain-text reference catalogs', () => { { start: 0, end: 14, trigger: '@' }, ]) }) + + it('matches whole file and folder path tokens, never a directory prefix', () => { + const slices = (text: string) => scanTextRefs(text, new Map()).map(range => text.slice(range.start, range.end)) + expect(slices('@src/deep/file.txt, @"/a/nv/SM100 ISA.html" 看 @"a/ b.md"。 @src/.env @C:\\src\\a.ts @src/de')) + .toEqual(['@src/deep/file.txt', '@"/a/nv/SM100 ISA.html"', '@"a/ b.md"', '@src/.env', '@C:\\src\\a.ts', '@src/de']) + expect(slices('@"dir a/" @src/deep/。 @dir/ 请看 @"src/",然后 @"open dir/ x')) + .toEqual(['@"dir a/"', '@src/deep/', '@dir/', '@"src/"', '@"open dir/']) + expect(slices('@person @"" @"a/b')).toEqual([]) + expect(slices('@"open @')).toEqual([]) + expect(slices('drill @"open dir/。')).toEqual(['@"open dir/']) + }) }) diff --git a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx index fc6af394fd..a657abf806 100644 --- a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx @@ -1938,6 +1938,15 @@ describe('decorations', () => { expect(shell.snapshot.draft).toBe('see @src/components/') }) + it('a pasted path decorates as one whole file or folder token', async () => { + const { view, shell, textarea } = bench() + const marks = () => [...view.container.querySelectorAll('[data-composer-text-ref]')].map(el => el.textContent) + const text = 'read @"/a/nv/SM100 ISA.html" and @src/deep/file.txt then @src/deep/ first' + fireEvent.paste(textarea, { clipboardData: { items: [], getData: () => text } }) + await vi.waitFor(() => { expect(shell.snapshot.draft).toBe(text) }) + expect(marks()).toEqual(['@"/a/nv/SM100 ISA.html"', '@src/deep/file.txt', '@src/deep/']) + }) + it('a plain-text reference keeps its DOM node while typing ahead of it (bug #2793 regression)', () => { const { view, shell } = bench() act(() => { shell.setDraft('see @src/components/ here') }) diff --git a/packages/client/ui-deliverables/README.i18n.yaml b/packages/client/ui-deliverables/README.i18n.yaml index e18a4ecc9a..ff6839eee5 100644 --- a/packages/client/ui-deliverables/README.i18n.yaml +++ b/packages/client/ui-deliverables/README.i18n.yaml @@ -18,8 +18,8 @@ en: 4a2fcdcf13276fef zh: 8bde03bbbf15840c /deepseek-ai-dsh-client-ui-deliverables/use-this-package/explicit-deliveries: - en: 62d4ae0abeeba61e - zh: 32eb43e49166bf61 + en: 5fc6e2d7926f08c0 + zh: 18cdb7450add873e /deepseek-ai-dsh-client-ui-deliverables/use-this-package/the-changed-files-card: en: 4b279e2bddddc083 zh: a3135393299c935b diff --git a/packages/client/ui-deliverables/README.md b/packages/client/ui-deliverables/README.md index a82b593ea8..6727c12776 100644 --- a/packages/client/ui-deliverables/README.md +++ b/packages/client/ui-deliverables/README.md @@ -36,7 +36,7 @@ The `present` tool's preparing stage is one non-expandable document-icon row. It The Web `standard`, `ptc`, and `cordis` presets expose `present` for final files accessible through the Session filesystem, including files created through Bash. Call it with `files: [{ path, description? }]` after creating the files. The [present tool](../../deliverables/deliverable-file-references/README.md) owns file-count limits and Session declarations. A single delivery fills the row; multiple deliveries form a two-column grid, collapsed after four files. Cards show the basename, description or file type, and a shared native opening control. Delivery and changed-file icon tiles use white at 50% opacity in the light theme and 5% in the dark theme. Icon tiles and single-file changed cards use the same border as delivery cards. Clicking the card previews the file in the right Sidebar. The control opens the default application and lists associated applications, with file reveal last. Matching inline-code references also open Sidebar previews. Delivery cards appear in the turn tail only after the turn ends, so a turn waiting on a blocking question shows none yet; the `present` tool description directs the model to link files in its message for earlier review. Cards include every declaration in the turn, including declarations recorded after the closing reply; duplicate declarations use the last description. -The `present` tool row retains its document icon and recorded result across progress, success, failure, and interruption. Native actions share pending state and publish progress or retryable errors on the card. Successful open or reveal feedback stays visible for five seconds, fades over 200ms, then restores the file description; failures remain visible until another attempt. Desktop metadata is loaded when cards mount and invalidated on connection replacement. Without a Host desktop, cards retain Sidebar preview but omit native controls; failed metadata reads offer Retry. Delivery cards and change review expose `deliverables.file.actions` and `deliverables.review.file.actions`, supplied by [ui-open-in-app](../ui-open-in-app/README.md). Association GET requests and native POST actions both validate the recorded file coordinates and Session filesystem mapping before using the Host desktop. +The `present` tool row retains its document icon and recorded result across progress, success, failure, and interruption. Native actions share pending state and publish progress or retryable errors on the card. An acknowledged open or reveal immediately restores the file description; failures remain visible until another attempt. Each gesture settles on a discriminated outcome — acknowledged open, acknowledged reveal, named failure, or a request dropped because the same file was already opening or the plugin was disposed — which the contributed control reports according to the mobile success and shared failure policy in [ui-open-in-app](../ui-open-in-app/README.md). Disposing the plugin cancels pending requests and suppresses their announcements. Desktop metadata is loaded when cards mount and invalidated on connection replacement. Without a Host desktop, cards retain Sidebar preview but omit native controls; failed metadata reads offer Retry. Delivery cards and change review expose `deliverables.file.actions` and `deliverables.review.file.actions`, supplied by [ui-open-in-app](../ui-open-in-app/README.md). Association GET requests and native POST actions both validate the recorded file coordinates and Session filesystem mapping before using the Host desktop. ### The changed-files card diff --git a/packages/client/ui-deliverables/README.zh.md b/packages/client/ui-deliverables/README.zh.md index 52169d6b34..45484627dd 100644 --- a/packages/client/ui-deliverables/README.zh.md +++ b/packages/client/ui-deliverables/README.zh.md @@ -36,7 +36,7 @@ kind: "package-reference" Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present`,用于声明会话文件系统可访问的最终文件,包括通过 Bash 创建的文件。创建后以 `files: [{ path, description? }]` 调用。[present 工具](../../deliverables/deliverable-file-references/README.zh.md)拥有文件数量限制和会话声明。单个交付占满一行,多个交付使用双列网格,超过四个时默认收起。卡片显示文件名、说明或文件类型,以及共享的原生打开控件。交付和改动文件卡片的图标底块在浅色主题下使用 50% 不透明度的白色,深色主题下为 5%。图标底块与单文件改动卡片的外框使用与交付卡片相同的描边。点击卡片会在右侧 Sidebar 预览文件。控件打开默认应用,并列出关联应用,文件定位始终放在最后。匹配的行内代码引用也打开 Sidebar 预览。交付卡片只在本轮结束后出现在 turn tail 中,因此等待阻塞提问的轮次暂时没有卡片;`present` 工具描述会引导模型在需要提前审阅时于消息中链接文件。卡片包含本轮的所有声明,包括收尾回复之后记录的声明;重复声明使用最近一次的说明。 -`present` 工具行在执行、成功、失败和中断状态下保留文档图标及记录的结果。原生操作共享等待状态,并在卡片显示进度或可重试错误。打开或定位成功的反馈显示五秒后,在 200ms 内淡出并恢复文件说明;失败信息保留到下次尝试。卡片挂载时读取桌面信息,连接更换时清除缓存。Host 没有桌面时,卡片保留 Sidebar 预览并隐藏原生控件;元数据读取失败时提供重试。交付卡片和变更对比页通过 `deliverables.file.actions` 和 `deliverables.review.file.actions` 使用 [ui-open-in-app](../ui-open-in-app/README.zh.md) 提供的控件。关联应用 GET 查询与原生 POST 操作都会先校验记录中的文件坐标及会话文件系统映射,再调用 Host 桌面。 +`present` 工具行在执行、成功、失败和中断状态下保留文档图标及记录的结果。原生操作共享等待状态,并在卡片显示进度或可重试错误。打开或定位获得确认后立即恢复文件说明;失败信息保留到下次尝试。每次操作会得出一个带判别标签的结果——已确认的打开、已确认的定位、具名失败,或因同一文件正在打开或插件已释放而丢弃的请求——由贡献的控件按照 [ui-open-in-app](../ui-open-in-app/README.zh.md) 的移动端成功与各端失败反馈规则播报。释放插件会取消在途请求,并停止播报这些请求的结果。卡片挂载时读取桌面信息,连接更换时清除缓存。Host 没有桌面时,卡片保留 Sidebar 预览并隐藏原生控件;元数据读取失败时提供重试。交付卡片和变更对比页通过 `deliverables.file.actions` 和 `deliverables.review.file.actions` 使用 [ui-open-in-app](../ui-open-in-app/README.zh.md) 提供的控件。关联应用 GET 查询与原生 POST 操作都会先校验记录中的文件坐标及会话文件系统映射,再调用 Host 桌面。 ### 改动文件卡片 diff --git a/packages/client/ui-deliverables/src/client/Deliverables.module.css b/packages/client/ui-deliverables/src/client/Deliverables.module.css index b0ce22c8fb..e5c439fe27 100644 --- a/packages/client/ui-deliverables/src/client/Deliverables.module.css +++ b/packages/client/ui-deliverables/src/client/Deliverables.module.css @@ -33,9 +33,3 @@ .toggle svg { flex: none; width: 14px; height: 14px; } @container (max-width: 620px) { .presented { grid-template-columns: minmax(0, 1fr); } } .actions { display: inline-flex; flex: none; pointer-events: auto; } - -.secondaryText[data-success] { animation-name: success-fade; animation-timing-function: ease-out; animation-fill-mode: forwards; } -@keyframes success-fade { to { opacity: 0; } } -@media (prefers-reduced-motion: reduce) { - .secondaryText[data-success] { animation-name: none; } -} diff --git a/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx b/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx index f0dd26035b..b40bfa40a0 100644 --- a/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx +++ b/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx @@ -4,7 +4,7 @@ import { resolveWorkspacePath } from '@deepseek-ai/dsh-util-workspace-path' import { FileTypeIcon, fileExtension } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { PresentedHost } from '../presented.ts' -import { PRESENTED_SUCCESS_HOLD_MS, PRESENTED_SUCCESS_FADE_MS, type PresentedOpenPhase } from './present-open.ts' +import type { PresentedOpenPhase } from './present-open.ts' import { basename, type PresentedPath } from './turn-deliverables.ts' import type { NS } from './locales.ts' import css from './Deliverables.module.css' @@ -27,15 +27,13 @@ export function PresentedFileCard({ file, cwd, phase, host, onPreview, actions, onPreview: () => void actions: ReactNode } & PropsLocale) { - const succeeded = phase === 'opened' || phase === 'revealed' const reveal = host?.fileManager ?? 'directory' const name = basename(file.path) const metadata = fileExtension(name).toUpperCase() || t('presented.file') const status = phase === undefined ? cardDescription(file.description, metadata) - : t(reveal === 'directory' && phase === 'revealed' ? 'presented.directoryOpened' - : reveal === 'directory' && phase === 'revealing' ? 'presented.directoryOpening' - : reveal === 'directory' && phase === 'revealError' ? 'presented.directoryError' : `presented.${phase}`) + : t(reveal === 'directory' && phase === 'revealing' ? 'presented.directoryOpening' + : reveal === 'directory' && phase === 'revealError' ? 'presented.directoryError' : `presented.${phase}`) return
, t: makeTranslate(en), }) -it('localizes reveal failures and accurately reports a directory-only action', () => { +it('localizes reveal failures and restores the description when progress clears', () => { const p = props() const view = render() expect(view.getByText(zh['presented.revealError'])).toBeTruthy() - view.rerender() - expect(view.getByText(en['presented.directoryOpened'])).toBeTruthy() - view.rerender() - expect(view.getByText(en['presented.revealed'])).toBeTruthy() + view.rerender() + expect(view.getByText(p.file.description)).toBeTruthy() + expect(view.queryByRole('status')).toBeNull() }) diff --git a/packages/client/ui-deliverables/tests/review-tab.client.spec.tsx b/packages/client/ui-deliverables/tests/review-tab.client.spec.tsx index 90209385fd..3036b0e4b6 100644 --- a/packages/client/ui-deliverables/tests/review-tab.client.spec.tsx +++ b/packages/client/ui-deliverables/tests/review-tab.client.spec.tsx @@ -160,7 +160,7 @@ describe('ReviewTab', () => { loadChangesSummary: vi.fn(() => Promise.resolve()), loadChangesDiff: vi.fn(() => Promise.resolve()), reloadPresentedHost: vi.fn(() => Promise.resolve()), - openChanged: vi.fn(() => Promise.resolve(null)), + openChanged: vi.fn(() => Promise.resolve({ kind: 'opened' })), } const sessions: SessionListState = { ids: [SESSION], diff --git a/packages/client/ui-open-in-app/README.i18n.yaml b/packages/client/ui-open-in-app/README.i18n.yaml index 64d58358e3..ea5f40f785 100644 --- a/packages/client/ui-open-in-app/README.i18n.yaml +++ b/packages/client/ui-open-in-app/README.i18n.yaml @@ -18,11 +18,11 @@ en: 056e2bd9e3c78dd8 zh: be35d2ba53be0442 /deepseek-ai-dsh-client-ui-open-in-app/use-this-package/what-to-expect: - en: 42f7977b585f9c3a - zh: a13772312d52b97f + en: 940e8891c458dee1 + zh: 8fd2fcee25065b23 /deepseek-ai-dsh-client-ui-open-in-app/understand-the-implementation: - en: a0304e1bd9b44370 - zh: af0fdfd01c296dce + en: ec7a7c56aaeafce6 + zh: 4cee0cd2cb7531df /deepseek-ai-dsh-client-ui-open-in-app/further-exploration: en: 562c58d589fb7afa zh: 9ba88b381160cc96 diff --git a/packages/client/ui-open-in-app/README.md b/packages/client/ui-open-in-app/README.md index 05a1825a98..0ba07f3edf 100644 --- a/packages/client/ui-open-in-app/README.md +++ b/packages/client/ui-open-in-app/README.md @@ -31,7 +31,9 @@ Mount this plugin in the Web composition beside [`dsh-host-open-in-app`](../../h The Session header, file-tree header, and document header use the same 24px-high split button with 9px corners. These headers show only the icon; their tooltip names the default application or the reveal action. They show the default action’s icon, mark its application with “(default)” in the menu, disable while their own gesture runs, and report failures through a transient toast. The file-tree opener targets the displayed directory supplied as `absolutePath` and shares the application choice and launch state with the Session header. The directory adapter uses the existing cross-platform application catalog and remembers the last successful choice in `dsh.open-in-app.choice`; a missing choice falls back to the first available application. -The **Open locally** shortcut captures the main Session's directory and the same remembered application as the header button. The effective binding appears in the button tooltip and `aria-keyshortcuts`; Web follows the [shortcut service’s platform defaults](../shortcuts/README.md). The command runs only while the Conversation is selected and the header button has a directory and installed application to open. An in-flight launch also blocks the command. Pointer and keyboard gestures share the controller's launch state, so repeated gestures cannot launch twice or change the remembered choice mid-launch. +On iOS and Android phones and tablets, including installed PWAs, an acknowledged native open shows “Opened on another device”; reveal shows “File location shown on another device”. Desktop clients stay silent on success, including narrow browser windows and installed desktop PWAs. Every client receives a failure toast when the Host refuses or cannot be reached. Success means the Host accepted the native operation, not that the destination application finished loading the file. The feedback uses the existing mobile-device detector rather than viewport width or installation mode. + +The **Open locally** shortcut captures the main Session's directory and the same remembered application as the header button. The effective binding appears in the button tooltip and `aria-keyshortcuts`; Web follows the [shortcut service’s platform defaults](../shortcuts/README.md). The command runs only while the Conversation is selected and the header button has a directory and installed application to open. An in-flight launch also blocks the command; its outcome follows the same feedback policy as pointer gestures. Pointer and keyboard gestures share the controller's launch state, so repeated gestures cannot launch twice or change the remembered choice mid-launch. File menus list the discovered associated applications without a separate “Open in default app” row. “Show file location” stays in a fixed footer separated from the scrolling applications. A successful query with only one available action renders a single button without a dropdown. Initial file-association loading uses a gray skeleton icon. When the Host identifies a default application the menu marks it “(default)” and the main button opens it; otherwise the first associated application takes that place, so the main button reveals the file only when no application is registered. Directory menus have no reveal row. Choosing a file application does not change the system default. An unpreviewable file uses the same menu in a 40px-high split button with an icon and action text; its label is “Open” or “Show file location” according to the default action. @@ -45,9 +47,9 @@ Mounted controls with the same association reader and file share one query and i
Implementation internals — click to expand -Both directory slots use `OpenInAppAction` with an explicit `absolutePath`. The `conversation.session.header.utilities` registration reads the Session cwd, while `sidebar.right.tab.files.actions` receives the displayed directory from its owner. The plugin registers the `open-in-app` dictionaries as one effect. A page-lifetime controller ([`src/client/controller.ts`](src/client/controller.ts)) owns the once-per-page availability read, the persisted choice snapshot store, and the launch POST; the component receives the shared sources through the inject `hooks` compartment, so every Session header shares one truth. Document-relative route forms and wire payload types come from the host package's browser-safe `@deepseek-ai/dsh-host-open-in-app/shared` subpath. The controller guards in-flight launches and publishes their captured directory and status; header controls derive delayed busy and transient error visuals from that source. +Both directory slots use `OpenInAppAction` with an explicit `absolutePath`. The `conversation.session.header.utilities` registration reads the Session cwd, while `sidebar.right.tab.files.actions` receives the displayed directory from its owner. The plugin registers the `open-in-app` dictionaries as one effect. A page-lifetime controller ([`src/client/controller.ts`](src/client/controller.ts)) owns the once-per-page availability read, the persisted choice snapshot store, and the launch POST; the component receives the shared sources through the inject `hooks` compartment, so every Session header shares one truth. Document-relative route forms and wire payload types come from the host package's browser-safe `@deepseek-ai/dsh-host-open-in-app/shared` subpath. The controller guards in-flight launches and publishes the captured directory and busy state. Adapters resolve native and transport failures to named outcomes; ignored or cancelled requests return a skipped outcome and announce nothing. -The directory and file adapters supply application metadata and operations to [`OpenTargetButton`](src/client/OpenTargetButton.tsx), which owns menu ordering, default markers, icons, sizing, and gesture feedback. The file header and empty state share `FileOpenTarget`, while `OpenPathInjected.applications` queries `session.workspacePathApplications` through [`open-path.ts`](src/client/open-path.ts). Opening uses `session.openWorkspacePath`; the Host revalidates an explicitly selected handler before launch. The directory adapter keeps the existing catalog routes. `FileRouteAction` supplies the same control to delivery cards and change review through `deliverables.file.actions` and `deliverables.review.file.actions`; their authenticated routes retain Session file authorization. A failed or unavailable file query therefore needs no platform-specific UI implementation. +The directory and file adapters supply application metadata and operations to [`OpenTargetButton`](src/client/OpenTargetButton.tsx), which owns menu ordering, default markers, icons, and sizing. Its injected reporter sends outcomes to one feature-owned `shell.overlay` toast, so switching Sessions or closing the initiating preview cannot discard the result. The file header and empty state share `FileOpenTarget`, while `OpenPathInjected.applications` queries `session.workspacePathApplications` through [`open-path.ts`](src/client/open-path.ts). Opening uses `session.openWorkspacePath`; the Host revalidates an explicitly selected handler before launch. The directory adapter keeps the existing catalog routes. `FileRouteAction` supplies the same control to delivery cards and change review through `deliverables.file.actions` and `deliverables.review.file.actions`; their authenticated routes retain Session file authorization. A failed or unavailable file query therefore needs no platform-specific UI implementation.
diff --git a/packages/client/ui-open-in-app/README.zh.md b/packages/client/ui-open-in-app/README.zh.md index b71fff7fd8..ac2371c621 100644 --- a/packages/client/ui-open-in-app/README.zh.md +++ b/packages/client/ui-open-in-app/README.zh.md @@ -31,7 +31,9 @@ kind: "package-reference" 会话标题栏、文件树标题栏和文档标题栏共用同一个高 24px、圆角 9px 的分体按钮。这些标题栏都只显示图标,悬停提示显示默认应用名称或文件定位动作。它们都显示默认动作的图标,在菜单的默认应用后标注“(默认)”,仅在自己的操作执行期间禁用,并通过短暂提示报告失败。文件树打开按钮以 `absolutePath` 传入的显示目录为目标,并与会话头部共享应用选择和启动状态。目录适配器使用已有的跨平台应用列表,将最后一次成功选择保存在 `dsh.open-in-app.choice` 中;原选择不可用时回退到第一个可用应用。 -**在本地打开**快捷键捕获主会话的目录,并使用与头部按钮相同的已记住应用。按钮 tooltip 和 `aria-keyshortcuts` 显示当前有效绑定;Web 遵循[快捷键服务的平台默认值](../shortcuts/README.zh.md)。只有选中会话界面、且头部按钮有可打开的目录和已安装应用时,命令才执行。启动尚未结束时也会阻止命令。鼠标与键盘操作共享 controller 的启动状态,重复操作不会启动两次,也不会在启动期间更改已记住的选择。 +在 iOS、Android 手机和平板上(包括已安装的 PWA),原生打开获得确认后显示「已在其它设备打开」,文件定位则显示「已在其它设备显示文件位置」。电脑端成功时不提示,包括窄窗口浏览器与已安装的桌面 PWA。Host 拒绝请求或无法连通时,所有客户端都会收到失败 toast。成功表示 Host 已接受原生操作,不代表目标应用已完成文件加载。反馈复用现有移动设备判定,不依据窗口宽度或安装模式。 + +**在本地打开**快捷键捕获主会话的目录,并使用与头部按钮相同的已记住应用。按钮 tooltip 和 `aria-keyshortcuts` 显示当前有效绑定;Web 遵循[快捷键服务的平台默认值](../shortcuts/README.zh.md)。只有选中会话界面、且头部按钮有可打开的目录和已安装应用时,命令才执行。启动尚未结束时也会阻止命令;其结果遵循与点击操作相同的反馈规则。鼠标与键盘操作共享 controller 的启动状态,重复操作不会启动两次,也不会在启动期间更改已记住的选择。 文件菜单列出已发现的关联应用,不单列“用默认应用打开”。“显示文件位置”固定在菜单底部,通过分隔线与滚动的应用列表分开。查询成功且只有一个可用操作时显示单按钮,不再显示下拉箭头。文件关联首次加载时使用灰色骨架图标。Host 识别出默认应用时,菜单将该项标注为“(默认)”,主按钮打开该应用;否则第一个关联应用占据该位置,只有在一个关联应用都没有时主按钮才执行文件定位。目录菜单不包含定位项。选择文件应用不修改系统默认应用。无法预览文件时,空态使用同一菜单,按钮增大到 40px 高并显示图标和动作文字;根据默认动作显示“打开”或“显示文件位置”。 @@ -45,9 +47,9 @@ kind: "package-reference"
实现内幕——点击展开 -两个目录插槽均通过显式的 `absolutePath` 使用 `OpenInAppAction`。`conversation.session.header.utilities` 的注册函数读取会话 cwd,`sidebar.right.tab.files.actions` 则接收所属组件传入的显示目录。插件以一个 effect 注册 `open-in-app` 词典。一个页面生命周期的 controller([`src/client/controller.ts`](src/client/controller.ts))拥有每页一次的可用性读取、持久化选择的 snapshot store 与启动 POST;组件经 inject 的 `hooks` 隔间接收共享源,因此所有会话头部共享同一份事实。文档相对的路由形式与 wire 载荷类型来自主机包的浏览器安全子路径 `@deepseek-ai/dsh-host-open-in-app/shared`。controller 守卫执行中的启动,并发布所捕获的目录与状态;头部控件从该源派生延迟出现的等待态和短暂错误态。 +两个目录插槽均通过显式的 `absolutePath` 使用 `OpenInAppAction`。`conversation.session.header.utilities` 的注册函数读取会话 cwd,`sidebar.right.tab.files.actions` 则接收所属组件传入的显示目录。插件以一个 effect 注册 `open-in-app` 词典。一个页面生命周期的 controller([`src/client/controller.ts`](src/client/controller.ts))拥有每页一次的可用性读取、持久化选择的 snapshot store 与启动 POST;组件经 inject 的 `hooks` 隔间接收共享源,因此所有会话头部共享同一份事实。文档相对的路由形式与 wire 载荷类型来自主机包的浏览器安全子路径 `@deepseek-ai/dsh-host-open-in-app/shared`。controller 守卫执行中的启动,并发布所捕获的目录与等待状态。适配器将原生操作和传输失败解析为具名结果;被忽略或取消的请求返回跳过结果,不作播报。 -目录和文件适配器把应用信息与操作交给 [`OpenTargetButton`](src/client/OpenTargetButton.tsx),由它统一管理菜单顺序、默认标记、图标、尺寸和操作反馈。文件标题栏和空态共用 `FileOpenTarget`,`OpenPathInjected.applications` 通过 [`open-path.ts`](src/client/open-path.ts) 查询 `session.workspacePathApplications`。打开操作使用 `session.openWorkspacePath`,Host 在启动前重新验证指定的关联应用。`FileRouteAction` 通过 `deliverables.file.actions` 和 `deliverables.review.file.actions` 为交付卡片和变更对比页提供同一控件,其认证路由保留会话文件校验。目录适配器继续使用已有的应用列表路由,文件查询失败或不可用时无需增加平台专用的界面实现。 +目录和文件适配器把应用信息与操作交给 [`OpenTargetButton`](src/client/OpenTargetButton.tsx),由它统一管理菜单顺序、默认标记、图标和尺寸。注入的回调将结果交给本功能在 `shell.overlay` 中持有的唯一 toast,因此切换会话或关闭发起操作的预览不会丢失结果。文件标题栏和空态共用 `FileOpenTarget`,`OpenPathInjected.applications` 通过 [`open-path.ts`](src/client/open-path.ts) 查询 `session.workspacePathApplications`。打开操作使用 `session.openWorkspacePath`,Host 在启动前重新验证指定的关联应用。`FileRouteAction` 通过 `deliverables.file.actions` 和 `deliverables.review.file.actions` 为交付卡片和变更对比页提供同一控件,其认证路由保留会话文件校验。目录适配器继续使用已有的应用列表路由,文件查询失败或不可用时无需增加平台专用的界面实现。
diff --git a/packages/client/ui-open-in-app/src/client/FileRouteAction.tsx b/packages/client/ui-open-in-app/src/client/FileRouteAction.tsx index 49c27245f1..35a507422b 100644 --- a/packages/client/ui-open-in-app/src/client/FileRouteAction.tsx +++ b/packages/client/ui-open-in-app/src/client/FileRouteAction.tsx @@ -1,10 +1,10 @@ /** Adapt authorized delivery and changed-file routes to the shared opening control. */ -import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type {} from '@deepseek-ai/dsh-client-ui-deliverables/client' import { parseNativeFileApplications } from '@deepseek-ai/dsh-native-command/types' import type { SessionWorkspacePathApplication } from '@deepseek-ai/dsh-api-session-controller/types' import { useFileApplications } from './file-applications.ts' -import { OpenTargetButton } from './OpenTargetButton.tsx' +import { OpenTargetButton, type OpenTargetReporter } from './OpenTargetButton.tsx' import type { NS } from './locales.ts' async function queryRoute(url: string, signal: AbortSignal): Promise { @@ -20,18 +20,18 @@ async function queryRoute(url: string, signal: AbortSignal): Promise, 'actionUrl' | 'available' | 'pending' | 'onAction'> & PropsLocale, + props: Pick, 'actionUrl' | 'available' | 'pending' | 'onAction'> + & PropsLocale & InjectFace, ) { const association = useFileApplications(props.actionUrl, queryRoute, props.available) if (!props.available) return null return app.default)?.id} failed={association.failed} - loading={association.loading} busy={props.pending} refresh={association.refresh} t={props.t} - execute={async (operation) => { - return props.onAction(operation.kind === 'reveal' ? 'reveal' : 'open', operation.kind === 'application' ? operation.id : undefined) - }} /> + loading={association.loading} busy={props.pending} refresh={association.refresh} t={props.t} report={props.report} + execute={operation => props.onAction(operation.kind === 'reveal' ? 'reveal' : 'open', operation.kind === 'application' ? operation.id : undefined)} /> } diff --git a/packages/client/ui-open-in-app/src/client/OpenInAppAction.tsx b/packages/client/ui-open-in-app/src/client/OpenInAppAction.tsx index 9610a9c11c..ceb84fc611 100644 --- a/packages/client/ui-open-in-app/src/client/OpenInAppAction.tsx +++ b/packages/client/ui-open-in-app/src/client/OpenInAppAction.tsx @@ -2,19 +2,20 @@ import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import type { InjectFace, PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import { APP_LABEL_KEY } from './applications.ts' import type { OpenInAppLaunchState } from './controller.ts' +import type { OpenInAppOutcome } from './outcome.ts' import type { ShortcutCatalogEntry } from '@deepseek-ai/dsh-client-shortcuts/client' import { NS } from './locales.ts' -import { OpenTargetButton } from './OpenTargetButton.tsx' +import { OpenTargetButton, type OpenTargetReporter } from './OpenTargetButton.tsx' /** Browser operations and state shared by the Session header and file tree contributions. */ -export interface OpenInAppActionInjected { +export interface OpenInAppActionInjected extends OpenTargetReporter { hooks: { openInAppApps: ObservableSnapshot openInAppChoice: ObservableSnapshot openInAppLaunch: ObservableSnapshot shortcuts: ObservableSnapshot } - launch: (appId: string, path: string) => Promise + launch: (appId: string, path: string) => Promise choose: (appId: string) => void iconUrl: (appId: string) => string } @@ -27,7 +28,7 @@ export type OpenInAppActionProps = /** * Adapt the installed directory catalog to the shared opening control. - * @param props - displayed directory, installed catalog, and launch operations. + * @param props - displayed directory, installed catalog, launch operations, and the announcement seat. * @returns the shared control, or null without an eligible application. */ export function OpenInAppAction(props: OpenInAppActionProps): React.JSX.Element | null { @@ -45,17 +46,12 @@ export function OpenInAppAction(props: OpenInAppActionProps): React.JSX.Element return ( { const id = operation.kind === 'application' ? operation.id : preferred.id - try { - await props.launch(id, absolutePath) - } catch (_error) { - // Native launch failure is announced by the shared control. - return 'openError' - } - if (operation.kind === 'application') props.choose(id) - return null + const outcome = await props.launch(id, absolutePath) + if (outcome.kind === 'opened' && operation.kind === 'application') props.choose(id) + return outcome }} /> ) diff --git a/packages/client/ui-open-in-app/src/client/OpenPathAction.tsx b/packages/client/ui-open-in-app/src/client/OpenPathAction.tsx index 2b6008d9b1..4111f70d84 100644 --- a/packages/client/ui-open-in-app/src/client/OpenPathAction.tsx +++ b/packages/client/ui-open-in-app/src/client/OpenPathAction.tsx @@ -5,18 +5,19 @@ import type { SessionWorkspacePathApplication } from '@deepseek-ai/dsh-api-sessi import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type {} from '@deepseek-ai/dsh-client-ui-sidebar-documentpreview/client' -import type { OpenInAppPathAction, OpenInAppPathFailure } from './open-path.ts' +import type { OpenInAppPathAction } from './open-path.ts' +import type { OpenInAppOutcome } from './outcome.ts' import { useFileApplications } from './file-applications.ts' -import { OpenTargetButton } from './OpenTargetButton.tsx' +import { OpenTargetButton, type OpenTargetReporter } from './OpenTargetButton.tsx' import type { NS } from './locales.ts' /** Desktop availability and the gesture carrier injected into both path controls. */ -export interface OpenPathInjected { +export interface OpenPathInjected extends OpenTargetReporter { hooks: { openInAppDesktop: ObservableSnapshot } loadDesktop: () => Promise - openPath: (path: string, action: OpenInAppPathAction, application?: string) => Promise + openPath: (path: string, action: OpenInAppPathAction, application?: string) => Promise applications: (path: string, signal: AbortSignal) => Promise } @@ -27,7 +28,7 @@ export type OpenPathActionProps = & InjectFace /** File inputs shared by the document header and its unpreviewable state. */ -type FileOpenTargetProps = Pick & { +type FileOpenTargetProps = Pick & { empty?: boolean } @@ -49,7 +50,7 @@ export function FileOpenTarget(props: FileOpenTargetProps): ReactNode { key={props.absolutePath} kind="file" applications={apps} defaultId={apps.find(app => app.default)?.id} loading={association.loading} failed={association.failed} prominent={props.empty === true} t={props.t} - refresh={association.refresh} + refresh={association.refresh} report={props.report} execute={operation => props.openPath(props.absolutePath, operation.kind === 'reveal' ? 'reveal' : 'open', operation.kind === 'application' ? operation.id : undefined)} /> diff --git a/packages/client/ui-open-in-app/src/client/OpenTargetButton.tsx b/packages/client/ui-open-in-app/src/client/OpenTargetButton.tsx index 93d4c67625..2e8f882766 100644 --- a/packages/client/ui-open-in-app/src/client/OpenTargetButton.tsx +++ b/packages/client/ui-open-in-app/src/client/OpenTargetButton.tsx @@ -1,4 +1,4 @@ -/** Shared file and directory opener: one default action, application menu, and per-gesture feedback. */ +/** Shared file and directory opener: one default action, application menu, and one reported outcome per gesture. */ import { useRef, useState } from 'react' import type { ReactNode } from 'react' import { @@ -6,8 +6,7 @@ import { } from '@deepseek-ai/dsh-client-ui-primitives' import type { ShortcutCatalogEntry } from '@deepseek-ai/dsh-client-shortcuts/client' import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' -import type { OpenInAppPathFailure } from './open-path.ts' -import { useOpenFailureToast } from './open-failure-toast.tsx' +import type { OpenInAppOutcome } from './outcome.ts' import type { NS } from './locales.ts' import css from './OpenTargetButton.module.css' @@ -21,8 +20,14 @@ export interface OpenTargetApplication { /** Opening intent; default selection remains owned by the target's adapter. */ export type OpenTargetOperation = { readonly kind: 'default' } | { readonly kind: 'application'; readonly id: string } | { readonly kind: 'reveal' } +/** Seat a contribution sends one settled gesture to for app-wide announcement. */ +export interface OpenTargetReporter { + /** Report the settled outcome; `{ kind: 'skipped' }` announces nothing. */ + readonly report: (outcome: OpenInAppOutcome) => void +} + /** Inputs shared by both target adapters and the file empty-state action. */ -export interface OpenTargetButtonProps { +export interface OpenTargetButtonProps extends OpenTargetReporter { readonly shortcut?: Pick | undefined readonly kind: 'file' | 'directory' readonly applications: readonly OpenTargetApplication[] @@ -32,34 +37,33 @@ export interface OpenTargetButtonProps { readonly loading?: boolean readonly prominent?: boolean readonly t: TranslateNS - readonly execute: (operation: OpenTargetOperation) => Promise + /** Resolve every gesture to an outcome, including Host and transport failures; never reject. */ + readonly execute: (operation: OpenTargetOperation) => Promise readonly refresh?: () => void } /** - * Serialize gestures and announce their failures through the initiating control's toast. - * @param execute - target adapter that returns the failure to announce, or null. - * @param t - localized control copy. - * @returns the pending state, feedback, and guarded action callback. + * Serialize gestures and report the outcome each one settles on to the app-wide + * announcer, which outlives this control and the panel holding it. + * @param execute - target adapter that settles on the gesture's outcome. + * @param report - announcement seat the settled outcome is sent to. + * @returns the pending state and the guarded action callback. */ -export function useOpenTargetGesture(execute: OpenTargetButtonProps['execute'], t: TranslateNS): { +export function useOpenTargetGesture( + execute: OpenTargetButtonProps['execute'], report: OpenTargetButtonProps['report'], +): { pending: boolean - toast: ReactNode act: (operation: OpenTargetOperation) => void } { const [pending, setPending] = useState(false) const inFlight = useRef(false) - const { toast, show } = useOpenFailureToast() return { pending, - toast, act: (operation) => { if (inFlight.current) return inFlight.current = true setPending(true) - void execute(operation).then((failure) => { - if (failure !== null) show(t(`path.${failure}`)) - }).finally(() => { inFlight.current = false; setPending(false) }) + void execute(operation).then(report).finally(() => { inFlight.current = false; setPending(false) }) }, } } @@ -76,12 +80,12 @@ function ApplicationIcon({ source, size = 14 }: { source: string | null; size?: * Render identical split buttons for files and directories. File reveal always * stays last; it is the default only when no application is registered. * @param props - target applications, default selection, and operations. - * @returns the control and its transient failure feedback. + * @returns the control alone: its settled outcomes are announced by the seat it reports to. */ export function OpenTargetButton(props: OpenTargetButtonProps): ReactNode { const { applications, defaultId, kind, t } = props const [menuOpen, setMenuOpen] = useState(false) - const { pending, toast, act } = useOpenTargetGesture(props.execute, t) + const { pending, act } = useOpenTargetGesture(props.execute, props.report) // Files open through an application whenever one is registered: the OS-marked // default when the Host reported one, else the first entry in the Shell's // preference order. Reveal is only promoted with no registered application, so @@ -107,54 +111,51 @@ export function OpenTargetButton(props: OpenTargetButtonProps): ReactNode { ? : return ( - <> - { setMenuOpen(false) }} - items={[ - ...applications.map(app => ({ - id: `app:${app.id}`, - icon: , - label: app.id === preferred?.id ? t('path.appDefault', { app: app.name }) : app.name, - })), - ...(props.failed ? [{ id: 'unavailable', label: t('path.appsError'), disabled: true }] : []), - ]} - footer={kind === 'file' ? [{ - id: 'reveal', icon: , - label: revealDefault ? t('path.appDefault', { app: t('path.reveal') }) : t('path.reveal'), - }] : []} - onSelect={(id) => { run(id === 'reveal' ? { kind: 'reveal' } : { kind: 'application', id: id.slice(4) }) }} - anchor={( -
- - - - {hasMenu && } -
- )} - /> - {toast} - + { setMenuOpen(false) }} + items={[ + ...applications.map(app => ({ + id: `app:${app.id}`, + icon: , + label: app.id === preferred?.id ? t('path.appDefault', { app: app.name }) : app.name, + })), + ...(props.failed ? [{ id: 'unavailable', label: t('path.appsError'), disabled: true }] : []), + ]} + footer={kind === 'file' ? [{ + id: 'reveal', icon: , + label: revealDefault ? t('path.appDefault', { app: t('path.reveal') }) : t('path.reveal'), + }] : []} + onSelect={(id) => { run(id === 'reveal' ? { kind: 'reveal' } : { kind: 'application', id: id.slice(4) }) }} + anchor={( +
+ + + + {hasMenu && } +
+ )} + /> ) } diff --git a/packages/client/ui-open-in-app/src/client/controller.ts b/packages/client/ui-open-in-app/src/client/controller.ts index d93148bab1..c2e25085e8 100644 --- a/packages/client/ui-open-in-app/src/client/controller.ts +++ b/packages/client/ui-open-in-app/src/client/controller.ts @@ -7,6 +7,7 @@ import { } from '@deepseek-ai/dsh-host-open-in-app/shared' import { APP_LABEL_KEY } from './applications.ts' +import type { OpenInAppOutcome } from './outcome.ts' /** Shared launch status for controls targeting the captured workspace path. */ export interface OpenInAppLaunchState { @@ -72,10 +73,12 @@ export class OpenInAppController { * @param appId - catalog id from the availability list. * @param path - the session's absolute workspace directory. * Concurrent gestures are ignored until the current Host request settles. - * @returns after the host acknowledged the launch; rejects on any failure. + * @returns the acknowledged handoff, or `{ kind: 'skipped' }` while another + * launch holds the operation, or a named failure when the Host refuses or + * cannot be reached. Host and transport failures resolve without rejecting. */ - async launch(appId: string, path: string): Promise { - if (this.operation.getSnapshot().phase === 'busy') return + async launch(appId: string, path: string): Promise { + if (this.operation.getSnapshot().phase === 'busy') return { kind: 'skipped' } this.operation.set({ phase: 'busy', path }) const body: OpenInAppOpenPayload = { app: appId, path } try { @@ -86,9 +89,11 @@ export class OpenInAppController { }) if (!response.ok) throw new Error(`open failed: HTTP ${String(response.status)}`) this.operation.set({ phase: 'idle', path }) - } catch (error) { + return { kind: 'opened' } + } catch (_error) { + // Refusal and transport failure share the same user-visible outcome. this.operation.set({ phase: 'error', path }) - throw error + return { kind: 'failed', failure: 'openError' } } } diff --git a/packages/client/ui-open-in-app/src/client/index.ts b/packages/client/ui-open-in-app/src/client/index.ts index 8da9f15f9e..a661daedca 100644 --- a/packages/client/ui-open-in-app/src/client/index.ts +++ b/packages/client/ui-open-in-app/src/client/index.ts @@ -25,7 +25,9 @@ import { OpenInAppPathController } from './open-path.ts' import { OpenPathAction, type OpenPathInjected } from './OpenPathAction.tsx' import { FileRouteAction } from './FileRouteAction.tsx' import { OpenPathEmptyAction } from './OpenPathEmptyAction.tsx' +import type { OpenTargetReporter } from './OpenTargetButton.tsx' import { en, ja, es, fr, de, ru, pt, NS, zh, type OpenInAppKey } from './locales.ts' +import { createOpenInAppToastSource, OpenInAppToast } from './open-toast.tsx' declare module '@deepseek-ai/dsh-client-ui-slots' { interface LocaleNamespaceMap { @@ -35,7 +37,8 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { } export type { OpenInAppActionInjected, OpenInAppActionProps } from './OpenInAppAction.tsx' -export type { OpenInAppPathAction, OpenInAppPathFailure, OpenInAppPathRemote } from './open-path.ts' +export type { OpenInAppPathAction, OpenInAppPathRemote } from './open-path.ts' +export type { OpenInAppOutcome, OpenInAppPathFailure } from './outcome.ts' export type { OpenPathActionProps, OpenPathInjected } from './OpenPathAction.tsx' export type { OpenPathEmptyActionProps } from './OpenPathEmptyAction.tsx' @@ -53,6 +56,12 @@ export function apply(ctx: ClientContext): void { const paths = new OpenInAppPathController(ctx.remote) ctx.effect(() => ctx.locale.register(NS, { zh, en, ja, es, fr, de, ru, pt }), 'open-in-app: dictionaries') const t = ctx.locale.bind(NS) + // Keep operation feedback visible after its initiating panel closes. + const announcement = createOpenInAppToastSource() + ctx.slots.inject('shell.overlay', () => ctx.slots.register({ + name: 'shell.overlay', id: 'open-in-app.toast', locale: NS, + inject: () => ({ hooks: announcement.hooks, dismiss: announcement.dismiss }), + }, OpenInAppToast)) const target = () => { if (ctx.layout.panelInfo.getSnapshot().activePanelId !== null) return undefined const session = Object.values(ctx.sessions.list.getSnapshot().byId) @@ -75,9 +84,7 @@ export function apply(ctx: ClientContext): void { const selected = target() if (selected === undefined) return { status: 'blocked', reason: t('shortcut.unavailable') } return { status: 'handled', run: () => { - void controller.launch(selected.appId, selected.path).catch((error: unknown) => { - console.warn('workspace open rejected:', error) - }) + void controller.launch(selected.appId, selected.path).then(announcement.report) } } }, }), 'open-in-app: workspace command') @@ -89,6 +96,7 @@ export function apply(ctx: ClientContext): void { shortcuts: ctx.shortcuts.catalog, }, launch: (appId, path) => controller.launch(appId, path), + report: announcement.report, choose: (appId) => { controller.choose(appId) }, iconUrl: appId => `${OPEN_IN_APP_ICON_PREFIX_ROUTE}/${appId}`, }) @@ -113,6 +121,7 @@ export function apply(ctx: ClientContext): void { loadDesktop: () => paths.load(), openPath: (path, action, application) => paths.openPath(path, action, application), applications, + report: announcement.report, }) ctx.slots.inject('sidebar.right.tab.document.actions', () => ctx.slots.register({ name: 'sidebar.right.tab.document.actions', @@ -126,10 +135,11 @@ export function apply(ctx: ClientContext): void { locale: NS, inject: pathInjected, }, OpenPathEmptyAction)) + const routeInjected = (): OpenTargetReporter => ({ report: announcement.report }) ctx.slots.inject('deliverables.file.actions', () => ctx.slots.register({ - name: 'deliverables.file.actions', id: 'open-in-app', locale: NS, + name: 'deliverables.file.actions', id: 'open-in-app', locale: NS, inject: routeInjected, }, FileRouteAction)) ctx.slots.inject('deliverables.review.file.actions', () => ctx.slots.register({ - name: 'deliverables.review.file.actions', id: 'open-in-app', locale: NS, + name: 'deliverables.review.file.actions', id: 'open-in-app', locale: NS, inject: routeInjected, }, FileRouteAction)) } diff --git a/packages/client/ui-open-in-app/src/client/locales.ts b/packages/client/ui-open-in-app/src/client/locales.ts index 737b027d00..95497e6b96 100644 --- a/packages/client/ui-open-in-app/src/client/locales.ts +++ b/packages/client/ui-open-in-app/src/client/locales.ts @@ -48,6 +48,8 @@ export const zh = { 'path.open': '打开', 'path.more': '更多打开方式', 'path.reveal': '显示文件位置', + 'path.opened': '已在其它设备打开', + 'path.revealed': '已在其它设备显示文件位置', 'path.openError': '打开失败,请重试', 'path.revealError': '无法显示文件位置,请重试', ...PRODUCT_NAMES, @@ -68,6 +70,8 @@ export const en: Record = { 'path.open': 'Open', 'path.more': 'More ways to open', 'path.reveal': 'Show file location', + 'path.opened': 'Opened on another device', + 'path.revealed': 'File location shown on another device', 'path.openError': 'Could not open. Try again.', 'path.revealError': 'Could not show the file location. Try again.', ...PRODUCT_NAMES, @@ -92,6 +96,8 @@ export const ja = { 'path.open': '開く', 'path.more': 'その他の開き方', 'path.reveal': 'ファイルの場所を表示', + 'path.opened': '別のデバイスで開きました', + 'path.revealed': '別のデバイスでファイルの場所を表示しました', 'path.openError': '開けませんでした。もう一度お試しください。', 'path.revealError': 'ファイルの場所を表示できませんでした。もう一度お試しください。', 'app.finder': 'Finder', @@ -111,6 +117,8 @@ export const es = { 'path.open': 'Abrir', 'path.more': 'Más formas de abrir', 'path.reveal': 'Mostrar ubicación del archivo', + 'path.opened': 'Abierto en otro dispositivo', + 'path.revealed': 'Ubicación del archivo mostrada en otro dispositivo', 'path.openError': 'No se pudo abrir. Intentar otra vez.', 'path.revealError': 'No se pudo mostrar la ubicación del archivo. Intentar otra vez.', ...PRODUCT_NAMES, @@ -131,6 +139,8 @@ export const fr = { 'path.open': 'Ouvrir', 'path.more': "Plus de façons d'ouvrir", 'path.reveal': "Afficher l'emplacement du fichier", + 'path.opened': 'Ouvert sur un autre appareil', + 'path.revealed': 'Emplacement du fichier affiché sur un autre appareil', 'path.openError': "Impossible d'ouvrir. Essayer à nouveau.", 'path.revealError': "Impossible d'afficher l'emplacement du fichier. Essayer à nouveau.", ...PRODUCT_NAMES, @@ -151,6 +161,8 @@ export const de = { 'path.open': 'Öffnen', 'path.more': 'Mehr Möglichkeiten zum Öffnen', 'path.reveal': 'Dateispeicherort anzeigen', + 'path.opened': 'Auf einem anderen Gerät geöffnet', + 'path.revealed': 'Dateispeicherort auf einem anderen Gerät angezeigt', 'path.openError': 'Konnte nicht geöffnet werden. Versuchen Sie es erneut.', 'path.revealError': 'Der Dateispeicherort konnte nicht angezeigt werden. Versuchen Sie es erneut.', ...PRODUCT_NAMES, @@ -171,6 +183,8 @@ export const ru = { 'path.open': 'Открыть', 'path.more': 'Больше способов открыть', 'path.reveal': 'Показать местоположение файла', + 'path.opened': 'Открыто на другом устройстве', + 'path.revealed': 'Местоположение файла показано на другом устройстве', 'path.openError': 'Не удалось открыть. Попробуйте еще раз.', 'path.revealError': 'Не удалось показать местоположение файла. Попробуйте еще раз.', ...PRODUCT_NAMES, @@ -191,6 +205,8 @@ export const pt = { 'path.open': 'Abrir', 'path.more': 'Mais maneiras de abrir', 'path.reveal': 'Mostrar localização do arquivo', + 'path.opened': 'Aberto em outro dispositivo', + 'path.revealed': 'Localização do arquivo exibida em outro dispositivo', 'path.openError': 'Não foi possível abrir. Tente novamente.', 'path.revealError': 'Não foi possível mostrar o local do arquivo. Tente novamente.', ...PRODUCT_NAMES, diff --git a/packages/client/ui-open-in-app/src/client/open-failure-toast.tsx b/packages/client/ui-open-in-app/src/client/open-failure-toast.tsx deleted file mode 100644 index e9bfa51ca4..0000000000 --- a/packages/client/ui-open-in-app/src/client/open-failure-toast.tsx +++ /dev/null @@ -1,26 +0,0 @@ -/** Per-control failure banner for path gestures. */ -import { useCallback, useRef, useState } from 'react' -import type { ReactNode } from 'react' -import { IconWarningOutlineRegular, Toast } from '@deepseek-ai/dsh-client-ui-primitives' - -/** - * Transient failure banner owned by the control that initiated the gesture, - * so one failed request announces once, from the control the user pressed. - * @returns `toast` owned by the control and `show` to announce one - * failure with resolved copy; announcing again replays the banner. - */ -export function useOpenFailureToast(): { toast: ReactNode; show: (text: string) => void } { - // The seq keys the banner so the same failure text replays instead of staying silently in place. - const seq = useRef(0) - const [banner, setBanner] = useState<{ seq: number; text: string } | null>(null) - const dismiss = useCallback(() => { setBanner(null) }, []) - return { - toast: banner === null - ? null - : } onDone={dismiss} />, - show: (text) => { - seq.current += 1 - setBanner({ seq: seq.current, text }) - }, - } -} diff --git a/packages/client/ui-open-in-app/src/client/open-path.ts b/packages/client/ui-open-in-app/src/client/open-path.ts index e403a80da9..4484bb29b9 100644 --- a/packages/client/ui-open-in-app/src/client/open-path.ts +++ b/packages/client/ui-open-in-app/src/client/open-path.ts @@ -5,13 +5,11 @@ import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { RemoteResult } from '@deepseek-ai/dsh-api-remotes/client' import type { SessionOpenWorkspacePathRequest, SessionWorkspacePathApplication } from '@deepseek-ai/dsh-api-session-controller/types' +import type { OpenInAppOutcome, OpenInAppPathFailure } from './outcome.ts' /** What a path gesture asks of the Host desktop: the default application, or the file manager showing the file. */ export type OpenInAppPathAction = 'open' | 'reveal' -/** Failure kind of one settled path gesture, for the initiating control to announce. */ -export type OpenInAppPathFailure = 'openError' | 'revealError' - /** The slice of the Client Remote the path actions call. */ export interface OpenInAppPathRemote { readonly session: { @@ -51,9 +49,9 @@ export class OpenInAppPathController { * @param path - absolute path on the Host, as the file's metadata reports it. * @param action - default application open, or file-manager reveal. * @param application - registered application path for an explicit open. - * @returns the failure kind to announce, or `null` once the Host acknowledged. + * @returns the acknowledged open or reveal, or the named failure to announce. */ - async openPath(path: string, action: OpenInAppPathAction, application?: string): Promise { + async openPath(path: string, action: OpenInAppPathAction, application?: string): Promise { const request: SessionOpenWorkspacePathRequest = action === 'reveal' ? { path, action } : { path, ...(application === undefined ? {} : { application }) } let ok = false try { @@ -62,7 +60,9 @@ export class OpenInAppPathController { // Swallows carrier rejections: a Host that cannot be reached failed the // gesture like a Host that refused it, and the control announces that. } - return ok ? null : action === 'open' ? 'openError' : 'revealError' + if (ok) return { kind: action === 'reveal' ? 'revealed' : 'opened' } + const failure: OpenInAppPathFailure = action === 'open' ? 'openError' : 'revealError' + return { kind: 'failed', failure } } /** diff --git a/packages/client/ui-open-in-app/src/client/open-toast.tsx b/packages/client/ui-open-in-app/src/client/open-toast.tsx new file mode 100644 index 0000000000..b711f6ecf8 --- /dev/null +++ b/packages/client/ui-open-in-app/src/client/open-toast.tsx @@ -0,0 +1,65 @@ +/** Native-open feedback that survives the initiating panel: mobile success and all failures. */ +import type { ReactNode } from 'react' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import { IconWarningOutlineRegular, isMobileDevice, Toast } from '@deepseek-ai/dsh-client-ui-primitives' +import type { HostObservable, InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type {} from '@deepseek-ai/dsh-client-ui-layout/client' +import { NS } from './locales.ts' +import type { OpenInAppOutcome } from './outcome.ts' + +/** One announced gesture; `seq` keys the banner so a repeat restarts it. */ +export type OpenInAppToastState = Exclude & { readonly seq: number } + +/** Store every contribution reports into and the overlay entry reads. */ +export interface OpenInAppToastSource { + /** Current notice for the overlay entry, or null while none shows. */ + readonly hooks: { readonly toast: HostObservable } + /** + * Announce failures on every device and acknowledged opens or reveals on mobile. + * Desktop successes and skipped gestures leave the current notice unchanged. + * @param outcome - settled native-open result. + */ + readonly report: (outcome: OpenInAppOutcome) => void + /** Clear the shown notice. */ + readonly dismiss: () => void +} + +/** + * Create the one outcome store the overlay entry shows. + * @returns the observable notice with its report and dismiss actions. + */ +export function createOpenInAppToastSource(): OpenInAppToastSource { + const store = createSnapshotStore(null) + let seq = 0 + return { + hooks: { toast: store }, + report: (outcome) => { + if (outcome.kind === 'skipped') return + if (outcome.kind !== 'failed' && !isMobileDevice()) return + seq += 1 + store.set({ ...outcome, seq }) + }, + dismiss: () => { store.set(null) }, + } +} + +/** Props of the `shell.overlay` entry: the notice store, its dismissal, and localized copy. */ +export type OpenInAppToastProps = PropsRuntime<'shell.overlay'> + & PropsLocale + & InjectFace> + +/** + * Render a mobile handoff confirmation or a native-open failure. + * @param props - the notice hook, its dismissal, and the locale seat. + * @returns the banner on display, or null. + */ +export function OpenInAppToast({ useToast, dismiss, t }: OpenInAppToastProps): ReactNode { + const toast = useToast(current => current) + if (toast === null) return null + const key = `open-in-app-${String(toast.seq)}` + if (toast.kind === 'failed') { + return } onDone={dismiss} /> + } + return +} diff --git a/packages/client/ui-open-in-app/src/client/outcome.ts b/packages/client/ui-open-in-app/src/client/outcome.ts new file mode 100644 index 0000000000..08dd76f795 --- /dev/null +++ b/packages/client/ui-open-in-app/src/client/outcome.ts @@ -0,0 +1,8 @@ +/** Settled native gestures shared by directory, path, and delivery controls. */ +import type { PresentedOpenFailure, PresentedOpenOutcome } from '@deepseek-ai/dsh-client-ui-deliverables/client' + +/** Failure kind of one settled gesture, keyed to the `path.*` copy it announces. */ +export type OpenInAppPathFailure = PresentedOpenFailure + +/** Acknowledged open or reveal, named failure, or deliberately skipped gesture. */ +export type OpenInAppOutcome = PresentedOpenOutcome diff --git a/packages/client/ui-open-in-app/tests/browser-plugin.client.spec.ts b/packages/client/ui-open-in-app/tests/browser-plugin.client.spec.ts index 43b988f2c9..6bd3cf0642 100644 --- a/packages/client/ui-open-in-app/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-open-in-app/tests/browser-plugin.client.spec.ts @@ -1,5 +1,9 @@ // @vitest-environment jsdom +import { createElement } from 'react' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import { isMobileDevice } from '@deepseek-ai/dsh-client-ui-primitives' /** * Browser-half lifecycle over the real SlotRegistry: the dictionary, * header-slot, and document-preview path registrations with fiber teardown @@ -11,7 +15,7 @@ import type { ShortcutCommand } from '@deepseek-ai/dsh-client-shortcuts/client' import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { Context } from '@deepseek-ai/cordis' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { LayoutController, type MainPanelId } from '@deepseek-ai/dsh-client-ui-layout/client' @@ -22,9 +26,21 @@ import { apply as nodeApply } from '../src/index.ts' import { OpenInAppAction } from '../src/client/OpenInAppAction.tsx' import { OpenPathAction } from '../src/client/OpenPathAction.tsx' import { OpenPathEmptyAction } from '../src/client/OpenPathEmptyAction.tsx' +import type { OpenTargetReporter } from '../src/client/OpenTargetButton.tsx' +import { FileRouteAction } from '../src/client/FileRouteAction.tsx' +import type { OpenInAppOutcome } from '../src/client/outcome.ts' +import { OpenInAppToast, type OpenInAppToastProps, type OpenInAppToastSource } from '../src/client/open-toast.tsx' import { en, NS, zh } from '../src/client/locales.ts' +vi.mock('@deepseek-ai/dsh-client-ui-primitives', async original => ({ + ...await original(), + isMobileDevice: vi.fn(() => false), +})) + +beforeEach(() => { vi.mocked(isMobileDevice).mockReturnValue(false) }) + afterEach(() => { + cleanup() vi.unstubAllGlobals() vi.clearAllMocks() }) @@ -51,6 +67,7 @@ async function bench() { 'sidebar.right.tab.document.unpreviewable': { kind: 'list', scope: 'session' }, 'deliverables.file.actions': { kind: 'list', scope: 'session' }, 'deliverables.review.file.actions': { kind: 'list', scope: 'session' }, + 'shell.overlay': { kind: 'list', scope: 'root' }, }, } as never, () => null) const list = createSnapshotStore({ ids: [], byId: {}, phase: 'ready', projectionsBySession: {} }) @@ -75,14 +92,24 @@ function headerEntryIds(ctx: Context): (string | undefined)[] { return ctx.slots.entries('conversation.session.header.utilities').map(entry => entry.options.id) } +/** The one announcement store the frame-wide overlay entry exposes. */ +function toastSource(ctx: Context): Omit { + const overlay = ctx.slots.entries('shell.overlay')[0] + if (overlay === undefined) throw new Error('expected the open-in-app overlay entry') + expect(overlay.component).toBe(OpenInAppToast) + expect(overlay.options).toMatchObject({ id: 'open-in-app.toast' }) + expect(overlay.locale).toBe(NS) + return (overlay.inject as () => Omit)() +} + describe('open-in-app browser half', () => { - it('refuses absent workspaces and reports failed keyboard launches', async () => { + it('refuses absent workspaces and reports failed keyboard launches to the app-wide toast', async () => { vi.stubGlobal('fetch', vi.fn(async (input: string | URL) => String(input) === 'open-in-app/apps' ? new Response(JSON.stringify({ apps: ['finder'] })) : new Response('failed', { status: 500 }))) - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const { fiber, list, commands } = await bench() + const { ctx, fiber, list, commands } = await bench() try { const command = commands.get('workspace.openLocal')! + expect(typeof command.label === 'function' ? command.label() : command.label).toBe(en['open.tooltip']) const context = { region: 'page', modal: null, target: null } as const expect(command.resolve(context).status).toBe('blocked') const id = 'main' as SessionId @@ -93,9 +120,14 @@ describe('open-in-app browser half', () => { await vi.waitFor(() => { expect(command.resolve(context).status).toBe('handled') }) const result = command.resolve(context) if (result.status !== 'handled') throw new Error('Expected an available workspace') + const toast = toastSource(ctx) + expect(toast.hooks.toast.getSnapshot()).toBeNull() result.run() - await vi.waitFor(() => { expect(warn).toHaveBeenCalledWith('workspace open rejected:', expect.any(Error)) }) - } finally { await fiber.dispose(); warn.mockRestore() } + // The keyboard path has no control of its own: its failure reaches the same overlay store. + await vi.waitFor(() => { + expect(toast.hooks.toast.getSnapshot()).toEqual({ kind: 'failed', failure: 'openError', seq: 1 }) + }) + } finally { await fiber.dispose() } }) it('captures the main directory and remembered app before dispatch and shares pointer launch occupancy', async () => { let finish!: (response: Response) => void @@ -121,7 +153,8 @@ describe('open-in-app browser half', () => { if (resolution.status !== 'handled') throw new Error('available workspace was blocked') resolution.run() expect(command.resolve(context).status).toBe('blocked') - await face.launch('finder', '/second') + // A gesture another launch already holds reports deliberate silence, not a second success. + await expect(face.launch('finder', '/second')).resolves.toEqual({ kind: 'skipped' }) const calls = fetcher.mock.calls.filter(call => call[0] === 'open-in-app/open') expect(calls).toHaveLength(1) expect(calls[0]?.[1]?.body).toBe(JSON.stringify({ app: 'cursor', path: '/first' })) @@ -131,6 +164,52 @@ describe('open-in-app browser half', () => { expect(commands.size).toBe(0) }) + it.each([ + ['mobile', true, 'open', { kind: 'opened' }, 'path.opened'], + ['mobile', true, 'reveal', { kind: 'revealed' }, 'path.revealed'], + ['mobile', true, 'open', { kind: 'failed', failure: 'openError' }, 'path.openError'], + ['mobile', true, 'reveal', { kind: 'failed', failure: 'revealError' }, 'path.revealError'], + ['desktop', false, 'open', { kind: 'failed', failure: 'openError' }, 'path.openError'], + ['desktop', false, 'reveal', { kind: 'failed', failure: 'revealError' }, 'path.revealError'], + ] as const)('keeps %s feedback after the initiating card unmounts (mobile=%s, %s, %j)', async (_device, mobile, action, outcome, message) => { + vi.mocked(isMobileDevice).mockReturnValue(mobile) + vi.stubGlobal('fetch', vi.fn(async (input: string | URL) => String(input) === 'open-in-app/apps' + ? Response.json({ apps: [] }) + : Response.json([{ id: 'editor', name: 'Editor', default: true, icon: null }]))) + const { ctx, fiber } = await bench() + const pending = Promise.withResolvers() + try { + const entry = ctx.slots.entries('deliverables.file.actions')[0]! + const report = entry.inject!().report as OpenTargetReporter['report'] + const toast = toastSource(ctx) + render(createElement(OpenInAppToast, { + useToast: bindSnapshotSelector(toast.hooks.toast), dismiss: toast.dismiss, t: makeTranslate(en), + } as OpenInAppToastProps)) + const onAction = vi.fn(() => pending.promise) + const control = render(createElement(FileRouteAction, { + report, actionUrl: 'api/present.open?sessionId=s&seq=2&index=0', + available: true, pending: false, onAction, t: makeTranslate(en), + })) + await screen.findByRole('button', { name: 'Open in Editor' }) + if (action === 'reveal') { + fireEvent.click(screen.getByRole('button', { name: en['path.more'] })) + fireEvent.click(screen.getByRole('menuitem', { name: en['path.reveal'] })) + } else fireEvent.click(screen.getByRole('button', { name: 'Open in Editor' })) + expect(onAction).toHaveBeenCalledWith(action, undefined) + control.unmount() + expect(screen.queryByRole('button')).toBeNull() + expect(screen.queryByRole('alert')).toBeNull() + expect(ctx.slots.entries('shell.overlay')).toHaveLength(1) + await act(async () => { pending.resolve(outcome) }) + expect(screen.getByRole('alert').textContent).toBe(en[message]) + expect(toast.hooks.toast.getSnapshot()).toEqual({ ...outcome, seq: 1 }) + } finally { + await act(async () => { pending.resolve({ kind: 'skipped' }) }) + cleanup() + await fiber.dispose() + } + }) + it('declares the services it binds', () => { expect(inject).toEqual(['sessions', 'slots', 'locale', 'remote', 'remote.session', 'shortcuts', 'layout']) }) @@ -164,6 +243,7 @@ describe('open-in-app browser half', () => { resolution.run() await vi.waitFor(() => { expect(face.hooks.openInAppLaunch.getSnapshot().phase).toBe('idle') }) expect(fetcher.mock.calls.filter(([input]) => input === 'open-in-app/open')).toHaveLength(1) + expect(toastSource(ctx).hooks.toast.getSnapshot()).toBeNull() } finally { await fiber.dispose() } }) @@ -187,7 +267,7 @@ describe('open-in-app browser half', () => { await expect(face.applications('/w/clip.mp4', signal)).resolves.toEqual([]) expect(remote.session.workspacePathApplications).toHaveBeenCalledWith({ path: '/w/clip.mp4' }, signal) expect(face.hooks.openInAppDesktop.getSnapshot()).toBe(true) - expect(await face.openPath('/w/clip.mp4', 'reveal')).toBeNull() + expect(await face.openPath('/w/clip.mp4', 'reveal')).toEqual({ kind: 'revealed' }) expect(remote.session.openWorkspacePath).toHaveBeenLastCalledWith({ path: '/w/clip.mp4', action: 'reveal' }) await fiber.dispose() expect(ctx.slots.entries('sidebar.right.tab.document.actions').map(entry => entry.options.id)).not.toContain('open-in-app') @@ -195,6 +275,18 @@ describe('open-in-app browser half', () => { expect(ctx.slots.entries('deliverables.file.actions')).toHaveLength(0) }) + it('injects the announcement seat into the delivery entries and releases every entry with the fiber', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ apps: [] }), { status: 200 }))) + const { ctx, fiber } = await bench() + const entry = ctx.slots.entries('deliverables.review.file.actions')[0] + const face = (entry?.inject as () => OpenTargetReporter & Record)() + face.report({ kind: 'failed', failure: 'revealError' }) + expect(toastSource(ctx).hooks.toast.getSnapshot()).toEqual({ kind: 'failed', failure: 'revealError', seq: 1 }) + await fiber.dispose() + expect(ctx.slots.entries('deliverables.review.file.actions')).toHaveLength(0) + expect(ctx.slots.entries('shell.overlay')).toHaveLength(0) + }) + it('adapts the Session directory, shares the controller with the file tree, and removes both on teardown', async () => { vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ apps: [] }), { status: 200 }))) const { ctx, fiber, list } = await bench() @@ -236,7 +328,7 @@ describe('open-in-app browser half', () => { const entry = ctx.slots.entries('conversation.session.header.utilities')[0] const injected: Partial | undefined = entry?.inject?.() if (injected?.hooks === undefined || injected.iconUrl === undefined - || injected.choose === undefined || injected.launch === undefined) { + || injected.choose === undefined || injected.launch === undefined || injected.report === undefined) { throw new Error('expected the injected open-in-app actions') } const { hooks } = injected @@ -249,7 +341,11 @@ describe('open-in-app browser half', () => { injected.choose('cursor') expect(hooks.openInAppChoice.getSnapshot()).toBe('cursor') - await injected.launch('cursor', '/w/dir') + const toast = toastSource(ctx) + injected.report({ kind: 'revealed' }) + expect(toast.hooks.toast.getSnapshot()).toBeNull() + + await expect(injected.launch('cursor', '/w/dir')).resolves.toEqual({ kind: 'opened' }) const openCall = fetcher.mock.calls.find(call => call[0] === 'open-in-app/open') expect(openCall?.[1]).toMatchObject({ method: 'POST', @@ -259,7 +355,7 @@ describe('open-in-app browser half', () => { await fiber.dispose() }) - it('publishes an empty availability list when the host read fails, and launches reject on HTTP errors', async () => { + it('publishes an empty availability list when the host read fails, and launches return named failures on HTTP errors', async () => { vi.stubGlobal('fetch', vi.fn(async (input: string | URL) => { if (String(input) === 'open-in-app/apps') throw new Error('down') return new Response('', { status: 502 }) @@ -272,7 +368,7 @@ describe('open-in-app browser half', () => { await vi.waitFor(() => { expect(hooks.openInAppApps.getSnapshot()).toEqual([]) }) - await expect(injected.launch('finder', '/w/dir')).rejects.toThrow('open failed: HTTP 502') + await expect(injected.launch('finder', '/w/dir')).resolves.toEqual({ kind: 'failed', failure: 'openError' }) await fiber.dispose() }) diff --git a/packages/client/ui-open-in-app/tests/controller.client.spec.ts b/packages/client/ui-open-in-app/tests/controller.client.spec.ts index df7081d462..059dd5dedb 100644 --- a/packages/client/ui-open-in-app/tests/controller.client.spec.ts +++ b/packages/client/ui-open-in-app/tests/controller.client.spec.ts @@ -65,12 +65,13 @@ describe('OpenInAppController launching', () => { const pending = controller.launch('cursor', '/workspace/first') expect(controller.operation.getSnapshot()).toEqual({ phase: 'busy', path: '/workspace/first' }) controller.choose('finder') - await controller.launch('finder', '/workspace/second') + // The gesture another launch already holds settles as deliberate silence. + await expect(controller.launch('finder', '/workspace/second')).resolves.toEqual({ kind: 'skipped' }) expect(controller.choice.getSnapshot()).toBe('cursor') expect(fetcher).toHaveBeenCalledOnce() expect(fetcher.mock.calls[0]?.[1]?.body).toBe(JSON.stringify({ app: 'cursor', path: '/workspace/first' })) finish(jsonResponse({ ok: true })) - await pending + await expect(pending).resolves.toEqual({ kind: 'opened' }) expect(controller.operation.getSnapshot()).toEqual({ phase: 'idle', path: '/workspace/first' }) }) @@ -91,7 +92,7 @@ describe('OpenInAppController launching', () => { it('posts the launch body and surfaces HTTP failures', async () => { const fetcher = vi.fn(async (input: string | URL, init?: RequestInit) => { void input; void init; return jsonResponse({ ok: true }) }) const controller = new OpenInAppController(fetcher) - await controller.launch('cursor', '/w/dir') + await expect(controller.launch('cursor', '/w/dir')).resolves.toEqual({ kind: 'opened' }) expect(fetcher.mock.calls[0]?.[0]).toBe('open-in-app/open') expect(fetcher.mock.calls[0]?.[1]).toMatchObject({ method: 'POST', @@ -100,7 +101,11 @@ describe('OpenInAppController launching', () => { }) const failing = new OpenInAppController(async () => jsonResponse({}, 404)) - await expect(failing.launch('cursor', '/w/dir')).rejects.toThrow('open failed: HTTP 404') + await expect(failing.launch('cursor', '/w/dir')).resolves.toEqual({ kind: 'failed', failure: 'openError' }) expect(failing.operation.getSnapshot()).toEqual({ phase: 'error', path: '/w/dir' }) + + const offline = new OpenInAppController(async () => { throw new Error('offline') }) + await expect(offline.launch('cursor', '/w/dir')).resolves.toEqual({ kind: 'failed', failure: 'openError' }) + expect(offline.operation.getSnapshot()).toEqual({ phase: 'error', path: '/w/dir' }) }) }) diff --git a/packages/client/ui-open-in-app/tests/file-route-action.client.spec.tsx b/packages/client/ui-open-in-app/tests/file-route-action.client.spec.tsx index 5b9a158086..ba691a5707 100644 --- a/packages/client/ui-open-in-app/tests/file-route-action.client.spec.tsx +++ b/packages/client/ui-open-in-app/tests/file-route-action.client.spec.tsx @@ -1,15 +1,19 @@ // @vitest-environment jsdom -/** Authorized file routes use the same application menu and failure feedback as previews. */ +/** Authorized file routes use the same application menu and outcome reporting as previews. */ import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, expect, it, vi } from 'vitest' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { FileRouteAction } from '../src/client/FileRouteAction.tsx' import { en } from '../src/client/locales.ts' +import type { OpenInAppOutcome } from '../src/client/outcome.ts' afterEach(() => { cleanup(); vi.unstubAllGlobals() }) const apps = [{ id: 'music', name: 'Music', default: true, icon: null }] -const props = () => ({ actionUrl: 'api/present.open?sessionId=s&seq=2&index=0', available: true, pending: false, - onAction: vi.fn(async () => null), t: makeTranslate(en) }) +const props = () => ({ + actionUrl: 'api/present.open?sessionId=s&seq=2&index=0', available: true, pending: false, + onAction: vi.fn(async (): Promise => ({ kind: 'opened' })), + report: vi.fn(), t: makeTranslate(en), +}) it('queries the authorized route, selects a handler, and keeps reveal last', async () => { const fetcher = vi.fn(async () => Response.json(apps)) @@ -18,13 +22,14 @@ it('queries the authorized route, selects a handler, and keeps reveal last', asy render() await act(async () => {}) expect(fetcher).toHaveBeenCalledWith(p.actionUrl, { signal: expect.any(AbortSignal) as AbortSignal }) - fireEvent.pointerEnter(screen.getByRole('button', { name: 'Open in Music' })) - expect(await screen.findByRole('tooltip')).toHaveProperty('textContent', 'Open in Music') + fireEvent.pointerEnter(screen.getByRole('button', { name: en['open.title'].replace('{app}', 'Music') })) + expect(await screen.findByRole('tooltip')).toHaveProperty('textContent', en['open.title'].replace('{app}', 'Music')) await act(async () => { fireEvent.click(screen.getByRole('button', { name: en['path.more'] })) }) expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual(['Music (default)', 'Show file location']) await act(async () => { fireEvent.click(screen.getByRole('menuitem', { name: 'Music (default)' })) }) expect(p.onAction).toHaveBeenLastCalledWith('open', 'music') - await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Open in Music' })) }) + expect(p.report).toHaveBeenLastCalledWith({ kind: 'opened' }) + await act(async () => { fireEvent.click(screen.getByRole('button', { name: en['open.title'].replace('{app}', 'Music') })) }) expect(p.onAction).toHaveBeenLastCalledWith('open', undefined) }) @@ -49,5 +54,26 @@ it('does not query without a desktop and honors another pending gesture', async expect(fetcher).not.toHaveBeenCalled() view.rerender() await act(async () => {}) - expect(screen.getByRole('button', { name: 'Open in Music' })).toHaveProperty('disabled', true) + expect(screen.getByRole('button', { name: en['open.title'].replace('{app}', 'Music') })).toHaveProperty('disabled', true) +}) + +it('reports failure and deliberate silence, and never renders a banner of its own', async () => { + vi.stubGlobal('fetch', vi.fn(async () => Response.json(apps))) + const failed = props() + failed.onAction = vi.fn(async (): Promise => ({ kind: 'failed', failure: 'openError' })) + render() + await act(async () => {}) + await act(async () => { fireEvent.click(screen.getByRole('button', { name: en['open.title'].replace('{app}', 'Music') })) }) + expect(failed.report).toHaveBeenCalledWith({ kind: 'failed', failure: 'openError' }) + // The banner belongs to ui-open-in-app's frame-wide overlay entry, not to this card control. + expect(screen.queryByRole('alert')).toBeNull() + cleanup() + + const dropped = props() + dropped.onAction = vi.fn(async (): Promise => ({ kind: 'skipped' })) + render() + await act(async () => {}) + await act(async () => { fireEvent.click(screen.getByRole('button', { name: en['open.title'].replace('{app}', 'Music') })) }) + expect(dropped.report).toHaveBeenCalledWith({ kind: 'skipped' }) + expect(screen.queryByRole('alert')).toBeNull() }) diff --git a/packages/client/ui-open-in-app/tests/open-in-app-action.client.spec.tsx b/packages/client/ui-open-in-app/tests/open-in-app-action.client.spec.tsx index 532a8e8df9..43952c4474 100644 --- a/packages/client/ui-open-in-app/tests/open-in-app-action.client.spec.tsx +++ b/packages/client/ui-open-in-app/tests/open-in-app-action.client.spec.tsx @@ -20,6 +20,7 @@ interface Bench { props: OpenInAppActionProps launch: ReturnType choose: ReturnType + report: ReturnType } function bench(over: { @@ -38,6 +39,7 @@ function bench(over: { }) const launch = vi.fn((appId: string, path: string) => controller.launch(appId, path)) const choose = vi.fn() + const report = vi.fn() const props: OpenInAppActionProps = { absolutePath: over.absolutePath ?? '/w', useOpenInAppApps: bindSnapshotSelector(apps), @@ -46,10 +48,11 @@ function bench(over: { useShortcuts: bindSnapshotSelector(createSnapshotStore(over.shortcuts ?? [])), launch, choose, + report, iconUrl: (appId: string) => `open-in-app/icon/${appId}`, t, } - return { props, launch, choose } + return { props, launch, choose, report } } describe('OpenInAppAction visibility', () => { @@ -75,7 +78,7 @@ describe('OpenInAppAction visibility', () => { it('shows the remembered choice, falling back to the first available app when it is gone', () => { render() - expect(screen.getByRole('button', { name: t('open.title', { app: 'Cursor' }) }).querySelector('img')?.getAttribute('src')).toBe('open-in-app/icon/cursor') + expect(screen.getByRole('button', { name: t('open.title', { app: zh['app.cursor'] }) }).querySelector('img')?.getAttribute('src')).toBe('open-in-app/icon/cursor') cleanup() render() @@ -97,15 +100,26 @@ describe('OpenInAppAction launching', () => { expect(view.container.querySelector('[data-open-target]')?.getAttribute('data-state')).toBe('busy') await act(async () => { pending.resolve(undefined) }) expect(main).toHaveProperty('disabled', false) + expect(b.report).toHaveBeenCalledWith({ kind: 'opened' }) }) - it('announces launch failure without changing the remembered application', async () => { + it('reports a dropped gesture without remembering its application', async () => { + const b = bench({ apps: ['finder', 'cursor'], absolutePath: '/w/dir' }) + render( ({ kind: 'skipped' })} />) + fireEvent.click(screen.getByRole('button', { name: zh['path.more'] })) + await act(async () => { fireEvent.click(screen.getByRole('menuitem', { name: 'Cursor' })) }) + expect(b.report).toHaveBeenCalledWith({ kind: 'skipped' }) + expect(b.choose).not.toHaveBeenCalled() + }) + + it('reports a launch failure without changing the remembered application', async () => { const b = bench({ apps: ['finder', 'cursor'], absolutePath: '/w', launch: async () => { throw new Error('gone') } }) render() fireEvent.click(screen.getByRole('button', { name: zh['path.more'] })) await act(async () => { fireEvent.click(screen.getByRole('menuitem', { name: 'Cursor' })) }) - expect(screen.getByRole('alert').textContent).toContain(zh['path.openError']) + expect(b.report).toHaveBeenCalledWith({ kind: 'failed', failure: 'openError' }) expect(b.choose).not.toHaveBeenCalled() + expect(screen.queryByRole('alert')).toBeNull() }) it('lists only applications, marks the remembered default, and remembers a successful choice', async () => { @@ -117,6 +131,7 @@ describe('OpenInAppAction launching', () => { await act(async () => { fireEvent.click(screen.getByRole('menuitem', { name: 'Cursor' })) }) expect(b.launch).toHaveBeenCalledWith('cursor', '/w/dir') expect(b.choose).toHaveBeenCalledWith('cursor') + expect(b.report).toHaveBeenCalledWith({ kind: 'opened' }) }) it('closes on Escape without launching and falls back after an icon fails', async () => { @@ -132,7 +147,7 @@ describe('OpenInAppAction launching', () => { expect(view.container.querySelector('svg')).not.toBeNull() }) - it('names the selected application in the tooltip', async () => { + it('names the selected application in both the button and tooltip', async () => { render() fireEvent.pointerEnter(screen.getByRole('button', { name: t('open.title', { app: zh['app.finder'] }) })) expect(await screen.findByText(t('open.title', { app: zh['app.finder'] }))).toBeTruthy() diff --git a/packages/client/ui-open-in-app/tests/open-path-action.client.spec.tsx b/packages/client/ui-open-in-app/tests/open-path-action.client.spec.tsx index 2113cba232..e38ebdf74d 100644 --- a/packages/client/ui-open-in-app/tests/open-path-action.client.spec.tsx +++ b/packages/client/ui-open-in-app/tests/open-path-action.client.spec.tsx @@ -1,16 +1,17 @@ // @vitest-environment jsdom /** * The two document-preview path controls: hidden until the Host reports a - * desktop, busy only for their own gesture, and announcing a failure once - * through their own toast. + * desktop, busy only for their own gesture, and reporting each settled outcome + * once to the app-wide announcement seat instead of rendering one itself. */ -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi, type Mock } from 'vitest' import { act, cleanup, fireEvent, render, renderHook, screen } from '@testing-library/react' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { OpenPathAction, type OpenPathActionProps } from '../src/client/OpenPathAction.tsx' import { OpenPathEmptyAction, type OpenPathEmptyActionProps } from '../src/client/OpenPathEmptyAction.tsx' -import type { OpenInAppPathAction, OpenInAppPathFailure } from '../src/client/open-path.ts' +import type { OpenInAppPathAction } from '../src/client/open-path.ts' +import type { OpenInAppOutcome } from '../src/client/outcome.ts' import { useOpenTargetGesture } from '../src/client/OpenTargetButton.tsx' import { zh } from '../src/client/locales.ts' @@ -27,13 +28,15 @@ interface Bench { props: OpenPathActionProps & OpenPathEmptyActionProps desktop: ReturnType> loadDesktop: ReturnType - openPath: ReturnType Promise>> + openPath: ReturnType Promise>> + report: Mock<(outcome: OpenInAppOutcome) => void> } function bench(over: { desktop?: boolean | null; openPath?: Bench['openPath'] } = {}): Bench { const desktop = createSnapshotStore(over.desktop === undefined ? true : over.desktop) const loadDesktop = vi.fn(async () => {}) - const openPath = over.openPath ?? vi.fn(async () => null) + const openPath = over.openPath ?? vi.fn(async (): Promise => ({ kind: 'opened' })) + const report = vi.fn<(outcome: OpenInAppOutcome) => void>() function useSelector(source: { getSnapshot(): T }): (select: (value: T) => R) => R { return select => select(source.getSnapshot()) } @@ -43,9 +46,10 @@ function bench(over: { desktop?: boolean | null; openPath?: Bench['openPath'] } loadDesktop, applications: vi.fn(async () => [{ id: '/Music.app', name: 'Music', default: true, icon: null }]), openPath, + report, t, } as Bench['props'] - return { props, desktop, loadDesktop, openPath } + return { props, desktop, loadDesktop, openPath, report } } describe('OpenPathAction visibility', () => { @@ -89,8 +93,8 @@ describe('OpenPathAction gestures', () => { expect(b.openPath).toHaveBeenLastCalledWith(ABSOLUTE_PATH, 'open', '/Music.app') }) - it('disables both halves while its own gesture settles and leaves no failure on the control', async () => { - const settled = Promise.withResolvers() + it('disables both halves while its own gesture settles and reports the failure it settled on', async () => { + const settled = Promise.withResolvers() const b = bench({ openPath: vi.fn(() => settled.promise) }) const { container } = render() const main = await screen.findByRole('button', { name: t('open.title', { app: 'Music' }) }) @@ -100,10 +104,12 @@ describe('OpenPathAction gestures', () => { expect(container.querySelector('[data-open-path]')?.getAttribute('data-state')).toBe('busy') fireEvent.click(main) expect(b.openPath).toHaveBeenCalledOnce() - await act(async () => { settled.resolve('openError'); await settled.promise }) + await act(async () => { settled.resolve({ kind: 'failed', failure: 'openError' }); await settled.promise }) expect(main).toHaveProperty('disabled', false) expect(container.querySelector('[data-open-path]')?.getAttribute('data-state')).toBe('idle') - expect(screen.getByRole('alert').textContent).toContain(zh['path.openError']) + expect(b.report).toHaveBeenCalledTimes(1) + expect(b.report).toHaveBeenCalledWith({ kind: 'failed', failure: 'openError' }) + expect(screen.queryByRole('alert')).toBeNull() }) }) @@ -117,37 +123,29 @@ describe('OpenPathEmptyAction', () => { await act(async () => {}) await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh['path.open'] })) }) expect(b.openPath).toHaveBeenCalledWith(ABSOLUTE_PATH, 'open', undefined) + expect(b.report).toHaveBeenCalledWith({ kind: 'opened' }) expect(screen.queryByRole('alert')).toBeNull() }) - it('announces a failed open once through its own toast, which leaves after its hold and fade', async () => { - vi.useFakeTimers() - const b = bench({ openPath: vi.fn(async () => 'openError' as const) }) + it('reports an acknowledged reveal and a failed open to the announcement seat, rendering none itself', async () => { + const revealed = bench({ openPath: vi.fn(async (): Promise => ({ kind: 'revealed' })) }) + render( []} />) + await act(async () => {}) + await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh['path.reveal'] })) }) + expect(revealed.report).toHaveBeenCalledWith({ kind: 'revealed' }) + cleanup() + + const b = bench({ openPath: vi.fn(async (): Promise => ({ kind: 'failed', failure: 'openError' })) }) render() await act(async () => {}) await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh['path.open'] })) }) - expect(screen.getAllByRole('alert')).toHaveLength(1) - expect(screen.getByRole('alert').textContent).toContain(zh['path.openError']) - act(() => { vi.advanceTimersByTime(4_000) }) + expect(b.report).toHaveBeenCalledTimes(1) + expect(b.report).toHaveBeenCalledWith({ kind: 'failed', failure: 'openError' }) + // The banner belongs to this plugin's overlay entry, which outlives the control. expect(screen.queryByRole('alert')).toBeNull() }) }) -it('dismisses a failure on schedule even when its owner rerenders', async () => { - vi.useFakeTimers() - const b = bench({ openPath: vi.fn(async () => 'openError' as const) }) - const view = render() - await act(async () => {}) - await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh['path.open'] })) }) - act(() => { vi.advanceTimersByTime(2_000) }) - view.rerender() - await act(async () => {}) - act(() => { vi.advanceTimersByTime(1_999) }) - expect(screen.getByRole('alert')).toBeTruthy() - act(() => { vi.advanceTimersByTime(1) }) - expect(screen.queryByRole('alert')).toBeNull() -}) - it('shows the default icon and current application list, and selects an application without replacing default opening', async () => { const b = bench() @@ -218,13 +216,16 @@ it('uses reveal as the default only when no application is registered', async () it('coalesces repeated gestures before the busy state renders', async () => { - const pending = Promise.withResolvers() + const pending = Promise.withResolvers() const b = bench({ openPath: vi.fn(() => pending.promise) }) - const { result } = renderHook(() => useOpenTargetGesture(() => b.openPath(ABSOLUTE_PATH, 'open'), t)) + const { result } = renderHook(() => useOpenTargetGesture(() => b.openPath(ABSOLUTE_PATH, 'open'), b.report)) act(() => { result.current.act({ kind: 'default' }); result.current.act({ kind: 'default' }) }) expect(b.openPath).toHaveBeenCalledOnce() - await act(async () => { pending.resolve(null) }) + expect(b.report).not.toHaveBeenCalled() + await act(async () => { pending.resolve({ kind: 'opened' }) }) expect(result.current.pending).toBe(false) + expect(b.report).toHaveBeenCalledOnce() + expect(b.report).toHaveBeenCalledWith({ kind: 'opened' }) }) diff --git a/packages/client/ui-open-in-app/tests/open-path.client.spec.ts b/packages/client/ui-open-in-app/tests/open-path.client.spec.ts index 2def94a830..e542b9164c 100644 --- a/packages/client/ui-open-in-app/tests/open-path.client.spec.ts +++ b/packages/client/ui-open-in-app/tests/open-path.client.spec.ts @@ -51,8 +51,8 @@ describe('OpenInAppPathController gestures', () => { const openWorkspacePath = vi.fn(async (_request: SessionOpenWorkspacePathRequest, _signal?: AbortSignal) => ({ ok: true as const, value: { opened: true as const } })) const controller = new OpenInAppPathController(remoteOf({ openWorkspacePath })) - expect(await controller.openPath('/w/clip.mp4', 'open')).toBeNull() - expect(await controller.openPath('/w/clip.mp4', 'reveal')).toBeNull() + expect(await controller.openPath('/w/clip.mp4', 'open')).toEqual({ kind: 'opened' }) + expect(await controller.openPath('/w/clip.mp4', 'reveal')).toEqual({ kind: 'revealed' }) expect(openWorkspacePath.mock.calls.map(call => call[0])).toEqual([ { path: '/w/clip.mp4' }, { path: '/w/clip.mp4', action: 'reveal' }, @@ -63,11 +63,11 @@ describe('OpenInAppPathController gestures', () => { const refused = new OpenInAppPathController(remoteOf({ openWorkspacePath: async () => ({ ok: false, error: new RemoteError('gateway/internal', 'boom', {}) }), })) - expect(await refused.openPath('/w/clip.mp4', 'open')).toBe('openError') - expect(await refused.openPath('/w/clip.mp4', 'reveal')).toBe('revealError') + expect(await refused.openPath('/w/clip.mp4', 'open')).toEqual({ kind: 'failed', failure: 'openError' }) + expect(await refused.openPath('/w/clip.mp4', 'reveal')).toEqual({ kind: 'failed', failure: 'revealError' }) const rejecting = new OpenInAppPathController(remoteOf({ openWorkspacePath: () => Promise.reject(new Error('down')) })) - expect(await rejecting.openPath('/w/clip.mp4', 'open')).toBe('openError') + expect(await rejecting.openPath('/w/clip.mp4', 'open')).toEqual({ kind: 'failed', failure: 'openError' }) }) }) diff --git a/packages/client/ui-open-in-app/tests/open-toast.client.spec.tsx b/packages/client/ui-open-in-app/tests/open-toast.client.spec.tsx new file mode 100644 index 0000000000..714d99d1a6 --- /dev/null +++ b/packages/client/ui-open-in-app/tests/open-toast.client.spec.tsx @@ -0,0 +1,107 @@ +// @vitest-environment jsdom +/** Mobile handoff feedback and native-open failures rendered outside the initiating panel. */ +import { act, cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { + OpenInAppToast, + type OpenInAppToastProps, type OpenInAppToastState, +} from '../src/client/open-toast.tsx' +import { en, ja, es, fr, de, ru, pt, zh } from '../src/client/locales.ts' + +afterEach(() => { cleanup(); vi.useRealTimers(); vi.unstubAllGlobals() }) + +function mount(state: OpenInAppToastState | null, dictionary: typeof en | typeof zh = en) { + const dismiss = vi.fn() + const props = { + useToast: (select: (value: OpenInAppToastState | null) => T): T => select(state), + dismiss, + t: makeTranslate(dictionary), + } as OpenInAppToastProps + return { view: render(), dismiss } +} + +describe('open-in-app toast source', () => { + it.each([ + ['iPhone', 'iPhone', 'iPhone', 5, 390, true], + ['Android', 'Android', 'Linux armv8l', 5, 390, true], + ['desktop-mode iPad', 'Macintosh', 'MacIntel', 5, 1024, true], + ['desktop browser', 'Macintosh', 'MacIntel', 0, 1280, false], + ['narrow desktop browser', 'Macintosh', 'MacIntel', 0, 390, false], + ['touch Windows desktop', 'Windows NT', 'Win32', 10, 1280, false], + ['Electron desktop', 'Macintosh Electron', 'MacIntel', 0, 1280, false], + ] as const)('announces successes only for mobile devices: %s', async (_device, userAgent, platform, maxTouchPoints, width, mobile) => { + vi.resetModules() + vi.stubGlobal('navigator', { userAgent, platform, maxTouchPoints }) + vi.stubGlobal('innerWidth', width) + const { createOpenInAppToastSource } = await import('../src/client/open-toast.tsx') + const source = createOpenInAppToastSource() + const listener = vi.fn() + const stop = source.hooks.toast.subscribe(listener) + expect(source.hooks.toast.getSnapshot()).toBeNull() + + source.report({ kind: 'skipped' }) + expect(listener).not.toHaveBeenCalled() + expect(source.hooks.toast.getSnapshot()).toBeNull() + + source.report({ kind: 'opened' }) + expect(source.hooks.toast.getSnapshot()).toEqual(mobile ? { kind: 'opened', seq: 1 } : null) + source.report({ kind: 'revealed' }) + expect(source.hooks.toast.getSnapshot()).toEqual(mobile ? { kind: 'revealed', seq: 2 } : null) + const previous = source.hooks.toast.getSnapshot() + source.report({ kind: 'skipped' }) + expect(source.hooks.toast.getSnapshot()).toBe(previous) + + source.report({ kind: 'failed', failure: 'openError' }) + expect(source.hooks.toast.getSnapshot()).toEqual({ kind: 'failed', failure: 'openError', seq: mobile ? 3 : 1 }) + source.report({ kind: 'failed', failure: 'revealError' }) + expect(source.hooks.toast.getSnapshot()).toEqual({ kind: 'failed', failure: 'revealError', seq: mobile ? 4 : 2 }) + if (!mobile) { + source.report({ kind: 'opened' }) + expect(source.hooks.toast.getSnapshot()).toEqual({ kind: 'failed', failure: 'revealError', seq: 2 }) + } + source.dismiss() + expect(source.hooks.toast.getSnapshot()).toBeNull() + expect(listener).toHaveBeenCalledTimes(mobile ? 5 : 3) + + stop() + source.report({ kind: 'failed', failure: 'openError' }) + expect(listener).toHaveBeenCalledTimes(mobile ? 5 : 3) + }) +}) + +describe('OpenInAppToast', () => { + it('renders nothing while no outcome shows', () => { + mount(null) + expect(screen.queryByRole('alert')).toBeNull() + }) + + it('shows where the acknowledged open happened and dismisses once it has faded', () => { + vi.useFakeTimers() + const { dismiss } = mount({ kind: 'opened', seq: 1 }) + expect(screen.getByRole('alert').textContent).toBe('Opened on another device') + act(() => { vi.runAllTimers() }) + expect(dismiss).toHaveBeenCalledOnce() + }) + + it('shows the other-device confirmation in Chinese', () => { + mount({ kind: 'opened', seq: 1 }, zh) + expect(screen.getByRole('alert').textContent).toBe('已在其它设备打开') + }) + + it.each([ + ['en', en], ['zh', zh], ['ja', ja], ['es', es], + ['fr', fr], ['de', de], ['ru', ru], ['pt', pt], + ] as const)('localizes the other-device file-location confirmation: %s', (_locale, dictionary) => { + mount({ kind: 'revealed', seq: 2 }, dictionary) + expect(screen.getByRole('alert').textContent).toBe(dictionary['path.revealed']) + }) + + it('shows the warning banner for a failed open and for a failed reveal', () => { + mount({ kind: 'failed', failure: 'openError', seq: 3 }) + expect(screen.getByRole('alert').textContent).toBe(en['path.openError']) + cleanup() + mount({ kind: 'failed', failure: 'revealError', seq: 4 }, zh) + expect(screen.getByRole('alert').textContent).toBe(zh['path.revealError']) + }) +}) diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index cad46c62fc..e3b1049c7d 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -24,8 +24,8 @@ en: b3744373b4b2c1fb zh: 2dbde8ee0bfa5f28 /deepseek-ai-dsh-client-ui-primitives/use-this-package/rendering-agent-output: - en: 2daec758b1f35563 - zh: 32684be1f8b1b4c3 + en: 32d4557c4429e00d + zh: 293a6fef712f7502 /deepseek-ai-dsh-client-ui-primitives/use-this-package/localizing-copy: en: a943603b74080ef5 zh: 43f7a0188aff271b @@ -54,8 +54,8 @@ en: ca75c51c89c2c9b0 zh: de54f3467ef3b9be /deepseek-ai-dsh-client-ui-primitives/known-limitations-and-deferred-work: - en: 9c5bc17e35af9d0b - zh: 4f1a142c7d56cf72 + en: 2f2db9565903b4e6 + zh: ca9d8fed836d857c /deepseek-ai-dsh-client-ui-primitives/known-limitations-and-deferred-work/dev-note: en: e67f633dc6fe7773 zh: 3bb0c20c93f16f98 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 95db022946..a1285641e2 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -110,7 +110,7 @@ The pure Markdown helper `formatQuoteText` normalizes CRLF and CR line endings, The nearest `MarkdownDelegateProvider` supplies optional `openExternalLink` and `openFile` navigation callbacks. Nested providers replace the enclosing capabilities, and callback changes reach already-rendered links without rebuilding Markdown. Its `openFile` makes local Markdown links clickable after settlement. Absolute and workspace-relative paths support percent escapes and `#L24` / `#L24-L30` fragments; ranges open at their first line. A destination with a backslash and no URL scheme (a drive letter is not one) is a Windows path when it has no `/` or has a backslash before a letter or digit. Such a path keeps every backslash, including one before punctuation, and still decodes character references, so `D:\repo\.github` is not read as an escaped `.`. Literal `?` and `#` in filenames must be percent-encoded. The tooltip uses the decoded path and supplies the accessible name when the label is empty. The callback receives the decoded path and optional line, while the renderer preserves the label and displays a file icon. Without a callback, local links remain text. URL schemes, queries, unsupported fragments, and malformed destinations never reach the file opener. -`MarkdownText` renders untrusted GFM and TeX math, blocks unsafe links and images, and can turn resolved file mentions into explicit controls. Strikethrough requires `~~`; a single `~`, as in `10~20`, stays literal. A surrounding `MarkdownDelegateProvider` receives sanitized HTTP(S) URLs from ordinary clicks; modified clicks and links outside a provider retain native external-anchor behavior. `omitHtml` renders raw HTML as nothing instead of literal text, for documents written for HTML renderers. When the owner passes a `pathImages` vocabulary, image destinations that are local media paths rewrite to displayable URLs on settled renders only (the same streaming gate as file mentions); without a vocabulary, local destinations remain inert alt text. Rewritten images accept HTTP(S), data, blob, and the Desktop `dsh-app://app/api/file`, `dsh-app://app/api/plugin-market/detail-file`, and `dsh-app://app/api/plugin-manager/detail-file` routes; authored Desktop URLs remain inert. A load or decode failure replaces the image with its authored alt text, or the original destination when alt is empty; `fileImages` additionally supplies a localized failure prefix. Changing the image source permits a fresh load. Each mounted image appends a `load` query parameter to a resolved Host `api/file` URL, because browsers reuse a same-URL image within one document despite `no-store`; a newly rendered image of an overwritten file therefore loads its current bytes, and every remount requests the file again; the Host file route redirects that request to a cacheable URL for the current file version. While a reply streams, it freezes completed blocks, advances a top-level open fence by completed lines, and highlights that fence from saved Shiki grammar state. Completed token lines enter fixed-size React groups, so later chunks reconcile only the growing group; an unchanged fence retains that DOM when the final full parse resolves cross-document syntax. `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, and `WebBlock` render the matching tool-result intent with copy controls, overflow handling, and ANSI processing where applicable. `JsonTree` and `JsonBlock` inspect JSON values read-only, while `UserTextRun` renders uninterpreted text and `ReferenceChip` renders business-resolved labels. An explicit `onActivate` makes a chip a keyboard-accessible button; repeated pointer clicks and an existing text selection do not activate it, while keyboard activation remains available. Labels inherit their host’s wrapping policy. Recognition and JSON transforms belong to [ui-conversation](../ui-conversation/README.md#sent-user-text). +`MarkdownText` renders untrusted GFM and TeX math, blocks unsafe links and images, and can turn resolved file mentions into explicit controls. Strikethrough requires `~~`; a single `~`, as in `10~20`, stays literal. A bare URL ends before non-ASCII punctuation or symbols, such as `。` or `,`, so CJK prose that follows it stays outside the link. A surrounding `MarkdownDelegateProvider` receives sanitized HTTP(S) URLs from ordinary clicks; modified clicks and links outside a provider retain native external-anchor behavior. `omitHtml` renders raw HTML as nothing instead of literal text, for documents written for HTML renderers. When the owner passes a `pathImages` vocabulary, image destinations that are local media paths rewrite to displayable URLs on settled renders only (the same streaming gate as file mentions); without a vocabulary, local destinations remain inert alt text. Rewritten images accept HTTP(S), data, blob, and the Desktop `dsh-app://app/api/file`, `dsh-app://app/api/plugin-market/detail-file`, and `dsh-app://app/api/plugin-manager/detail-file` routes; authored Desktop URLs remain inert. A load or decode failure replaces the image with its authored alt text, or the original destination when alt is empty; `fileImages` additionally supplies a localized failure prefix. Changing the image source permits a fresh load. Each mounted image appends a `load` query parameter to a resolved Host `api/file` URL, because browsers reuse a same-URL image within one document despite `no-store`; a newly rendered image of an overwritten file therefore loads its current bytes, and every remount requests the file again; the Host file route redirects that request to a cacheable URL for the current file version. While a reply streams, it freezes completed blocks, advances a top-level open fence by completed lines, and highlights that fence from saved Shiki grammar state. Completed token lines enter fixed-size React groups, so later chunks reconcile only the growing group; an unchanged fence retains that DOM when the final full parse resolves cross-document syntax. `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, and `WebBlock` render the matching tool-result intent with copy controls, overflow handling, and ANSI processing where applicable. `JsonTree` and `JsonBlock` inspect JSON values read-only, while `UserTextRun` renders uninterpreted text and `ReferenceChip` renders business-resolved labels. An explicit `onActivate` makes a chip a keyboard-accessible button; repeated pointer clicks and an existing text selection do not activate it, while keyboard activation remains available. Labels inherit their host’s wrapping policy. Recognition and JSON transforms belong to [ui-conversation](../ui-conversation/README.md#sent-user-text). `MarkdownText` defaults to `variant="body"`. Use `variant="compact"` for secondary content: its 13px text and 20px line height follow the content-size setting, all heading levels use the same size with weight 600, and paragraphs and lists use tighter spacing. Text, links, and code keep the tertiary color; dotted underlines distinguish links. Code headers scroll with their blocks. Tables and math stay enabled at the surrounding text size and scroll horizontally within the available width. Both variants share the parser and streaming cache. `textInteractionSource` marks its existing root as a source for text selection interactions. @@ -220,6 +220,7 @@ These limits define how the atoms behave at the edges; they are current package - **Streaming defers cross-boundary reference resolution** — a reference-style link or footnote whose definition sits on the other side of the incremental freeze boundary renders as literal text while the reply streams; the settled full parse at finalize resolves it. - **A long highlighted fence retains its complete token DOM** — streaming avoids re-parsing, re-tokenizing, and reconciling the completed prefix, but it does not discard old colors or virtualize token spans. Final DOM cardinality therefore still follows the fence's token count; nested/container fences and a pathological single long line remain on the general tail path. - **Overlong lines stay uncolored** — a source line of 1,000 or more UTF-16 units renders as one plain run in every highlighted surface, because TextMate scanning cost grows superlinearly with line length and a single generated line can otherwise block the page for seconds. The grammar state skips that line, so a comment or template literal that opens or closes on it mis-colors the following lines. Highlighting still runs synchronously during render, so a file with thousands of ordinary lines costs proportionally more. +- **Bare URLs end before non-ASCII punctuation** — the [tokenizer patch](../../../patches/micromark-extension-gfm-autolink-literal@2.1.0.patch) and the [transform patch](../../../patches/mdast-util-gfm-autolink-literal@2.0.1.patch) end a GFM autolink literal at non-ASCII punctuation, symbols, or whitespace, so a URL with an unencoded character such as `(` or `・` in its path links only up to it; write such a URL as `<…>` or `[…](…)`. A URL followed directly by CJK letters still links them, because paths such as `/wiki/中文` are valid. Both patches are keyed to exact versions: an upgrade of either package must re-apply or retire its patch, otherwise the boundary rule silently disappears. - **The Go struct-field rule is patched locally** — when the Go grammar loads, its repeated one-line struct-field rule is replaced with the rule [worlpaker/go-syntax#25](https://github.com/worlpaker/go-syntax/pull/25) merged, because the vendored rule backtracks exponentially on lines such as a brace-bearing comment after `struct {`. If a dependency upgrade removes that rule, loading the grammar throws and Go code renders as plain text; delete the patch once `@shikijs/langs` ships the upstream rule. - **The fish logo is a redrawn approximation** — its font glyph has no exportable vector geometry in the local design data, so a hand-authored recreation stands in until an exact export path exists. - **`Pill` and `Input` have no design source** — both atoms are self-defined; the sidebar search field and view-tab strip that resemble them are consumer-owned compositions, not these atoms. diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 910185907b..c5b9360995 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -110,7 +110,7 @@ kind: "package-library" 最近的 `MarkdownDelegateProvider` 提供可选的 `openExternalLink` 和 `openFile` 导航回调。嵌套 Provider 替换外层能力,回调变化无需重新构建 Markdown 即可到达已渲染链接。其 `openFile` 使本地 Markdown 链接在落定后可点击。绝对路径和工作区相对路径支持百分号转义以及 `#L24` / `#L24-L30` 片段;范围定位到起始行。含反斜杠且无 URL 协议(盘符不算 URL 协议)的目标,若不含 `/`,或在字母或数字前有反斜杠,即为 Windows 路径。此类路径保留所有反斜杠(包括标点前的反斜杠),仍会解码字符引用,因此 `D:\repo\.github` 不会被读作转义的 `.`。文件名中的字面 `?` 和 `#` 必须百分号编码。悬停提示使用解码后的路径,并在标签为空时提供可访问名称。回调接收解码后的路径和可选行号,渲染器保留标签并显示文件图标。不传回调时,本地链接仍为文本。URL 协议、查询串、不支持的片段及格式错误的目标不会传给文件打开器。 -`MarkdownText` 渲染不可信的 GFM 与 TeX 公式、阻止不安全的链接与图片,并可把已解析的文件提及转换为显式控件。删除线需要 `~~`;单个 `~`(如 `10~20`)保持字面文本。外层 `MarkdownDelegateProvider` 会接收普通点击产生的已净化 HTTP(S) URL;带修饰键的点击和 Provider 外的链接保留原生外部 anchor 行为。`omitHtml` 不渲染原始 HTML,而不是将其显示为原样文本,用于为 HTML 渲染器编写的文档。当 owner 传入 `pathImages` 词表时,本地媒体路径的图片目标只在落定渲染阶段重写为可展示 URL(与 file mentions 相同的流式门);不传词表时本地目标保持惰性 alt 文本。重写后的图片支持 HTTP(S)、data、blob 及桌面端 `dsh-app://app/api/file`、`dsh-app://app/api/plugin-market/detail-file` 与 `dsh-app://app/api/plugin-manager/detail-file` 路由;正文直接书写的桌面端 URL 仍保持惰性。加载或解码失败后,图片替换为作者的 alt 文本;alt 为空时显示原始目标路径;`fileImages` 还提供本地化的失败提示前缀。图片源变化后可重新加载。每个挂载的图片会为解析得到的 Host `api/file` 地址追加 `load` 查询参数,因为浏览器在同一文档内会无视 `no-store` 复用相同 URL 的图片;因此覆盖后的文件在新渲染的图片中会加载当前内容,每次重新挂载都会重新请求该文件;Host 文件路由会把该请求重定向到当前文件版本的可缓存地址。回复流式输出时,它冻结已完成的块、按已完成行推进顶层未闭合 fence,并从保存的 Shiki grammar state 为该 fence 增量高亮。已完成的 token 行进入固定大小的 React 分组,后续分片只 reconcile 正在增长的分组;最终全量解析解决跨文档语法时,未变化的 fence 会保留该 DOM。`TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock` 与 `WebBlock` 把对应的工具结果意图渲染为带复制控件、溢出处理及适用时 ANSI 处理的卡片。`JsonTree` 与 `JsonBlock` 以只读方式检查 JSON 值;`UserTextRun` 渲染不做解析的文本,`ReferenceChip` 渲染业务已解析的标签。显式传入 `onActivate` 后,胶囊成为可由键盘操作的按钮;重复指针点击和已有文本选区不触发激活,键盘激活仍然可用。标签继承所属容器的换行规则。识别和 JSON 变换由 [ui-conversation](../ui-conversation/README.zh.md#sent-user-text) 负责。 +`MarkdownText` 渲染不可信的 GFM 与 TeX 公式、阻止不安全的链接与图片,并可把已解析的文件提及转换为显式控件。删除线需要 `~~`;单个 `~`(如 `10~20`)保持字面文本。裸 URL 在非 ASCII 标点或符号(如 `。`、`,`)前结束,紧随其后的中文正文不会并入链接。外层 `MarkdownDelegateProvider` 会接收普通点击产生的已净化 HTTP(S) URL;带修饰键的点击和 Provider 外的链接保留原生外部 anchor 行为。`omitHtml` 不渲染原始 HTML,而不是将其显示为原样文本,用于为 HTML 渲染器编写的文档。当 owner 传入 `pathImages` 词表时,本地媒体路径的图片目标只在落定渲染阶段重写为可展示 URL(与 file mentions 相同的流式门);不传词表时本地目标保持惰性 alt 文本。重写后的图片支持 HTTP(S)、data、blob 及桌面端 `dsh-app://app/api/file`、`dsh-app://app/api/plugin-market/detail-file` 与 `dsh-app://app/api/plugin-manager/detail-file` 路由;正文直接书写的桌面端 URL 仍保持惰性。加载或解码失败后,图片替换为作者的 alt 文本;alt 为空时显示原始目标路径;`fileImages` 还提供本地化的失败提示前缀。图片源变化后可重新加载。每个挂载的图片会为解析得到的 Host `api/file` 地址追加 `load` 查询参数,因为浏览器在同一文档内会无视 `no-store` 复用相同 URL 的图片;因此覆盖后的文件在新渲染的图片中会加载当前内容,每次重新挂载都会重新请求该文件;Host 文件路由会把该请求重定向到当前文件版本的可缓存地址。回复流式输出时,它冻结已完成的块、按已完成行推进顶层未闭合 fence,并从保存的 Shiki grammar state 为该 fence 增量高亮。已完成的 token 行进入固定大小的 React 分组,后续分片只 reconcile 正在增长的分组;最终全量解析解决跨文档语法时,未变化的 fence 会保留该 DOM。`TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock` 与 `WebBlock` 把对应的工具结果意图渲染为带复制控件、溢出处理及适用时 ANSI 处理的卡片。`JsonTree` 与 `JsonBlock` 以只读方式检查 JSON 值;`UserTextRun` 渲染不做解析的文本,`ReferenceChip` 渲染业务已解析的标签。显式传入 `onActivate` 后,胶囊成为可由键盘操作的按钮;重复指针点击和已有文本选区不触发激活,键盘激活仍然可用。标签继承所属容器的换行规则。识别和 JSON 变换由 [ui-conversation](../ui-conversation/README.zh.md#sent-user-text) 负责。 `MarkdownText` 默认为 `variant="body"`。次级内容使用 `variant="compact"`:其 13px 字号与 20px 行高跟随内容字号设置,各级标题保持同一字号并使用 600 字重,段落与列表采用更紧凑的间距。正文、链接和代码均保持 tertiary 颜色,以点状下划线区分链接。代码标题栏随代码块滚动。表格和公式仍然启用,使用周围文字的字号,并在可用宽度内横向滚动。两个变体共享解析器与流式缓存。 `textInteractionSource` 将已有根元素标记为文本选区交互的来源。 @@ -220,6 +220,7 @@ kind: "package-library" - **流式期间跨边界引用解析被推迟**:定义落在增量冻结边界另一侧的引用式链接或脚注,在回复流式输出期间渲染为字面文本;定稿时的全量解析会将其解析。 - **长高亮 fence 会保留完整 token DOM**:流式路径避免重新解析、重新 tokenize 和 reconcile 已完成前缀,但不会丢弃旧颜色或虚拟化 token span。因此最终 DOM 数量仍随 fence 的 token 数增长;嵌套/容器内 fence 与病态的单个超长行仍走通用尾部路径。 - **超长行不着色**:所有高亮界面中,长度达到 1,000 个 UTF-16 单元的源码行渲染为一段纯文本,因为 TextMate 扫描成本随行长超线性增长,单个生成行否则会阻塞页面数秒。语法状态会跳过该行,因此在该行开始或结束的注释、模板字符串会使后续行着色错误。高亮仍在渲染期间同步执行,因此数千个普通行的文件仍按比例增加开销。 +- **裸 URL 在非 ASCII 标点前结束**:[tokenizer 补丁](../../../patches/micromark-extension-gfm-autolink-literal@2.1.0.patch)和[转换补丁](../../../patches/mdast-util-gfm-autolink-literal@2.0.1.patch)让 GFM 自动链接在非 ASCII 标点、符号或空白处结束,因此路径中含未编码 `(`、`・` 等字符的 URL 只链接到该字符之前;这类 URL 请写成 `<…>` 或 `[…](…)`。URL 后直接跟中日韩文字时仍会并入链接,因为 `/wiki/中文` 这类路径是合法的。两个补丁都绑定精确版本:升级任一依赖时必须重新应用或撤销对应补丁,否则该边界规则会悄然失效。 - **Go 结构体字段规则在本地打了补丁**:Go 语法加载时,重复匹配单行结构体字段的规则会替换为 [worlpaker/go-syntax#25](https://github.com/worlpaker/go-syntax/pull/25) 合入的规则,因为内置规则在 `struct {` 之后含花括号注释等行上会指数级回溯。若依赖升级后该规则消失,加载语法会抛错,Go 代码以纯文本显示;`@shikijs/langs` 发布上游规则后删除此补丁。 - **鱼形标志是重新绘制的近似版本**:它来自字体字形,而本地设计数据无法导出其矢量几何;在获得精确导出路径前,使用手工重建版本代替。 - **`Pill` 与 `Input` 没有设计来源**:两个原子组件均自行定义;与其相似的侧边栏搜索字段和视图标签条由消费方组合,不是这些原子组件。 diff --git a/packages/client/ui-primitives/tests/fixtures/markdown-dom/links-and-autolinks.settled.txt b/packages/client/ui-primitives/tests/fixtures/markdown-dom/links-and-autolinks.settled.txt index 7efb9bcaaa..0f674a580a 100644 --- a/packages/client/ui-primitives/tests/fixtures/markdown-dom/links-and-autolinks.settled.txt +++ b/packages/client/ui-primitives/tests/fixtures/markdown-dom/links-and-autolinks.settled.txt @@ -43,6 +43,16 @@ #text "https://autolink.example.com" #text " literal." +

+ #text "中文 " + +

#text "[spaces encoded](" diff --git a/packages/client/ui-primitives/tests/fixtures/markdown-dom/links-and-autolinks.streaming.txt b/packages/client/ui-primitives/tests/fixtures/markdown-dom/links-and-autolinks.streaming.txt index 7efb9bcaaa..0f674a580a 100644 --- a/packages/client/ui-primitives/tests/fixtures/markdown-dom/links-and-autolinks.streaming.txt +++ b/packages/client/ui-primitives/tests/fixtures/markdown-dom/links-and-autolinks.streaming.txt @@ -43,6 +43,16 @@ #text "https://autolink.example.com" #text " literal." +

+ #text "中文 " + +

#text "[spaces encoded](" diff --git a/packages/client/ui-primitives/tests/markdown-autolink-literals.client.spec.ts b/packages/client/ui-primitives/tests/markdown-autolink-literals.client.spec.ts new file mode 100644 index 0000000000..9ee6cd9451 --- /dev/null +++ b/packages/client/ui-primitives/tests/markdown-autolink-literals.client.spec.ts @@ -0,0 +1,57 @@ +import type { Paragraph, Root } from 'mdast' +import { describe, expect, it } from 'vitest' +import { parseGfm, parseGfmWithMath } from '../src/markdown/parse.ts' + +/** Each phrasing child as a link destination, its literal text, or its type. */ +function phrasing(root: Root): unknown[] { + return (root.children[0] as Paragraph).children.map(node => + node.type === 'link' ? { link: node.url } : node.type === 'text' ? node.value : node.type) +} + +describe.each([parseGfm, parseGfmWithMath])('autolink literals in %o', (parse) => { + it('end before non-ASCII punctuation', () => { + expect(phrasing(parse('工作台还在 http://127.0.0.1:8790/。建议先彩排;如果超过 18 分钟'))).toEqual( + ['工作台还在 ', { link: 'http://127.0.0.1:8790/' }, '。建议先彩排;如果超过 18 分钟']) + expect(phrasing(parse('见 https://example.com,然后'))).toEqual(['见 ', { link: 'https://example.com' }, ',然后']) + expect(phrasing(parse('(https://example.com/a?b=1)'))).toEqual(['(', { link: 'https://example.com/a?b=1' }, ')']) + expect(phrasing(parse('见 www.example.com/a.。'))).toEqual(['见 ', { link: 'http://www.example.com/a' }, '.。']) + expect(phrasing(parse('https://a.com/x、https://b.com/y。'))).toEqual( + [{ link: 'https://a.com/x' }, '、', { link: 'https://b.com/y' }, '。']) + expect(phrasing(parse('(www.example.com/a)后'))).toEqual(['(', { link: 'http://www.example.com/a' }, ')后']) + expect(phrasing(parse('😀参见(https://example.com/a_(b)),再看'))).toEqual( + ['😀参见(', { link: 'https://example.com/a_(b)' }, '),再看']) + const qq = 'https://qm.qq.com/cgi-bin/qm/qr?k=Abc%2BDef%2Fghi%3D%3D&authKey=tok%252Fvalue%253D&noverify=0' + expect(phrasing(parse(`加入群聊:${qq},欢迎。`))).toEqual(['加入群聊:', { link: qq }, ',欢迎。']) + // After an unbalanced `[`, the literal comes from the text transform instead of the tokenizer. + // Its case-insensitive Unicode regex must keep ASCII `s` and `k`, which fold with `ſ` and `K`. + expect(phrasing(parse('[x https://example.com/docs/Kit?s=K。后'))).toEqual( + ['[x ', { link: 'https://example.com/docs/Kit?s=K' }, '。后']) + for (const prefix of ['', '[x ']) { + expect(phrasing(parse(`${prefix}https://example.com/a😀下一句`))).toEqual( + [prefix, { link: 'https://example.com/a' }, '😀下一句'].filter(part => part !== '')) + expect(phrasing(parse(`${prefix}https://example.com/a\u3000下一句`))).toEqual( + [prefix, { link: 'https://example.com/a' }, '\u3000下一句'].filter(part => part !== '')) + expect(phrasing(parse(`${prefix}https://example.com/a𝒳b ok`))).toEqual( + [prefix, { link: 'https://example.com/a𝒳b' }, ' ok'].filter(part => part !== '')) + } + // Unencoded non-ASCII punctuation inside a URL also ends it. + expect(phrasing(parse('https://zh.wikipedia.org/wiki/中文(消歧义)'))).toEqual( + [{ link: 'https://zh.wikipedia.org/wiki/中文' }, '(消歧义)']) + }) + + it('leave following inline Markdown to the inline grammar', () => { + expect(phrasing(parse('https://example.com/a,**下一句**'))).toEqual([{ link: 'https://example.com/a' }, ',', 'strong']) + expect((parse('预览在 **https://example.com/a**(PR #1)').children[0] as Paragraph).children).toMatchObject([ + { value: '预览在 ' }, + { type: 'strong', children: [{ type: 'link', url: 'https://example.com/a' }] }, + { value: '(PR #1)' }, + ]) + }) + + it('keep letters, ASCII punctuation, and authored links', () => { + expect(phrasing(parse('https://zh.wikipedia.org/wiki/中文_(消歧义)?q=a,b ok'))).toEqual( + [{ link: 'https://zh.wikipedia.org/wiki/中文_(消歧义)?q=a,b' }, ' ok']) + expect(phrasing(parse(''))).toEqual([{ link: 'https://example.com/。a' }]) + expect(phrasing(parse('[link](https://example.com/。a)'))).toEqual([{ link: 'https://example.com/。a' }]) + }) +}) diff --git a/packages/client/ui-primitives/tests/markdown-dom-parity.client.spec.tsx b/packages/client/ui-primitives/tests/markdown-dom-parity.client.spec.tsx index 00af46ceab..9ff98f5817 100644 --- a/packages/client/ui-primitives/tests/markdown-dom-parity.client.spec.tsx +++ b/packages/client/ui-primitives/tests/markdown-dom-parity.client.spec.tsx @@ -171,6 +171,8 @@ const CORPUS: Record = { '', ' and bare autolink https://autolink.example.com literal.', '', + '中文 https://autolink.example.com/a。后文 ends before CJK punctuation.', + '', '[spaces encoded](https://example.com/a b)', '', '[![linked badge](https://example.com/badge.png)](https://example.com/target) image-only anchors carry no leading glyph.', diff --git a/packages/client/ui-sidebar-right/README.i18n.yaml b/packages/client/ui-sidebar-right/README.i18n.yaml index cedfe4f4e4..9ad6755db1 100644 --- a/packages/client/ui-sidebar-right/README.i18n.yaml +++ b/packages/client/ui-sidebar-right/README.i18n.yaml @@ -30,8 +30,8 @@ en: ef2af36136c4a919 zh: 48d992e2c923a130 /deepseek-ai-dsh-client-ui-sidebar-right/ctx-sidebarright: - en: 4519c4633d160c2a - zh: e4019a62532f885a + en: 228794a144cb4065 + zh: b0897365a1dfcd1e /deepseek-ai-dsh-client-ui-sidebar-right/the-tab-domain: en: 717b86e05b44ebdd zh: 0f4f694d0ea1d7f8 diff --git a/packages/client/ui-sidebar-right/README.md b/packages/client/ui-sidebar-right/README.md index 01fbf0a30e..ff666c6dd4 100644 --- a/packages/client/ui-sidebar-right/README.md +++ b/packages/client/ui-sidebar-right/README.md @@ -94,7 +94,7 @@ Two more seats extend what is already there: `sidebar.right.tab.guide` (chain) r `preferNewPane: true` first asks the target docked pane to split under the normal two-pane and room rules, then falls back to that pane when no split is available. `replaceTab` takes precedence and disables this preference. -`close(tabId)` closes a tab; `active()` reads the active tab. `isExpanded()` and `toggleExpanded()` read and drive the column's expansion; `toggleFullscreen(target)` applies the panel control’s display-mode action to a captured dock pane. Layout operations, for callers that arrange the column programmatically, each recorded like the gesture it stands in for: `focus(tabId)` focuses a tab and its pane; `split(paneId?)` splits a docked pane (the active one by default) under the same pane budget and room rule as the strip's control and returns the new pane's id, or `undefined` — recording nothing — when it cannot; `float(tabId, rect?)` takes a docked tab out into a panel; `dock(paneId)` returns a floating panel to the active docked pane. A tab or pane that does not exist, or already is where the call would put it, is left alone. The face exposes operations only: no layout snapshot, no operation log, no lookup by address. `_undo()` / `_redo()` step the on-screen surface's history; they are `@internal` — the sequence has no user-facing control, and these exist for tests. Commands act on the on-screen Session through its adopted store; with no Session on screen, or before the runtime mints its store, they throw rather than write into a surface nobody draws. `mounted` is the observable of the Session on screen: the selected Session while the Conversation fills the main column, `undefined` while a global panel replaces it or no Session is selected. The plugin derives it from the Session selection and `ctx.layout.panelInfo`, and it changes before React renders the change that causes it, so a component rendered in that commit reads the arriving Session and may act on it from its effects: the frame renders that Session's seat in the same pass, which mints its store. Seats never publish it; each reports only what it renders with: the room its docking kit measured, which `split` and `preferNewPane` apply to that Session's panes (unmeasured panes have room), and whether its frame width presents the panel fullscreen automatically, which the fullscreen command applies. The plan review's automatic open reads `mounted` through a bound hook. +`close(tabId)` closes a tab; `hide(tabId)` closes a floating tab or collapses its docked column; `active()` reads the active tab. `isExpanded()` and `toggleExpanded()` read and drive the column's expansion; `toggleFullscreen(target)` applies the panel control’s display-mode action to a captured dock pane. Layout operations, for callers that arrange the column programmatically, each recorded like the gesture it stands in for: `focus(tabId)` focuses a tab and its pane; `split(paneId?)` splits a docked pane (the active one by default) under the same pane budget and room rule as the strip's control and returns the new pane's id, or `undefined` — recording nothing — when it cannot; `float(tabId, rect?)` takes a docked tab out into a panel; `dock(paneId)` returns a floating panel to the active docked pane. A tab or pane that does not exist, or already is where the call would put it, is left alone. The face exposes operations only: no layout snapshot, no operation log, no lookup by address. `_undo()` / `_redo()` step the on-screen surface's history; they are `@internal` — the sequence has no user-facing control, and these exist for tests. Commands act on the on-screen Session through its adopted store; with no Session on screen, or before the runtime mints its store, they throw rather than write into a surface nobody draws. `mounted` is the observable of the Session on screen: the selected Session while the Conversation fills the main column, `undefined` while a global panel replaces it or no Session is selected. The plugin derives it from the Session selection and `ctx.layout.panelInfo`, and it changes before React renders the change that causes it, so a component rendered in that commit reads the arriving Session and may act on it from its effects: the frame renders that Session's seat in the same pass, which mints its store. Seats never publish it; each reports only what it renders with: the room its docking kit measured, which `split` and `preferNewPane` apply to that Session's panes (unmeasured panes have room), and whether its frame width presents the panel fullscreen automatically, which the fullscreen command applies. The plan review's automatic open reads `mounted` through a bound hook. `focusedTarget(element?)` resolves the current DOM pane and tab, including an embedding iframe; outside focus returns no page. `commandTarget(element?)` additionally allows an outside open to use the on-screen Session's active dock pane. Captures include the occurrence and navigation revision; `isTargetCurrent(target)` rejects changed Sessions, moved or reopened tabs, and intervening navigation. Focus and pointer activation remain separate from persisted layout selection. diff --git a/packages/client/ui-sidebar-right/README.zh.md b/packages/client/ui-sidebar-right/README.zh.md index 96f12af1a6..0fd0e54bb1 100644 --- a/packages/client/ui-sidebar-right/README.zh.md +++ b/packages/client/ui-sidebar-right/README.zh.md @@ -94,7 +94,7 @@ tab 类型分两阶段注册,随包发布的引导类型走的正是别的包 `preferNewPane: true` 会先按普通的两格上限与空间规则尝试分割目标停靠格,无法分栏时回退到该格。`replaceTab` 优先并会禁用这项偏好。 -`close(tabId)` 关闭一个 tab;`active()` 读取活动 tab。`isExpanded()` 与 `toggleExpanded()` 读取并驱动该列的展开;`toggleFullscreen(target)` 对捕获的停靠分栏执行面板控件的显示模式操作。布局操作供以编程方式安排该列的调用方使用,每个都像它替代的手势一样被记录:`focus(tabId)` 聚焦一个 tab 及其格;`split(paneId?)` 在与 tab 条控件相同的格预算与空间规则下分栏一个停靠格(默认活跃格),返回新格的 id,做不到时返回 `undefined`——且不记录任何东西;`float(tabId, rect?)` 把停靠 tab 浮出为浮窗;`dock(paneId)` 把浮窗放回活跃停靠格。不存在的 tab 或格、或已处于调用目标状态的,都原样不动。该接口只暴露操作:没有布局快照、没有操作日志、没有按地址查找。`_undo()` / `_redo()` 步进屏幕上停靠面的历史;它们是 `@internal`——序列没有面向用户的控件,这两个只为测试存在。命令通过屏幕上会话已采用的 store 作用于该会话;没有会话在屏幕上、或运行时尚未创建它的 store 时,命令 throw,而不是写进一个没人绘制的面里。`mounted` 是「屏幕上的会话」的可观察值:Conversation 占据主栏时为选中的会话,全局面板替代它或没有选中会话时为 `undefined`。插件从会话选择与 `ctx.layout.panelInfo` 推导它,它在 React 渲染引起变化的那次更新之前就已改变,因此同一次提交里渲染的组件读到的是到来的会话,并可以在自己的 effect 里对它行动:框架在同一轮渲染里渲染该会话的席位,席位随之创建它的 store。席位从不发布它;每个席位只上报它渲染时依据的事实:停靠套件测得的空间,`split` 与 `preferNewPane` 把它用于该会话的分栏(未测量的分栏视为有空间);以及框架宽度是否让面板自动全屏,全屏命令据此执行。计划审阅的自动打开通过绑定的 hook 读取 `mounted`。 +`close(tabId)` 关闭一个 tab;`hide(tabId)` 关闭浮动 tab 或收起其停靠列;`active()` 读取活动 tab。`isExpanded()` 与 `toggleExpanded()` 读取并驱动该列的展开;`toggleFullscreen(target)` 对捕获的停靠分栏执行面板控件的显示模式操作。布局操作供以编程方式安排该列的调用方使用,每个都像它替代的手势一样被记录:`focus(tabId)` 聚焦一个 tab 及其格;`split(paneId?)` 在与 tab 条控件相同的格预算与空间规则下分栏一个停靠格(默认活跃格),返回新格的 id,做不到时返回 `undefined`——且不记录任何东西;`float(tabId, rect?)` 把停靠 tab 浮出为浮窗;`dock(paneId)` 把浮窗放回活跃停靠格。不存在的 tab 或格、或已处于调用目标状态的,都原样不动。该接口只暴露操作:没有布局快照、没有操作日志、没有按地址查找。`_undo()` / `_redo()` 步进屏幕上停靠面的历史;它们是 `@internal`——序列没有面向用户的控件,这两个只为测试存在。命令通过屏幕上会话已采用的 store 作用于该会话;没有会话在屏幕上、或运行时尚未创建它的 store 时,命令 throw,而不是写进一个没人绘制的面里。`mounted` 是「屏幕上的会话」的可观察值:Conversation 占据主栏时为选中的会话,全局面板替代它或没有选中会话时为 `undefined`。插件从会话选择与 `ctx.layout.panelInfo` 推导它,它在 React 渲染引起变化的那次更新之前就已改变,因此同一次提交里渲染的组件读到的是到来的会话,并可以在自己的 effect 里对它行动:框架在同一轮渲染里渲染该会话的席位,席位随之创建它的 store。席位从不发布它;每个席位只上报它渲染时依据的事实:停靠套件测得的空间,`split` 与 `preferNewPane` 把它用于该会话的分栏(未测量的分栏视为有空间);以及框架宽度是否让面板自动全屏,全屏命令据此执行。计划审阅的自动打开通过绑定的 hook 读取 `mounted`。 `focusedTarget(element?)` 解析当前 DOM 分栏和标签页,也接受嵌入 iframe;焦点位于外部时不返回页面。`commandTarget(element?)` 另外允许外部打开动作使用屏幕上会话的活动停靠分栏。捕获值包含 occurrence 和导航版本;`isTargetCurrent(target)` 拒绝会话变更、已移动或重新打开的标签页,以及期间发生的导航。焦点和指针激活独立于持久化的布局选择。 diff --git a/packages/client/ui-sidebar-right/src/client/service.ts b/packages/client/ui-sidebar-right/src/client/service.ts index f3d90e7584..df9519d364 100644 --- a/packages/client/ui-sidebar-right/src/client/service.ts +++ b/packages/client/ui-sidebar-right/src/client/service.ts @@ -219,6 +219,11 @@ export interface ISidebarRight { * @param tabId - the tab to close. */ close(tabId: TabId): void + /** + * Hide a tab: close its floating occurrence, or collapse its docked column. + * @param tabId - the tab to hide; missing tabs are unchanged. + */ + hide(tabId: TabId): void /** * The active tab of the active pane. * @returns the record, or `undefined` with no Session on screen. @@ -457,6 +462,17 @@ export class SidebarRightController implements ISidebarRight { this.closeIn(this.require().sessionId, tabId) } + /** + * Hide a tab: close its floating occurrence, or collapse its docked column. + * @param tabId - the tab to hide; missing tabs are unchanged. + */ + hide(tabId: TabId): void { + const layout = this.mountedSurface()?.layout + if (layout?.tabs[tabId] === undefined) return + if (findTabPane(layout, tabId).host === 'float') this.close(tabId) + else if (layout.expanded) this.toggleExpanded() + } + /** * The active tab of the active pane. * @returns the record, or `undefined` without an on-screen surface. diff --git a/packages/client/ui-sidebar-right/tests/service.client.spec.ts b/packages/client/ui-sidebar-right/tests/service.client.spec.ts index 9dd8979cea..6125e1ae7c 100644 --- a/packages/client/ui-sidebar-right/tests/service.client.spec.ts +++ b/packages/client/ui-sidebar-right/tests/service.client.spec.ts @@ -624,11 +624,35 @@ describe('SidebarRightController — a tab\'s own actions', () => { }) }) +describe('SidebarRightController — hiding tabs', () => { + it('collapses a docked tab and closes a floating tab without toggling the dock', () => { + const { controller, publish, layout, tabOf } = harness() + publish() + controller.openResource('dsh-resource://file/session/s-test/a.txt') + const docked = tabOf('a.txt') + controller.hide(docked) + expect(layout().expanded).toBe(false) + expect(layout().tabs[docked]).toBeDefined() + controller.hide(docked) + expect(layout().expanded).toBe(false) + controller.openResource('dsh-resource://file/session/s-test/b.txt') + const floating = tabOf('b.txt') + controller.float(floating) + const expanded = layout().expanded + controller.hide(floating) + expect(layout().tabs[floating]).toBeUndefined() + expect(layout().expanded).toBe(expanded) + controller.hide(floating) + expect(layout().expanded).toBe(expanded) + }) +}) + describe('SidebarRightController — the readable slice', () => { it('answers for the no-session case rather than throwing', () => { const { controller } = harness() expect(controller.isExpanded()).toBe(false) expect(controller.active()).toBeUndefined() + controller.hide('missing' as TabId) }) it('reports the last committed surface', () => { diff --git a/packages/client/ui-theme/README.i18n.yaml b/packages/client/ui-theme/README.i18n.yaml index 1b86d7c1dd..2290e7fe26 100644 --- a/packages/client/ui-theme/README.i18n.yaml +++ b/packages/client/ui-theme/README.i18n.yaml @@ -18,8 +18,8 @@ en: 5a279269e31713c1 zh: dcd5c31fbdfd63e2 /deepseek-ai-dsh-client-ui-theme/use-this-package/appearance-and-font-size: - en: 7dc280d0e36a3e7d - zh: f7a75164db34cf0a + en: 7dff01929847efd1 + zh: ec04b4c4485aba96 /deepseek-ai-dsh-client-ui-theme/use-this-package/fonts: en: b45d5b48ca799c3a zh: af65514b3759204f @@ -33,8 +33,8 @@ en: 3eb9b0e3365b2db3 zh: c73acc55dd897266 /deepseek-ai-dsh-client-ui-theme/understand-the-implementation/stylesheets: - en: e0f0bce3e166e71a - zh: bf5a68e7f440aca0 + en: 925119bd420325b4 + zh: 384ef1f890b059da /deepseek-ai-dsh-client-ui-theme/understand-the-implementation/scrollbar-rebinding: en: bf76e7c34a331595 zh: 5accd0260ef7c61e diff --git a/packages/client/ui-theme/README.md b/packages/client/ui-theme/README.md index 2e65e8be04..c0c2916068 100644 --- a/packages/client/ui-theme/README.md +++ b/packages/client/ui-theme/README.md @@ -29,7 +29,7 @@ Users switch the color scheme, font sizes, and fonts from rows in Settings (Gene ### Appearance and font size -In narrow browser settings, the three Appearance choices share one row and font-size labels use a smaller gutter. Windows and macOS desktop windows retain wrapping choices and standard spacing. +In narrow browser settings, the three Appearance choices share one row and font-size labels use a smaller gutter. At viewport widths up to 767 CSS px, including narrow desktop windows, font-family rows stack each title, description, and full-width field vertically. Font-size steppers stay to the right of their labels. Wider viewports retain horizontal font-family rows. Windows and macOS desktop windows retain wrapping Appearance choices and standard font-size label spacing. Font-family fields use 16 px text on devices with a coarse primary pointer. The plugin registers Appearance preference cubes and the text font-size stepper in the General section. A More font settings button after the stepper expands a region below it, collapsed on every settings open, with a font row for each of text, code, and the terminal, plus the code and terminal font-size steppers. The text stepper accepts integer values from 10 through 22 px and defaults to 14 px. It changes conversation headings and base text by the same increment, including the user bubble and composer draft; flow-row titles, summaries, and tables follow one step under the body size, while small text keeps a fixed size. The code stepper (10–16 px, default 11) sets the code-block size; inline code stays 1 px larger, and the small tool-output variant uses the same size. The terminal stepper (10–20 px, default 13) sets the sidebar terminal cell font size; changing it keeps the shell and output and refits the grid. Each accepted change writes through the Host settings API. Rapid changes serialize in gesture order with namespace revisions, and a rejected latest write reloads the durable values. @@ -65,7 +65,7 @@ The style installer publishes `--dsh-hairline-width` on the document root and up `base.css` owns the shared radius scale, the font tokens, and settings-card material aliases. `--dsh-font-family-text-default` and `--dsh-font-family-code-default` hold the built-in stacks on `:root`; on `body`, `--dsw-font-family` and `--ds-font-family-code` place `--dsh-font-family-text` and `--dsh-font-family-code` ahead of those stacks, and an unset list resolves to the built-in stack (repeated once, which font matching ignores). The material aliases resolve on `body`, alongside the active palette. Follow [Web styling](../../../docs/web-styling.md#corner-radii-and-settings-cards) when choosing component radii. -`src/styles/` holds eight sheets imported in order by ui-theme's dynamic client entry: `base.css`, `corner-shape.css`, `design-platform.css`, `focus.css`, `onboarding.css`, `scrollbar.css`, `gradient-shadow-text.css`, and `shiki.css`. The client bundle compiles and injects them as plugin-owned global styles, so unload and HMR remove them with ui-theme. `scrollbar.css` consumes the `--dsw-alias-scrollbar-*` tokens and must follow `design-platform.css`, which declares them. Status marks use their own semantic state tokens. `--dsw-alias-bg-document-selection` uses blue-500 at 40% opacity in both themes for selections over original document colors. `design-platform.css` also owns the code-diff fill aliases and their static alpha palette entries, plus the `--dsw-alias-file-diff-*` code, gutter, and marker palette for file comparisons; `shiki.css` owns syntax colors. +`src/styles/` holds eight sheets imported in order by ui-theme's dynamic client entry: `base.css`, `corner-shape.css`, `design-platform.css`, `focus.css`, `onboarding.css`, `scrollbar.css`, `gradient-shadow-text.css`, and `shiki.css`. The client bundle compiles and injects them as plugin-owned global styles, so unload and HMR remove them with ui-theme. `scrollbar.css` consumes the `--dsw-alias-scrollbar-*` tokens and must follow `design-platform.css`, which declares them. Status marks use their own semantic state tokens; `--dsw-alias-state-done-primary` marks finished work, such as a merged change request, with violet-600 in the light palette and violet-400 in the dark palette. `--dsw-alias-bg-document-selection` uses blue-500 at 40% opacity in both themes for selections over original document colors. `design-platform.css` also owns the code-diff fill aliases and their static alpha palette entries, plus the `--dsw-alias-file-diff-*` code, gutter, and marker palette for file comparisons; `shiki.css` owns syntax colors. [`focus.css`](src/styles/focus.css) provides a `:focus-visible` fallback that names the ring colour through `var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary))` and the standard width through `--dsw-focus-ring-width`, never the outline style — so a control that disables its outline stays paintless, and one that declares no ring keeps the standard geometry instead of Chromium's `auto 1px`. The theme resolves this blue to `#4176E6` in light mode and `#7AAAFF` in dark mode. Component outlines and focus-ring shadows use the same colour expression, including rings on descendants and pseudo-elements. `--dsw-focus-ring-width` (2px) is the standard width; dense tables and toolbars may keep 1px, and offsets remain component-owned. diff --git a/packages/client/ui-theme/README.zh.md b/packages/client/ui-theme/README.zh.md index 1095fbc1c3..0b63fb239f 100644 --- a/packages/client/ui-theme/README.zh.md +++ b/packages/client/ui-theme/README.zh.md @@ -29,7 +29,7 @@ kind: "package-reference" ### 外观与字号 -窄屏浏览器设置中,三个外观选项同排显示,字号标签与控件的间距缩小;Windows 和 macOS 桌面窗口保留可换行选项和标准间距。 +窄屏浏览器设置中,三个外观选项同排显示,字号标签使用较小的间距。视口宽度不超过 767 CSS px 时(包括窄桌面窗口),字体输入行按标题、说明、输入框纵向排列,输入框占满可用宽度;字号步进器保持在标签右侧。更宽的视口保留字体输入行的横向排列。Windows 和 macOS 桌面窗口的外观选项保留换行,字号标签保留标准间距。主指针精度较低的设备上,字体输入框使用 16 px 文字。 插件在「通用」分区注册外观偏好方块与正文字号步进器。步进器后的「更多字体设置」按钮在其下方展开一个区域,每次打开设置时均为折叠状态,其中包含正文、代码与终端各自的字体行,以及代码与终端的字号步进器。正文步进器接受 10 至 22 px 的整数,默认值为 14 px。它以相同增量调整会话标题与基础文本,包括用户气泡与 composer 草稿;流内行的标题、摘要与表格跟随比正文低一档的字号,小号文本保持固定字号。代码步进器(10–16 px,默认 11)设置代码块字号;行内代码保持大 1 px,工具输出的小号变体使用相同字号。终端步进器(10–20 px,默认 13)设置侧栏终端的单元格字号;修改后保留 shell 与输出,并重新适配网格。每次通过的变更都经 Host settings API 写入。连续快速变更按操作顺序携带命名空间 revision 串行写入,最新写入被拒时重新加载持久值。 @@ -65,7 +65,7 @@ Desktop 的固定快捷键 `Cmd`/`Ctrl` + `+`(或 `=`)和 `Cmd`/`Ctrl` + `-` `base.css` 持有共享圆角尺度、字体 token 与设置卡片材质别名。`--dsh-font-family-text-default` 与 `--dsh-font-family-code-default` 在 `:root` 上保存内置字体栈;在 `body` 上,`--dsw-font-family` 与 `--ds-font-family-code` 把 `--dsh-font-family-text` 与 `--dsh-font-family-code` 排在这些字体栈之前,未设置的列表解析为内置字体栈(重复一次,字体匹配会忽略重复项)。材质别名在 `body` 上随当前色板解析。组件圆角选择遵循 [Web 样式参考](../../../docs/web-styling.zh.md#corner-radii-and-settings-cards)。 -`src/styles/` 下有八张样式表,由 ui-theme 的动态客户端 entry 依次导入:`base.css`、`corner-shape.css`、`design-platform.css`、`focus.css`、`onboarding.css`、`scrollbar.css`、`gradient-shadow-text.css` 与 `shiki.css`。客户端 bundle 将其编译并注入为插件持有的全局样式,因此卸载与 HMR(热模块替换)会随 ui-theme 一同移除。`scrollbar.css` 消费 `--dsw-alias-scrollbar-*` token,必须排在声明这些 token 的 `design-platform.css` 之后。状态标记使用各自的语义状态 token。`--dsw-alias-bg-document-selection` 在两种主题中均使用 40% 不透明度的 blue-500,供保留文档原色的选区使用。`design-platform.css` 还负责代码差异底色的别名及其静态透明度色阶,以及文件对比所用的 `--dsw-alias-file-diff-*` 代码区、行号区和标记配色;`shiki.css` 负责语法颜色。 +`src/styles/` 下有八张样式表,由 ui-theme 的动态客户端 entry 依次导入:`base.css`、`corner-shape.css`、`design-platform.css`、`focus.css`、`onboarding.css`、`scrollbar.css`、`gradient-shadow-text.css` 与 `shiki.css`。客户端 bundle 将其编译并注入为插件持有的全局样式,因此卸载与 HMR(热模块替换)会随 ui-theme 一同移除。`scrollbar.css` 消费 `--dsw-alias-scrollbar-*` token,必须排在声明这些 token 的 `design-platform.css` 之后。状态标记使用各自的语义状态 token;`--dsw-alias-state-done-primary` 标记已完成的工作(例如已合并的变更请求),浅色主题为 violet-600,深色主题为 violet-400。`--dsw-alias-bg-document-selection` 在两种主题中均使用 40% 不透明度的 blue-500,供保留文档原色的选区使用。`design-platform.css` 还负责代码差异底色的别名及其静态透明度色阶,以及文件对比所用的 `--dsw-alias-file-diff-*` 代码区、行号区和标记配色;`shiki.css` 负责语法颜色。 [`focus.css`](src/styles/focus.css) 提供 `:focus-visible` 兜底:通过 `var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary))` 声明焦点环颜色,并通过 `--dsw-focus-ring-width` 声明标准宽度,但不声明轮廓样式——因此禁用轮廓的控件仍然不绘制,而没有自有焦点环的控件保持标准几何,而不是落到 Chromium 的 `auto 1px`。主题将该蓝色解析为浅色模式的 `#4176E6` 和深色模式的 `#7AAAFF`。组件轮廓与焦点环阴影使用同一颜色表达式,包括后代和伪元素上的环。`--dsw-focus-ring-width`(2px)是标准宽度;密集表格与工具栏可以保留 1px,offset 仍由组件决定。 diff --git a/packages/client/ui-theme/src/client/FontFamilyRow.module.css b/packages/client/ui-theme/src/client/FontFamilyRow.module.css index c3ca4ff66e..f5498280b8 100644 --- a/packages/client/ui-theme/src/client/FontFamilyRow.module.css +++ b/packages/client/ui-theme/src/client/FontFamilyRow.module.css @@ -1,7 +1,18 @@ -/* Font-family rows reuse the font-size row cell (FontSizeRow.module.css); - * the fields share one basis so the three rows align, and shrink with the row. */ +/* Font-family fields; shared row text and separators live in FontSizeRow.module.css. */ .field { flex: 0 1 240px; min-width: 0; } + +@media (max-width: 767px) { + .field { + flex-basis: auto; + } +} + +@media (pointer: coarse) { + .field input { + font-size: 16px; + } +} diff --git a/packages/client/ui-theme/src/client/FontFamilyRow.tsx b/packages/client/ui-theme/src/client/FontFamilyRow.tsx index b14b9af034..58e76042b5 100644 --- a/packages/client/ui-theme/src/client/FontFamilyRow.tsx +++ b/packages/client/ui-theme/src/client/FontFamilyRow.tsx @@ -5,6 +5,7 @@ * an empty field restores the built-in stack. The field shows the persisted * list and resets whenever that list changes. */ +import clsx from 'clsx' import { Input } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' import { normalizeFontFamily, type FontRole } from '../theme-settings.ts' @@ -39,7 +40,7 @@ export function FontFamilyRow({ t, role, setFontFamily, useStore }: FontFamilyRo setFontFamily(input.value) } return ( -

+
{title}
{t(`fontFamily.${role}.description`)}
diff --git a/packages/client/ui-theme/src/client/FontSizeRow.module.css b/packages/client/ui-theme/src/client/FontSizeRow.module.css index 4676f34862..e5a26d5472 100644 --- a/packages/client/ui-theme/src/client/FontSizeRow.module.css +++ b/packages/client/ui-theme/src/client/FontSizeRow.module.css @@ -19,12 +19,21 @@ padding-right: 48px; } -/* Narrow settings panel: the 48px gutter separates the label from its control - across a wide row and only squeezes the label here. */ @media (max-width: 767px) { - :global(html:not([data-windows-titlebar]):not([data-platform='darwin'])) .rowText { + /* The wide-row gutter squeezes labels beside steppers in narrow browser panels. */ + :global(html:not([data-windows-titlebar]):not([data-platform='darwin'])) .row:not(.stacked) .rowText { padding-right: 16px; } + + .stacked { + flex-direction: column; + align-items: stretch; + } + + .stacked .rowText { + flex: none; + padding-right: 0; + } } .title { diff --git a/packages/client/ui-theme/src/styles/design-platform.css b/packages/client/ui-theme/src/styles/design-platform.css index dcdbb74d87..a30485495f 100644 --- a/packages/client/ui-theme/src/styles/design-platform.css +++ b/packages/client/ui-theme/src/styles/design-platform.css @@ -83,6 +83,8 @@ body { --dsw-static-red-600: rgb(236, 19, 19); --dsw-static-red-600-a08: rgb(236 19 19 / 8%); --dsw-static-red-900: rgb(87, 12, 12); + --dsw-static-violet-400: rgb(167, 139, 250); + --dsw-static-violet-600: rgb(124, 58, 237); } body[data-ds-dark-theme] { @@ -167,6 +169,8 @@ body[data-ds-dark-theme] { --dsw-static-red-600: rgb(236, 19, 19); --dsw-static-red-600-a08: rgb(236 19 19 / 8%); --dsw-static-red-900: rgb(87, 12, 12); + --dsw-static-violet-400: rgb(167, 139, 250); + --dsw-static-violet-600: rgb(124, 58, 237); } body { @@ -259,6 +263,7 @@ body { --dsw-alias-file-diff-deleted-bg: rgb(252, 230, 226); --dsw-alias-file-diff-deleted-gutter: rgb(253, 236, 233); --dsw-alias-file-diff-deleted-marker: rgb(186, 39, 35); + --dsw-alias-state-done-primary: var(--dsw-static-violet-600); --dsw-alias-state-error-primary: var(--dsw-static-red-600); --dsw-alias-state-error-secondary: var(--dsw-static-red-400); --dsw-alias-state-idle-primary: var(--dsw-static-neutral-300); @@ -382,6 +387,7 @@ body[data-ds-dark-theme] { --dsw-alias-file-diff-deleted-bg: rgb(60, 31, 27); --dsw-alias-file-diff-deleted-gutter: rgb(40, 19, 14); --dsw-alias-file-diff-deleted-marker: rgb(250, 66, 62); + --dsw-alias-state-done-primary: var(--dsw-static-violet-400); --dsw-alias-state-error-primary: var(--dsw-static-red-400); --dsw-alias-state-error-secondary: var(--dsw-static-red-400); --dsw-alias-state-idle-primary: var(--dsw-static-neutral-600); diff --git a/packages/core/tool-executor-ptc/README.i18n.yaml b/packages/core/tool-executor-ptc/README.i18n.yaml index a056d5ebf3..d99f3ac777 100644 --- a/packages/core/tool-executor-ptc/README.i18n.yaml +++ b/packages/core/tool-executor-ptc/README.i18n.yaml @@ -21,8 +21,8 @@ en: 9d4f61c351945d30 zh: 1c575526230b0e02 /deepseek-ai-dsh-tool-executor-ptc/understand-the-implementation/ptc-mode: - en: a87a9af0deeddabe - zh: a4cef6f43a3b1422 + en: 48f90d4c78ba69d7 + zh: 5dcbbf23ebc1b803 /deepseek-ai-dsh-tool-executor-ptc/further-exploration: en: ee69078c38c655a9 zh: fb0873407f23c8c5 diff --git a/packages/core/tool-executor-ptc/README.md b/packages/core/tool-executor-ptc/README.md index 833a798623..fa20171fa9 100644 --- a/packages/core/tool-executor-ptc/README.md +++ b/packages/core/tool-executor-ptc/README.md @@ -59,6 +59,8 @@ New sub-calls use `:ptc:` ids. Consumers treat these ids as opaque an Successful image-bearing subcall results become deferred user-message context with `source.kind` set to `ptc-mode`. Other additional contexts retain their producing tool's attribution. +When the caller cancels and the runtime reports `abort`, `run_code` waits for cleanup and returns the native tool cancellation result: `AbortError` / `ABORTED`, with `Error: tool call aborted` as its content. The Agent records the specific cancellation cause in `turn/end`, including a hook's reason. Other runtime failures retain `CODE_RUN_FAILED` and their diagnostics, even if the caller also cancels. Cancellation can discard retained runtime state and does not undo completed tool effects. + `run_code` accepts `timeoutMs` when the mounted runtime supports an override; its schema reports the configured default and maximum, and the runtime's usage instructions. The Node default is 120,000 ms with a 600,000 ms cap, including nested tool and approval waits. A wider `sandbox_permissions` mode requires a non-empty `justification` and approval before the program starts. The schema asks the model to write that reason in the current user's language. The grant applies to that complete execution; standing Session policy and nested tools retain their own authority. Programs are never replayed automatically: inspect earlier effects before explicitly retrying a denied program. Retained PTC environments follow the standing file policy. PTC observes committed Session mode changes and immediately cancels affected calls in all Node modes. Later PTC calls wait for their nested-tool cleanup before resolving the latest policy. The runtime owns process invalidation and cleanup; permission setters remain synchronous. One-time escalations omit the state owner and complete in a temporary environment; standing variables remain available to ordinary calls. - [`src/index.ts`](src/index.ts) — Validated configuration and scoped registration; tool descriptions and batch execution. diff --git a/packages/core/tool-executor-ptc/README.zh.md b/packages/core/tool-executor-ptc/README.zh.md index c1b3f6cf20..2f56dbcb12 100644 --- a/packages/core/tool-executor-ptc/README.zh.md +++ b/packages/core/tool-executor-ptc/README.zh.md @@ -59,6 +59,8 @@ PTC 执行器公开保留的 `run_code` 传输以及按所加载运行时语言 成功且包含图片的子调用结果会成为延后的 user-message 上下文,其 `source.kind` 为 `ptc-mode`。其他 additional context 保留其生产工具的归属。 +调用方取消且运行时报告 `abort` 时,`run_code` 等待清理完成,并返回原生工具的取消结果:`AbortError` / `ABORTED`,内容为 `Error: tool call aborted`。Agent 在 `turn/end` 中记录具体取消原因,包括 hook 的理由。其他运行时失败保留 `CODE_RUN_FAILED` 及其诊断,即使调用方同时取消。取消可能丢弃保留的运行时状态,也不会撤销已完成的工具操作。 + 当已挂载运行时支持覆盖时,`run_code` 接受 `timeoutMs`;其 schema 报告配置的默认值和上限以及运行时使用说明。Node 默认值为 120,000 ms,上限为 600,000 ms,包含嵌套工具和审批等待。更宽的 `sandbox_permissions` 模式要求非空 `justification`,并在程序启动前获得审批。schema 提示模型使用当前用户的语言撰写理由。授权仅用于该次完整执行;常驻 Session 策略与嵌套工具保留各自权限。程序不会自动重放:显式重试被拒程序前,应检查先前已发生的效果。 保留状态的 PTC 环境遵循会话文件策略。PTC 观察已提交的 Session 模式变化,并立即取消所有 Node 模式下受影响的调用。后续 PTC 调用等待这些调用的嵌套工具清理后,再解析最新策略。运行时负责进程失效和清理,权限设置保持同步。单次提权省略状态所有者,在临时环境中执行;普通调用仍可访问原环境的变量。 - [`src/index.ts`](src/index.ts) — 配置校验和作用域注册;工具描述和批次执行。 diff --git a/packages/core/tool-executor-ptc/src/errors.ts b/packages/core/tool-executor-ptc/src/errors.ts index 67d468e9d0..c2dcd8ec45 100644 --- a/packages/core/tool-executor-ptc/src/errors.ts +++ b/packages/core/tool-executor-ptc/src/errors.ts @@ -3,7 +3,8 @@ import { HarnessError } from '@deepseek-ai/dsh-llm' /** * Thrown by `run_code` when the program run itself failed — a program - * exception, a budget expiry, an abort, or substrate death. Extends + * exception, a budget expiry, a runtime-local abort, or substrate death. Caller + * cancellation uses the shared `AbortError` / `ABORTED` tool outcome. Extends * {@link HarnessError} (`code: 'CODE_RUN_FAILED'`); the registry's execution * pipeline converts it into a structured `isError` result whose text carries * the failure kind plus the captured logs, so the model can self-correct. diff --git a/packages/core/tool-executor-ptc/src/ptc.ts b/packages/core/tool-executor-ptc/src/ptc.ts index d23a37b471..346837f460 100644 --- a/packages/core/tool-executor-ptc/src/ptc.ts +++ b/packages/core/tool-executor-ptc/src/ptc.ts @@ -9,7 +9,7 @@ import type { PtcDispatchLog } from './runtime-types.ts' import { symbols, type Context } from '@deepseek-ai/cordis' import { brandString } from '@deepseek-ai/dsh-brand' -import { createUserMessage } from '@deepseek-ai/dsh-llm' +import { createUserMessage, HarnessError } from '@deepseek-ai/dsh-llm' import type { ContentBlock, ToolCallId, ToolSchema } from '@deepseek-ai/dsh-llm' import type { PtcBindingFunction, PtcRunResult, PtcRunSandbox, PtcRuntime } from '@deepseek-ai/dsh-ptc-runtime' @@ -19,7 +19,7 @@ import type {} from '@deepseek-ai/dsh-user-approval' import type {} from '@deepseek-ai/dsh-working-directory' import type {} from '@deepseek-ai/dsh-sandbox-policy' import { deepFreeze, snapshotJsonValue, type JsonValue } from '@deepseek-ai/dsh-util-values' -import { defineTool, parameterSchemaSpecToJsonSchema, RUN_CODE_NAME } from '@deepseek-ai/dsh-tools' +import { defineTool, parameterSchemaSpecToJsonSchema, RUN_CODE_NAME, TOOL_ABORTED } from '@deepseek-ai/dsh-tools' import type { ToolExecutionPipeline } from '@deepseek-ai/dsh-tools/pipeline' import { toolErrorMessage } from '@deepseek-ai/dsh-tools/pipeline' import { scopeTarget } from '@deepseek-ai/dsh-scope' @@ -694,6 +694,11 @@ export function createRunCodeTool( } if (result.error) { + if (result.error.kind === 'abort' && exec.signal.aborted) { + const error = new HarnessError('tool call aborted', TOOL_ABORTED) + error.name = 'AbortError' + throw error + } const logsText = result.logs.length > 0 ? `\nCaptured output:\n${result.logs.join('\n')}` : '' const sandboxText = result.sandbox === undefined ? '' : `\nFile sandbox: ${result.sandbox.mode}${result.sandbox.enforcement === undefined ? '' : `; enforcement: ${result.sandbox.enforcement}`}${result.sandbox.denied ? '; operation denied' : ''}.` diff --git a/packages/core/tool-executor-ptc/tests/ptc.spec.ts b/packages/core/tool-executor-ptc/tests/ptc.spec.ts index 942434e44b..99c9dd38c1 100644 --- a/packages/core/tool-executor-ptc/tests/ptc.spec.ts +++ b/packages/core/tool-executor-ptc/tests/ptc.spec.ts @@ -14,11 +14,11 @@ import { createScope } from '@deepseek-ai/dsh-scope' import type { Scope } from '@deepseek-ai/dsh-scope' import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt' import { PtcRuntime } from '@deepseek-ai/dsh-ptc-runtime' -import type { PtcRunRequest, PtcRunResult } from '@deepseek-ai/dsh-ptc-runtime' -import ToolRuntime, { RUN_CODE_NAME, TOOL_ABORTED_BEFORE_DISPATCH, defineTool } from '@deepseek-ai/dsh-tools' +import type { PtcRunFailure, PtcRunRequest, PtcRunResult } from '@deepseek-ai/dsh-ptc-runtime' +import ToolRuntime, { RUN_CODE_NAME, TOOL_ABORTED, TOOL_ABORTED_BEFORE_DISPATCH, defineTool } from '@deepseek-ai/dsh-tools' import { defineContentToolFixture } from '@deepseek-ai/dsh-tool-testkit' import type { JsonSchemaNode, PostToolDecision, ToolExecutionResult } from '@deepseek-ai/dsh-tools' -import type { Agent } from '@deepseek-ai/dsh-agent' +import type { Agent, AgentCancelCause } from '@deepseek-ai/dsh-agent' import SessionStore, { Session, SessionId, SessionSeq } from '@deepseek-ai/dsh-session' import ApprovalService, { type ApprovalOutcome, type ApprovalRequest } from '@deepseek-ai/dsh-user-approval' import type { SessionEventMap } from '@deepseek-ai/dsh-session' @@ -1602,7 +1602,9 @@ describe('the run_code dispatch bridge', () => { it('aborting the outer signal aborts the in-flight sub-dispatch and abandons queued ones', async () => { const { ctx, runtime } = await setup({ mode: 'ptc' }) + onTestFinished(async () => { await ctx.fiber.dispose() }) const seen: string[] = [] + const entered = Promise.withResolvers() let sawAbort = false ctx.tools.register(defineContentToolFixture({ name: 'slow', @@ -1611,8 +1613,8 @@ describe('the run_code dispatch bridge', () => { async execute(args, exec) { seen.push(args.id) await new Promise((resolve) => { - const timer = setTimeout(resolve, 500) - exec.signal.addEventListener('abort', () => { sawAbort = true; clearTimeout(timer); resolve() }, { once: true }) + exec.signal.addEventListener('abort', () => { sawAbort = true; resolve() }, { once: true }) + entered.resolve(undefined) }) return [{ type: 'text' as const, text: args.id }] }, @@ -1621,7 +1623,8 @@ describe('the run_code dispatch bridge', () => { runtime.behavior = async (request) => { const tools = request.bindings[0]!.functions const calls = [tools.slow!({ id: 'first' }).catch(() => 'rejected'), tools.slow!({ id: 'second' }).catch(() => 'rejected')] - setTimeout(() => { controller.abort('user-cancel') }, 50) + await entered.promise + controller.abort('user-cancel') await Promise.all(calls) // A real runtime would be terminated by the abort; the fake honors the // contract by reporting the abort as the run failure. @@ -1629,7 +1632,7 @@ describe('the run_code dispatch bridge', () => { } const result = await runCode(ctx, 'program', { signal: controller.signal }) expect(result.isError).toBe(true) - expect((result.content[0] as { text: string }).text).toContain('code run failed (abort)') + expect(result.error).toEqual({ message: 'tool call aborted', info: { name: 'AbortError', code: TOOL_ABORTED } }) expect(seen).toEqual(['first']) expect(sawAbort).toBe(true) }) @@ -2045,6 +2048,60 @@ describe('the run_code dispatch bridge', () => { expect(calls).toEqual([]) }) + describe.each(['typescript', 'python'])('%s caller cancellation', (language) => { + it.each([ + { kind: 'user' }, + { kind: 'parent' }, + { kind: 'hook', reason: 'policy stopped the turn' }, + { kind: 'disposed' }, + undefined, + ])('maps reason %j to the native tool abort result', async (cause) => { + const { ctx, runtime } = await setup({ mode: 'ptc', runtime: { language } }) + onTestFinished(async () => { await ctx.fiber.dispose() }) + const controller = new AbortController() + runtime.behavior = async (request) => { + if (cause === undefined) controller.abort() + else controller.abort(cause) + expect(request.signal?.reason).toBe(controller.signal.reason) + const reason: unknown = request.signal?.reason + const message = reason instanceof Error ? reason.message : String(reason) + return { logs: [], error: { kind: 'abort', message } } + } + + const result = await runCode(ctx, 'program', { signal: controller.signal }) + + expect(result).toEqual({ + content: [{ type: 'text', text: 'Error: tool call aborted' }], + isError: true, + error: { + message: 'tool call aborted', + info: { name: 'AbortError', code: TOOL_ABORTED }, + }, + }) + }) + }) + + it.each<[boolean, PtcRunFailure]>([ + [false, { kind: 'abort', message: 'sandbox mode changed' }], + [true, { kind: 'worker-exit', message: 'managed process cleanup failed' }], + [true, { kind: 'timeout', message: 'execution deadline reached' }], + ])('keeps runtime failure details with caller cancellation %s: %j', async (cancelled, failure) => { + const { ctx, runtime } = await setup({ mode: 'ptc' }) + onTestFinished(async () => { await ctx.fiber.dispose() }) + const controller = new AbortController() + runtime.behavior = async () => { + if (cancelled) controller.abort({ kind: 'user' }) + return { logs: ['got this far'], error: failure } + } + + const result = await runCode(ctx, 'program', { signal: controller.signal }) + + expect(result.error).toEqual({ + message: `code run failed (${failure.kind}): ${failure.message}\nCaptured output:\ngot this far`, + info: { name: 'CodeRunFailedError', code: 'CODE_RUN_FAILED' }, + }) + }) + it('a tool/ptc-dispatch event never derives a model message', () => { const session = Session.create(SessionId('ptc-derive')) session.append('user/message', createUserMessage({ diff --git a/packages/experimental/README.i18n.yaml b/packages/experimental/README.i18n.yaml index 188724214d..b430030da4 100644 --- a/packages/experimental/README.i18n.yaml +++ b/packages/experimental/README.i18n.yaml @@ -18,11 +18,11 @@ en: b55aeb927737b90d zh: 0b215fcb5c2c7908 /packages-experimental/packages: - en: e33fba56551dea3e - zh: 9a926b22d9994215 + en: b1f5913dd5384887 + zh: 512a73927d199675 /packages-experimental/related-documentation: - en: 1cb9d3448d44e48d - zh: cd326d70a746b896 + en: 40797d4849455966 + zh: bb91a68dd995887c /packages-experimental/dev-note: en: 5de1d6304e00c58f zh: 8d8225361cfa29e5 diff --git a/packages/experimental/README.md b/packages/experimental/README.md index 9a74c95bed..db46606a73 100644 --- a/packages/experimental/README.md +++ b/packages/experimental/README.md @@ -95,6 +95,11 @@ Promotion requires a named product role, documented public behavior and limitati | [`webworker-packer`](webworker-packer/README.md) | Builds the gzip-compressed VFS image consumed by the browser worker preview | library and CLI — no ctx key | | [`webworker-runtime`](webworker-runtime/README.md) | Runs the harness plugin tree inside a dedicated browser worker | library and worker entry — no ctx key | | [`worktree`](worktree/README.md) | Creates a named Git worktree and changes the calling Session directory | `ctx.worktrees` | +| [`forge`](forge/README.md) | Code host provider registry with shared change request polling | `ctx.forge` | +| [`forge-github`](forge-github/README.md) | GitHub provider that reads pull requests through the host GitHub CLI | registers a provider on `ctx.forge` | +| [`forge-session`](forge-session/README.md) | Binds change requests to the Sessions that created, pushed, or tracked them | `ctx.forgeSession` | +| [`client-ui-forge`](client-ui-forge/README.md) | GUI panel for a Session's Git checkout, worktrees, and bound pull requests | `ctx.forgePanel` | +| [`forge-bundle`](forge-bundle/README.md) | Default-disabled pull request and worktree panel composition | — | ----- @@ -103,6 +108,7 @@ Promotion requires a named product role, documented public behavior and limitati - [Experimental publication reference](../../scripts/experimental-package-policy.ts) — public defaults and private exceptions. - [Worktrees](../../docs/subsystems/worktrees.md) — explicit branch and checkout creation. +- [Forges](../../docs/subsystems/forge.md) — change request providers, status polling, Session bindings, and the panel. - [Computer use](../../docs/subsystems/computer-use.md) — desktop provider choices. - [Browser use](../../docs/subsystems/browser-use.md) — browser provider choices and Session ownership. - [Agent Teams subsystem](../../docs/subsystems/agent-team.md) — durable Team types and the `ctx.agentTeams` service API. diff --git a/packages/experimental/README.zh.md b/packages/experimental/README.zh.md index 67e1093b3e..3b4474b8d0 100644 --- a/packages/experimental/README.zh.md +++ b/packages/experimental/README.zh.md @@ -95,6 +95,11 @@ kind: "package-group" | [`webworker-packer`](webworker-packer/README.zh.md) | 构建浏览器 worker 预览所消费的 gzip 压缩虚拟文件系统(VFS)镜像 | 库与 CLI(命令行界面),不使用 ctx key | | [`webworker-runtime`](webworker-runtime/README.zh.md) | 在专用浏览器 worker 中运行 harness 插件树 | 库与 worker 入口,不使用 ctx key | | [`worktree`](worktree/README.zh.md) | 创建具名 Git 工作树并改变调用 Session 的当前目录 | `ctx.worktrees` | +| [`forge`](forge/README.zh.md) | 代码托管提供方注册表,共享变更请求轮询 | `ctx.forge` | +| [`forge-github`](forge-github/README.zh.md) | 通过宿主 GitHub CLI 读取 pull request 的 GitHub 提供方 | 在 `ctx.forge` 上注册提供方 | +| [`forge-session`](forge-session/README.zh.md) | 把变更请求绑定到创建、推送或跟踪它们的 Session | `ctx.forgeSession` | +| [`client-ui-forge`](client-ui-forge/README.zh.md) | 展示 Session 的 Git 检出、worktree 与已绑定 pull request 的 GUI 面板 | `ctx.forgePanel` | +| [`forge-bundle`](forge-bundle/README.zh.md) | 默认禁用的 pull request 与 worktree 面板组合 | — | ----- @@ -103,6 +108,7 @@ kind: "package-group" - [实验包发布说明](../../scripts/experimental-package-policy.ts)——默认公开与私有例外。 - [工作树](../../docs/subsystems/worktrees.zh.md)——显式创建分支与检出目录。 +- [Forges](../../docs/subsystems/forge.zh.md)——变更请求提供方、状态轮询、Session 绑定与面板。 - [计算机操作](../../docs/subsystems/computer-use.zh.md)——桌面提供方选择。 - [浏览器操作](../../docs/subsystems/browser-use.zh.md)——浏览器提供方选择与 Session 所有权。 - [Agent Teams 子系统](../../docs/subsystems/agent-team.zh.md)——持久 Team 类型与 `ctx.agentTeams` 服务 API。 diff --git a/packages/experimental/client-ui-forge/README.i18n.yaml b/packages/experimental/client-ui-forge/README.i18n.yaml new file mode 100644 index 0000000000..806ba0bdb9 --- /dev/null +++ b/packages/experimental/client-ui-forge/README.i18n.yaml @@ -0,0 +1,37 @@ +# Bilingual-pair consistency record for README.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/experimental/client-ui-forge/README.md +/: + en: 27cf01b13f177106 + zh: c4b56ad7dfe7b1c7 +/deepseek-ai-dsh-experimental-client-ui-forge: + en: 8ca3d802a44c6fd1 + zh: 2723c9e28ecea7f7 +/deepseek-ai-dsh-experimental-client-ui-forge/summary: + 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: 8e0486572ab1076b + zh: 35a619b956c722a4 +/deepseek-ai-dsh-experimental-client-ui-forge/understand-the-implementation: + en: 1c41ae08ae498fa7 + zh: 7335562db3fa9557 +/deepseek-ai-dsh-experimental-client-ui-forge/further-exploration: + en: fa4d8c579ffc49ff + zh: 5856ed7469e4585b +/deepseek-ai-dsh-experimental-client-ui-forge/model-experience: + 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: a2f1864d15c5f9f6 + zh: a42f98cc27e1adfc +/deepseek-ai-dsh-experimental-client-ui-forge/known-limitations-and-deferred-work/dev-note: + en: e67f633dc6fe7773 + zh: 3bb0c20c93f16f98 diff --git a/packages/experimental/client-ui-forge/README.md b/packages/experimental/client-ui-forge/README.md new file mode 100644 index 0000000000..7ce1b2c8bc --- /dev/null +++ b/packages/experimental/client-ui-forge/README.md @@ -0,0 +1,135 @@ +--- +description: "GUI panel for the change requests a Session creates, and a worktree option for new Sessions, with the Host Remote that serves them." +kind: "package-reference" +--- + +# @deepseek-ai/dsh-experimental-client-ui-forge + +English | [中文](README.zh.md) + +## Summary + +This optional two-face package shows the change requests a Session created or pushed and their changes, and lets a new Session start in its own Git worktree. The Host half is the `forgePanel` Remote: it inspects the Session's checkout read-only, 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 + +- [Use this package](#use-this-package) +- [Understand the implementation](#understand-the-implementation) +- [Further Exploration](#further-exploration) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + +
+## Use this package + +Enable the [forge bundle](../forge-bundle/README.md) on the Plugins page; it composes this package with the forge registry, the GitHub provider, the Session bindings, and its own [worktree service](../worktree/README.md). + +Panel text follows the application language: Chinese, English, Japanese, Spanish, French, German, Russian, or Portuguese. + +- **Worktree option** — on the New Session screen, Worktree sits at the right end of the row with the workspace and mode pickers. Checking it shows the base branch, the current branch by default; the menu lists recent local branches. The option is disabled, with the reason in its tooltip, outside a Git checkout, in a read-only Session, and when the checkout or its Git directory lies outside the Session's writable root, as for a project that is itself a linked worktree. Sending the first message hands it to the Session at once, and it waits there while the branch `dsh--<4 random hexadecimal digits>` is created from the base under the repository's `.agents/worktrees/` and its checkout becomes the Session's working directory; the message's turn starts once the worktree exists. +- **Creation card** — while the worktree is created, a card above the composer shows the new branch and its two steps, creating the branch and checking out files; More details adds the branch and base. Cancel stops the attempt. After a stopped or failed attempt the message keeps waiting, and the card shows the reason of a failure: Retry tries again with a new branch name, and Continue without a worktree withdraws the request so the message goes on in the current checkout. +- **Removed worktree** — when the worktree the Session works in has been removed outside the Session, a card above the composer says so, and a message sent meanwhile waits without starting its turn until the worktree is restored or left; the card then says that the message goes on afterwards. Restore worktree, also offered as Restore in the tab, checks the branch out again at the same directory, or creates the branch again at the commit it started from when the branch is gone too; while it runs, the card follows its two steps, preparing the worktree and checking out files, and the tab's Restore turns into a spinner. Continue in the project moves the Session to the source checkout. Meanwhile the tab's first row shows a warning glyph. +- **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. Files are named before their directories, which use smaller subdued type and shorten first; hovering one shows its complete path, and both paths for a renamed file. Each file shows badges for a new, deleted, or renamed file, the last naming the old file name, or the old path when only the directory changed, and, in the Branch scope, an untracked one, and a menu that copies its path and opens the whole file in the sidebar for local changes or its page at the head in the browser for a change request. Unchanged lines between hunks collapse into rows that expand from the file's new side: the working tree, the index for Staged, or the head commit through the code host; a file that changed after the diff was read stays collapsed until the view reads again. A binary file and an untracked file over `maxDiffBytes` show a note instead of their lines, a file whose hunks draw more than 1,000 lines starts as one collapsed strip that names its changed lines and draws them when opened, and a file below files that change more than 5,000 lines starts collapsed. The view reads again on Refresh and the page refresh shortcut, on each navigation, when the change request's head moves, and when the Branch scope's summary changes; a change request's view first waits for the status that names its head, unless no provider serves it or that poll failed. While a read replaces changes already shown, the Refresh icon turns. A change request whose diff the code host or its provider refuses over a size limit, such as a GitHub diff over 300 files, says its changes are too large to show here and offers View in browser; another failed read offers Retry and, for a change request, View in browser. A change request's diff is downloaded once per head commit and base branch while it stays cached, so reopening, refreshing, and navigating at the same head read nothing from the host. + +`gitCommand`, `gitTimeoutMs`, `graceMs`, `maxOutputBytes`, `readinessTimeoutMs`, `maxBranches`, `worktreePrefix`, `maxDiffBytes`, `maxUntrackedFiles`, and `maxCachedDiffBytes` are listed in the [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-experimental-client-ui-forge). + +----- + + +## Understand the implementation + +
+Implementation internals — click to expand + +`ForgePanelController` keeps one `SessionPanel` per followed Session and disposes it when the last follower leaves. A panel starts with the Session's working directory and bindings, re-inspects after each `working-directory/change`, `tool/result`, and `sandbox/mode` event, and publishes a new `ForgePanelState` for every change. Each change request keeps the latest snapshot a poll returned while later polls fail. A failed parallel Git read cancels and joins its siblings. Inspections run one at a time with one trailing rerun, and a result for a directory the Session has since left is dropped. The code host comes from the remotes in order: the upstream's remote, then `origin`, then the rest. When an inspection finds an upstream branch on a remote a provider recognizes, the panel asks `ctx.forgeSession.discoverBranch` to bind that branch's open change requests, once per remote and branch until a refresh. A sign-in probe runs when the recognized host changes and on refresh, within `readinessTimeoutMs`; a refresh settles once the inspection and the probe it started have ended, and poll results arrive later. + +Inspection runs `rev-parse --show-toplevel`, `rev-parse --path-format=absolute --git-common-dir`, `rev-parse --path-format=absolute --git-dir`, `symbolic-ref --short HEAD`, `rev-parse --short HEAD`, `rev-parse --abbrev-ref @{upstream}`, and `remote -v`; `branches` runs `symbolic-ref` and `for-each-ref --count=`. 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 `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. + +Each inspection also decides whether the worktree is missing: the Session's directory lies inside the created worktree, `ctx.fs.lstat` finds no directory there, and `show-ref` in the source repository tells whether the branch still exists. + +`localDiff` takes the fork point from the created worktree's `baseCommit` when the Session works in it, else, for each remote in the code host order, from `symbolic-ref --quiet --short refs/remotes//HEAD` and `merge-base HEAD`; without one the `branch` scope refuses with `forgePanel/no-fork-point`. It runs `diff --no-ext-diff --no-textconv --no-color -M` with fixed `a/` and `b/` prefixes and `core.quotePath=false` against the fork point, against HEAD (the empty tree before the first commit), against the index, or with `--cached` against HEAD. Untracked files, at most `maxUntrackedFiles` with the rest counted as omitted, come from `ls-files --others --exclude-standard -z` and are read through `ctx.fs` as new files, binary when their first 8,000 bytes hold a NUL byte or they are not UTF-8. A patch, file list, or file over `maxDiffBytes` refuses with `forgePanel/diff-too-large`, and a directory outside a checkout with `forgePanel/not-a-checkout`. The Host parses patches with jsdiff into files and hunks, so the browser receives no raw patch. `localText` reads the new side of one file, from the index through `show :` for Staged and through `ctx.fs` otherwise, and refuses a path that leaves the checkout. `changeRequestDiff` and `changeRequestText` act only on a visible binding a provider serves, else they refuse with `forgePanel/not-bound`; they read the head commit of the binding's latest status through the provider's `diff` and `fileText`, refuse a `too-large` failure with `forgePanel/diff-too-large`, and refuse with `forgePanel/diff-failed` before a status was read or when the host refuses otherwise. The Host keeps the patches `changeRequestDiff` read, one per change request at its head commit and base branch, and drops the least recently read once they exceed `maxCachedDiffBytes`; a later read at the same head and base answers from memory for any Session, and a failed read is not kept. The commits the checkout has beyond the head come from `symbolic-ref`, `cat-file -e`, and `rev-list --count`, and are null when the checked-out branch is not the head branch, the head commit is absent, or Git fails. + +`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//local` and `dsh-resource://forge-changes//change/`, name the source, and a navigation may name a file to scroll to. Its body keeps the scope and layout choices of each tab in a slot store, reads through the Remote, and highlights lines with the shared code highlighter; a chip title shows its name with the changes icon. + +| Source | Responsibility | +|---|---| +| [`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 | +| [`src/local-diff.ts`](src/local-diff.ts) | Fork point, local scopes, new-side text, and the `branch` scope summary | +| [`src/client/mount.ts`](src/client/mount.ts) | Remote mount, slot entries, and action outcome mapping | +| [`src/client/ChangeRequestCard.tsx`](src/client/ChangeRequestCard.tsx) | Reusable change request card | +| [`src/client/LocalChangesCard.tsx`](src/client/LocalChangesCard.tsx) | Local changes card | +| [`src/client/ChangesTab.tsx`](src/client/ChangesTab.tsx) | Changes view | +| [`src/client/DiffFile.tsx`](src/client/DiffFile.tsx) | One file of the changes view | +| [`src/client/FileLabel.tsx`](src/client/FileLabel.tsx) | Changed file label: name, then directory | +| [`src/client/WorktreeToggle.tsx`](src/client/WorktreeToggle.tsx) | New Session worktree option | +| [`src/client/WorktreeProgress.tsx`](src/client/WorktreeProgress.tsx) | Worktree creation and removed-worktree card | +| [`src/client/SessionRowForge.tsx`](src/client/SessionRowForge.tsx) | Session row icons | +| [`src/client/SessionHoverForge.tsx`](src/client/SessionHoverForge.tsx) | Session row hover card section | + +
+ +----- + + +## Further Exploration + +- [Forge subsystem](../../../docs/subsystems/forge.md) — change request types and service reference. +- [Session bindings](../forge-session/README.md) — how change requests bind to a Session. +- [Worktree service](../worktree/README.md) — how a worktree is created and entered. + +----- + + +## 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, the Session list, and the rest of the panel add nothing to model requests. + +#### KV Cache effect + +None for a new worktree, as its notice belongs to the first request of the Session, before any cached prefix exists. A notice after Continue in the project and the message Create change request sends are appended after the cached prefix. + +## Known Limitations and Deferred Work + + + +- **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. +- **Waiting messages** — while a message waits, the Agent stays idle in maintenance and the composer offers no stop; the card's actions, the Agent's cancellation, or the Remote's stop release it, and callers that wait for the Agent to become idle wait as well. +- **Restoring** — restoring recreates the checkout from the branch, or from the commit the branch started from when the branch is gone; changes that were never committed are lost with the removed directory. The [worktree service](../worktree/README.md#use-this-package) validates the recorded target and removes only its missing, unlocked registration. An existing or incomplete directory stays blocked; preserve its files and remove the directory before retrying, or continue in the source project. +- **Linked-worktree projects** — the option is unavailable when the project is itself a linked worktree: under workspace-write its Git directory lies outside the writable root, so neither the new branch nor later commits could be written. +- **Changes scopes** — deferred: scopes for one commit and for the last turn. The Branch scope needs a remote whose default branch HEAD shares history with, or a worktree the Session created. +- **Large changes** — a local patch over `maxDiffBytes` shows no files, and a change request diff over the size limit of the host or its provider shows no files and offers only View in browser. Without wrapping, the side-by-side view cuts long lines at the column edge. +- **Review page** — deferred: a left-navigation page that lists the user's change requests from the selected provider; the [decision note](../../../.agents/notes/implemented/feature/2026-10-09-forge-panel.md) records where it attaches. + + +### Dev Note + +
+Working context for maintainers — click to expand + +None. + +
diff --git a/packages/experimental/client-ui-forge/README.zh.md b/packages/experimental/client-ui-forge/README.zh.md new file mode 100644 index 0000000000..e5539f9860 --- /dev/null +++ b/packages/experimental/client-ui-forge/README.zh.md @@ -0,0 +1,135 @@ +--- +description: "展示 Session 创建的变更请求、并让新 Session 以 worktree 开始的 GUI 面板,以及为它们提供数据的宿主 Remote。" +kind: "package-reference" +--- + +# @deepseek-ai/dsh-experimental-client-ui-forge + +[English](README.md) | 中文 + +## 概述 + +这个可选的双端包展示 Session 创建或推送的变更请求及其变更,并让新 Session 在自己的 Git worktree 中开始。宿主端是 `forgePanel` Remote:它以只读方式检查 Session 的检出,检查代码托管平台的登录状态,为单个 Session 或会话列表以流的形式推送[已绑定变更请求](../forge-session/README.zh.md)及其轮询状态,读取 diff,并在第一条消息于 Session 的收件箱中等待时创建所请求的 worktree。客户端增加一个侧边栏标签页、一个变更视图、标题栏中的入口、一个新会话选项、一张创建卡片、会话列表中的图标,以及 toast 提示。 + +## 目录 + +- [使用本包](#use-this-package) +- [了解实现](#understand-the-implementation) +- [深入探索](#further-exploration) +- [模型体验](#model-experience) +- [已知限制与待办工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 使用本包 + +在插件页启用 [forge 套件](../forge-bundle/README.zh.md);它把本包与 forge 注册表、GitHub 提供方、Session 绑定以及它自己的 [worktree 服务](../worktree/README.zh.md)组合在一起。 + +面板文案跟随应用语言,支持中文、英文、日语、西班牙语、法语、德语、俄语和葡萄牙语。 + +- **工作树选项**——在新会话页面上,“工作树”位于工作区与模式选择器那一行的右端。勾选后显示基准分支,默认为当前分支;菜单列出最近的本地分支。不在 Git 检出中、Session 为只读,或检出及其 Git 目录不在 Session 的可写根内(例如项目本身就是链接 worktree)时,该选项不可用,提示中说明原因。发送第一条消息后,消息立即交给 Session 并在那里等待,同时从基准分支在仓库的 `.agents/worktrees/` 下创建分支 `dsh--<4 位随机十六进制数>`,并把它的检出设为 Session 的工作目录;worktree 建好后这条消息的这一轮才开始。 +- **创建卡片**——创建 worktree 期间,输入框上方的卡片显示新分支以及“创建分支”“检出文件”两个步骤;“更多详情”会列出分支与基准。“取消”停止这次尝试。尝试被停止或失败后,消息继续等待,失败时卡片显示原因:“重试”换一个新分支名再试;“不使用工作树”撤回请求,消息在当前检出中继续发送。 +- **工作树已被删除**——Session 所在的 worktree 在 Session 之外被删除后,输入框上方的卡片会说明这一点;此期间发送的消息会等待、不开始这一轮,直到该 worktree 被恢复或离开,卡片也会说明消息之后会接着发送。“恢复工作树”(标签页中的“恢复”与之相同)在原目录重新检出该分支;分支也已删除时,从它开始时的提交重新创建分支;恢复期间卡片显示“准备工作树”“检出文件”两个步骤,标签页中的“恢复”变为加载状态。“在项目目录继续”把 Session 移回源检出。此期间标签页顶部一行显示警示图标。 +- **标题栏按钮**——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 多出几个提交。工具栏可以跳到某个文件、汇总变更行数、刷新,并在左右对比与单栏对比、自动换行与截断长行之间切换。文件名在前,目录在后,用更小的浅色字显示,空间不够时目录先缩短;悬停时显示完整路径,重命名的文件显示新旧两条路径。每个文件带有新文件、已删除、重命名的标记(重命名标记写出原文件名,只换了目录时写出原路径),“分支”范围下的未跟踪文件还标“未提交”;文件菜单可以复制路径,本地变更可在侧边栏打开整个文件,变更请求可在浏览器中查看该文件在 head 处的页面。hunk 之间未变更的行折叠成一行,点击后从文件的新版本展开:工作树、“已暂存”时为暂存区,或经托管平台读取的 head 提交;读取 diff 之后文件又有修改时不会展开,直到视图重新读取。二进制文件与超过 `maxDiffBytes` 的未跟踪文件只显示一条说明;hunk 超过 1,000 行的文件先显示为一条写出变更行数的折叠条,点击后才绘制;前面的文件累计变更超过 5,000 行后,其后的文件默认收起。刷新与页面刷新快捷键、每次导航、变更请求的 head 变化,以及“分支”范围的摘要变化时,视图都会重新读取;变更请求的视图会先等到给出 head 的状态再读取,除非没有提供方服务它或那次轮询失败。重新读取已显示的变更时,刷新图标会旋转。托管平台或其提供方因超过大小上限拒绝给出 diff 的变更请求(例如 GitHub 上超过 300 个文件的 diff)显示“变更太大,无法在这里显示”并提供“在浏览器中查看”;其他读取失败提供“重试”,变更请求还提供“在浏览器中查看”。变更请求的 diff 在缓存期间对每个 head 提交与目标分支只下载一次,因此在同一 head 下重新打开、刷新与导航都不会再从托管平台读取。 + +`gitCommand`、`gitTimeoutMs`、`graceMs`、`maxOutputBytes`、`readinessTimeoutMs`、`maxBranches`、`worktreePrefix`、`maxDiffBytes`、`maxUntrackedFiles` 与 `maxCachedDiffBytes` 列在[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-experimental-client-ui-forge)中。 + +----- + + +## 了解实现 + +
+实现细节——点击展开 + +`ForgePanelController` 为每个被关注的 Session 保留一个 `SessionPanel`,最后一个关注方离开时销毁它。面板从 Session 的工作目录与绑定开始,在每个 `working-directory/change`、`tool/result` 与 `sandbox/mode` 事件之后重新检查,每次变化都发布新的 `ForgePanelState`。每个变更请求在后续轮询失败时保留最近一次轮询得到的快照。并行 Git 读取失败时会取消并等待同批读取结束。检查一次只运行一个,最多再追加一次;Session 已离开的目录的结果会被丢弃。代码托管平台按远端顺序识别:先是上游所在的远端,然后是 `origin`,最后是其余远端。检查发现提供方识别的远端上有上游分支时,面板调用 `ctx.forgeSession.discoverBranch` 绑定该分支打开状态的变更请求;同一远端与分支在刷新前只查找一次。识别出的托管平台变化时以及刷新时,会在 `readinessTimeoutMs` 内检查登录状态;一次刷新在它触发的检查与登录状态检查结束后完成,轮询结果之后才到达。 + +检查运行 `rev-parse --show-toplevel`、`rev-parse --path-format=absolute --git-common-dir`、`rev-parse --path-format=absolute --git-dir`、`symbolic-ref --short HEAD`、`rev-parse --short HEAD`、`rev-parse --abbrev-ref @{upstream}` 与 `remote -v`;`branches` 运行 `symbolic-ref` 与 `for-each-ref --count=`。之后,检查会为变更卡片统计 `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` 记录生效。该投影还从 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 目录必须匹配记录的工作树。插件卸载会取消并等待所有创建、恢复与消息等待任务,然后释放面板读取器和投影。 + +每次检查还会判断 worktree 是否缺失:Session 的目录位于所创建的 worktree 内,`ctx.fs.lstat` 在那里找不到目录,再由源仓库中的 `show-ref` 判断分支是否还在。 + +Session 在所创建的 worktree 中工作时,`localDiff` 以它的 `baseCommit` 为分叉点;否则按代码托管远端的顺序,对每个远端依次运行 `symbolic-ref --quiet --short refs/remotes//HEAD` 与 `merge-base HEAD` 找出分叉点;找不到时 `branch` 范围以 `forgePanel/no-fork-point` 拒绝。它以固定的 `a/`、`b/` 前缀和 `core.quotePath=false` 运行 `diff --no-ext-diff --no-textconv --no-color -M`,分别与分叉点、HEAD(第一次提交之前为空树)、暂存区比较,或带 `--cached` 与 HEAD 比较。未跟踪文件由 `ls-files --others --exclude-standard -z` 列出,最多 `maxUntrackedFiles` 个,其余计为未列出;它们经 `ctx.fs` 读取并作为新文件加入,前 8,000 字节含 NUL 字节或不是 UTF-8 时视为二进制。补丁、文件列表或文件超过 `maxDiffBytes` 时以 `forgePanel/diff-too-large` 拒绝,目录不在检出中时以 `forgePanel/not-a-checkout` 拒绝。宿主用 jsdiff 把补丁解析为文件与 hunk,浏览器不会收到原始补丁。`localText` 读取一个文件的新版本:“已暂存”经 `show :` 从暂存区读取,其余经 `ctx.fs` 读取;离开检出的路径会被拒绝。`changeRequestDiff` 与 `changeRequestText` 只作用于该 Session 中由提供方服务的可见绑定,否则以 `forgePanel/not-bound` 拒绝;它们经提供方的 `diff` 与 `fileText` 读取绑定最新状态中的 head 提交,`too-large` 失败以 `forgePanel/diff-too-large` 拒绝,尚未读到状态或托管平台以其他原因拒绝时以 `forgePanel/diff-failed` 拒绝。Host 保留 `changeRequestDiff` 读到的补丁,每个变更请求一份,按 head 提交与目标分支区分,总量超过 `maxCachedDiffBytes` 时丢弃最久未读的;之后在同一 head 与目标分支下的读取不论来自哪个 Session 都从内存返回,读取失败的结果不保留。本地分支比 head 多出的提交数由 `symbolic-ref`、`cat-file -e` 与 `rev-list --count` 得出;当前检出分支不是源分支、本地没有 head 提交或 Git 失败时为 null。 + +`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//local` 与 `dsh-resource://forge-changes//change/` 指明来源,导航参数可以指定要滚动到的文件。它的正文用 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 记录 | +| [`src/local-diff.ts`](src/local-diff.ts) | 分叉点、本地范围、新版本文本与 `branch` 范围摘要 | +| [`src/client/mount.ts`](src/client/mount.ts) | Remote 挂载、slot 条目与操作结果映射 | +| [`src/client/ChangeRequestCard.tsx`](src/client/ChangeRequestCard.tsx) | 可复用的变更请求卡片 | +| [`src/client/LocalChangesCard.tsx`](src/client/LocalChangesCard.tsx) | 变更卡片 | +| [`src/client/ChangesTab.tsx`](src/client/ChangesTab.tsx) | 变更视图 | +| [`src/client/DiffFile.tsx`](src/client/DiffFile.tsx) | 变更视图中的一个文件 | +| [`src/client/FileLabel.tsx`](src/client/FileLabel.tsx) | 变更文件的标签:文件名在前,目录在后 | +| [`src/client/WorktreeToggle.tsx`](src/client/WorktreeToggle.tsx) | 新会话的工作树选项 | +| [`src/client/WorktreeProgress.tsx`](src/client/WorktreeProgress.tsx) | worktree 创建卡片与 worktree 缺失卡片 | +| [`src/client/SessionRowForge.tsx`](src/client/SessionRowForge.tsx) | 会话行图标 | +| [`src/client/SessionHoverForge.tsx`](src/client/SessionHoverForge.tsx) | 会话行悬浮卡片中的部分 | + +
+ +----- + + +## 深入探索 + +- [Forge 子系统](../../../docs/subsystems/forge.zh.md)——变更请求类型与服务参考。 +- [Session 绑定](../forge-session/README.zh.md)——变更请求如何绑定到 Session。 +- [Worktree 服务](../worktree/README.zh.md)——worktree 如何创建并进入。 + +----- + + +## 模型体验 + +间接影响,经由工作目录服务与 Session 的对话:关于 Session 新 worktree 的通知会加入第一次请求的消息,“在项目目录继续”会为下一次请求排入同类通知,“创建变更请求”会发送一条请 Agent 创建变更请求的用户消息。检查、状态、diff、恢复、会话列表与面板的其余部分都不会给模型请求增加内容。 + +#### KV Cache 影响 + +新 worktree 的通知没有影响,因为它属于 Session 的第一次请求,那时还没有任何缓存前缀。“在项目目录继续”之后的通知与“创建变更请求”发送的消息都追加在缓存前缀之后。 + +## 已知限制与待办工作 + + + +- **只在显示时轮询**——只有会话列表的某一行或本包为该 Session 挂载了某个界面时才会轮询状态;其间检查结束不会有通知。 +- **会话列表中未打开的 Session**——会话列表从缓存的摘要中读取未加载 Session 的 worktree 与绑定:没有缓存摘要的 Session 两者都不显示;此版本之前最后使用的 Session 要打开之后才显示其 worktree。 +- **检查触发时机**——检出只在 Session 的工具结果与目录变化之后检查;Session 之外的改动要等到刷新或下一个工具结果才会出现。 +- **保留检出**——面板只在 Session 的第一步之前创建 worktree,不删除已有的检出目录或分支;失败或被取消的尝试可能留下分支或检出。分支名取自 Session id 与随机数字,而不是 Session 标题,因为那时标题还不存在。 +- **一轮进行中的消息**——Agent 正在运行一轮或其他维护任务时进入的消息不会被暂留:所请求的 worktree 改为在第一步创建,失败时这一轮结束且不带这条消息;已删除的 worktree 不会被检查,工作目录服务会把 Session 移回项目目录。 +- **等待中的消息**——消息等待期间,Agent 以空闲状态处于维护任务中,输入框不提供停止;只有卡片上的操作、Agent 的取消或 Remote 的停止能放行它,等待 Agent 空闲的调用方也会一并等待。 +- **恢复**——恢复会从分支重新检出;分支也已删除时,从它开始时的提交重新创建。从未提交的修改随被删除的目录一起丢失。[worktree 服务](../worktree/README.zh.md#use-this-package)校验记录的目标,只移除该目标缺失且未锁定的登记。已有或未恢复完成的目录仍会阻止恢复;请先保存其中的文件并移除目录再重试,或在源项目继续。 +- **链接 worktree 项目**——项目本身是链接 worktree 时该选项不可用:在 workspace-write 下它的 Git 目录位于可写根之外,新分支与之后的提交都无法写入。 +- **变更范围**——待办:单个提交与上一轮的范围。“分支”范围需要一个默认分支与 HEAD 有共同历史的远端,或 Session 创建的 worktree。 +- **大型变更**——本地补丁超过 `maxDiffBytes` 时不显示任何文件;超过托管平台或其提供方大小上限的变更请求 diff 不显示任何文件,只提供“在浏览器中查看”。不自动换行时,左右对比会在栏边截断长行。 +- **代码审查页**——待办:在左侧导航中新增一个页面,列出用户在所选提供方上的变更请求;[决策笔记](../../../.agents/notes/implemented/feature/2026-10-09-forge-panel.zh.md)记录了它的接入位置。 + + +### 开发备注 + +
+维护者的工作上下文——点击展开 + +无。 + +
diff --git a/packages/experimental/client-ui-forge/locale/de.json b/packages/experimental/client-ui-forge/locale/de.json new file mode 100644 index 0000000000..c374fc16b8 --- /dev/null +++ b/packages/experimental/client-ui-forge/locale/de.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Bereich für Änderungsanfragen", + "description": "Zeigt die Änderungsanfragen einer Sitzung in einem Tab der Seitenleiste an und lässt eine neue Sitzung in einem Git-Arbeitsbaum beginnen." + } +} diff --git a/packages/experimental/client-ui-forge/locale/en.json b/packages/experimental/client-ui-forge/locale/en.json new file mode 100644 index 0000000000..b3e49c65ad --- /dev/null +++ b/packages/experimental/client-ui-forge/locale/en.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Change request panel", + "description": "Shows a Session's change requests in a sidebar tab and lets a new Session start in a worktree." + } +} diff --git a/packages/experimental/client-ui-forge/locale/es.json b/packages/experimental/client-ui-forge/locale/es.json new file mode 100644 index 0000000000..32ef8c6852 --- /dev/null +++ b/packages/experimental/client-ui-forge/locale/es.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Panel de solicitudes de cambio", + "description": "Muestra las solicitudes de cambio de una sesión en una pestaña de la barra lateral y permite que una nueva sesión comience en un árbol de trabajo." + } +} diff --git a/packages/experimental/client-ui-forge/locale/fr.json b/packages/experimental/client-ui-forge/locale/fr.json new file mode 100644 index 0000000000..1d90c7d069 --- /dev/null +++ b/packages/experimental/client-ui-forge/locale/fr.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Panneau des demandes de modification", + "description": "Affiche les demandes de modification d'une session dans un onglet de la barre latérale et permet à une nouvelle session de démarrer dans un arbre de travail." + } +} diff --git a/packages/experimental/client-ui-forge/locale/ja.json b/packages/experimental/client-ui-forge/locale/ja.json new file mode 100644 index 0000000000..08b4583711 --- /dev/null +++ b/packages/experimental/client-ui-forge/locale/ja.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "変更リクエストパネル", + "description": "セッションの変更リクエストをサイドバーのタブに表示し、新しいセッションをワークツリーで開始できるようにします。" + } +} diff --git a/packages/experimental/client-ui-forge/locale/pt.json b/packages/experimental/client-ui-forge/locale/pt.json new file mode 100644 index 0000000000..b8cc548b53 --- /dev/null +++ b/packages/experimental/client-ui-forge/locale/pt.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Painel de solicitações de alteração", + "description": "Mostra as solicitações de alteração de uma sessão em uma guia da barra lateral e permite que uma nova sessão comece em uma árvore de trabalho." + } +} diff --git a/packages/experimental/client-ui-forge/locale/ru.json b/packages/experimental/client-ui-forge/locale/ru.json new file mode 100644 index 0000000000..848b93f2cb --- /dev/null +++ b/packages/experimental/client-ui-forge/locale/ru.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Панель запросов на изменение", + "description": "Показывает запросы на изменение сеанса во вкладке боковой панели и позволяет начать новый сеанс в рабочем дереве." + } +} diff --git a/packages/experimental/client-ui-forge/locale/zh.json b/packages/experimental/client-ui-forge/locale/zh.json new file mode 100644 index 0000000000..341221743c --- /dev/null +++ b/packages/experimental/client-ui-forge/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "变更请求面板", + "description": "在侧栏标签页中显示会话的变更请求,并让新会话可以在工作树中开始。" + } +} diff --git a/packages/experimental/client-ui-forge/package.json b/packages/experimental/client-ui-forge/package.json new file mode 100644 index 0000000000..951a947996 --- /dev/null +++ b/packages/experimental/client-ui-forge/package.json @@ -0,0 +1,126 @@ +{ + "name": "@deepseek-ai/dsh-experimental-client-ui-forge", + "description": "Sidebar panel for a Session's change requests, with a worktree option for new Sessions and its Host Remote", + "version": "0.2.1-rc.3", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/experimental/client-ui-forge" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./remote": { + "types": "./lib/typert.remote-client.d.ts", + "default": "./lib/typert.remote-client.js" + }, + "./typert": { + "types": "./lib/typert.host.d.ts", + "default": "./lib/typert.host.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json", + "./types": { + "types": "./lib/types/types.d.ts" + } + }, + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-api-remotes", + "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-ui-conversation", + "@deepseek-ai/dsh-client-ui-layout", + "@deepseek-ai/dsh-client-ui-sidebar-right", + "@deepseek-ai/dsh-client-ui-workspace" + ], + "platform": "web" + } + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-experimental-worktree": "workspace:*", + "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/dsh-timeout": "workspace:*", + "@deepseek-ai/dsh-experimental-forge": "workspace:*", + "@deepseek-ai/dsh-fs": "workspace:*" + }, + "dependencies": { + "@deepseek-ai/schemastery": "workspace:~", + "@deepseek-ai/dsh-util-values": "workspace:*", + "diff": "^9.0.0", + "zod": "^4.4.3", + "@deepseek-ai/dsh-typert-protocol": "workspace:*", + "@deepseek-ai/dsh-storage-domain": "workspace:*" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-agent": "workspace:*", + "@deepseek-ai/dsh-agent-loop-testkit": "workspace:*", + "@deepseek-ai/dsh-api-remotes": "workspace:*", + "@deepseek-ai/dsh-api-session-controller": "workspace:*", + "@deepseek-ai/dsh-client-locale": "workspace:*", + "@deepseek-ai/dsh-client-store": "workspace:*", + "@deepseek-ai/dsh-client-test-runtime": "workspace:*", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:*", + "@deepseek-ai/dsh-client-ui-layout": "workspace:*", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:*", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:*", + "@deepseek-ai/dsh-client-ui-session": "workspace:*", + "@deepseek-ai/dsh-client-ui-sidebar-right": "workspace:*", + "@deepseek-ai/dsh-client-ui-slots": "workspace:*", + "@deepseek-ai/dsh-client-ui-workspace": "workspace:*", + "@deepseek-ai/dsh-experimental-forge": "workspace:*", + "@deepseek-ai/dsh-experimental-forge-session": "workspace:*", + "@deepseek-ai/dsh-experimental-worktree": "workspace:*", + "@deepseek-ai/dsh-fs": "workspace:*", + "@deepseek-ai/dsh-fs-local": "workspace:*", + "@deepseek-ai/dsh-llm": "workspace:*", + "@deepseek-ai/dsh-sandbox": "workspace:*", + "@deepseek-ai/dsh-sandbox-local": "workspace:*", + "@deepseek-ai/dsh-sandbox-policy": "workspace:*", + "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/dsh-session-projection": "workspace:*", + "@deepseek-ai/dsh-subprocess": "workspace:*", + "@deepseek-ai/dsh-subprocess-local": "workspace:*", + "@deepseek-ai/dsh-system-prompt": "workspace:*", + "@deepseek-ai/dsh-util-workspace-path": "workspace:*", + "@deepseek-ai/dsh-working-directory": "workspace:*", + "@testing-library/react": "^16.3.3", + "clsx": "^2.1.1", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "@deepseek-ai/dsh-timeout": "workspace:*", + "@deepseek-ai/dsh-session-projection-cache": "workspace:*", + "@deepseek-ai/dsh-session-query": "workspace:*", + "@deepseek-ai/dsh-api-remote-access": "workspace:*" + }, + "files": [ + "locale/*.json", + "lib/index.js", + "lib/client.js", + "lib/types/**/*.d.ts", + "lib/typert.host.js", + "lib/typert.host.d.ts", + "lib/typert.remote-client.js", + "lib/typert.remote-client.d.ts" + ] +} diff --git a/packages/experimental/client-ui-forge/src/client/ActionToast.tsx b/packages/experimental/client-ui-forge/src/client/ActionToast.tsx new file mode 100644 index 0000000000..c2430c2f8d --- /dev/null +++ b/packages/experimental/client-ui-forge/src/client/ActionToast.tsx @@ -0,0 +1,38 @@ +/** App-wide notice for forge panel actions, rendered outside the panel so it outlives it. */ + +import type { ReactNode } from 'react' +import { IconWarningOutlineRegular, Toast } from '@deepseek-ai/dsh-client-ui-primitives' +import type { InjectFace, PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' +import type { NS } from './locales.ts' +import type { NoticeSource } from './source.ts' + +/** Full-opacity hold of a notice that offers to undo its action, long enough to reach the action. */ +const UNDO_HOLD_MS = 6000 + +/** Full props of the `shell.overlay` entry. */ +export type ActionToastProps = PropsLocale & InjectFace> + +/** + * Render the current notice: a success notice with its undo action, or a failure warning. + * @param props - notice hook, dismissal, and copy. + * @returns the toast, or nothing while no notice shows. + */ +export function ActionToast({ useNotice, dismiss, t }: ActionToastProps): ReactNode { + const notice = useNotice(current => current) + if (notice === null) return null + const { undo } = notice + return ( + }} + {...undo === undefined + ? {} + : { + holdMs: UNDO_HOLD_MS, + actions: [{ label: t('toast.undo'), onClick: () => { dismiss(); undo() } }], + }} + onDone={dismiss} + /> + ) +} diff --git a/packages/experimental/client-ui-forge/src/client/ChangeRequestCard.tsx b/packages/experimental/client-ui-forge/src/client/ChangeRequestCard.tsx new file mode 100644 index 0000000000..615d637e18 --- /dev/null +++ b/packages/experimental/client-ui-forge/src/client/ChangeRequestCard.tsx @@ -0,0 +1,500 @@ +/** + * One change request as a card: its title links to the host, a one-line + * summary shows while collapsed, and while expanded one row per fact (checks, + * review, merge state, and the changed lines, which open its changes) above + * the ready-for-review or merge action. + */ + +import { useState, type ReactNode } from 'react' +import clsx from 'clsx' +import { + Button, IconChevronDownOutlineRegular, IconChevronRightOutlineRegular, IconCloseOutlineRegular, IconCopyOutlineRegular, + IconListPenOutlineRegular, IconRightUpOutlineRegular, IconUserOutlineRegular, Menu, MenuItemButton, StateDot, Tooltip, + type MenuEntry, +} from '@deepseek-ai/dsh-client-ui-primitives' +import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' +import type { + ChangeRequestCheck, ChangeRequestSnapshot, CheckState, ChecksRollup, ForgeFailure, MergeMethod, MergeRequest, +} from '@deepseek-ai/dsh-experimental-forge/types' +import type { ForgePanelChangeRequest } from '../types.ts' +import { formatAgo, formatDuration } from './format.ts' +import { ChangeRequestGlyph, CheckGlyph } from './glyphs.tsx' +import type { ForgePanelKey, NS } from './locales.ts' +import { checkCounts, checkDuration, ciSummary, flagOf, numberOf, TONE_LABEL, toneOf } from './status.ts' +import css from './ForgeTab.module.css' + +type Translate = PropsLocale['t'] + +/** What a card can do; a surface without a Session binding omits `remove`, `markReady`, `merge`, and `viewChanges`. */ +export interface ChangeRequestActions { + /** Open a URL outside the app. */ + readonly openLink: (url: string) => void + /** Copy a URL to the clipboard. */ + readonly copyLink: (url: string) => void + /** Read the status again after a failed read. */ + readonly retry: () => void + /** Remove the change request from its Session. */ + readonly remove?: ((entry: ForgePanelChangeRequest) => void) | undefined + /** Mark a draft ready for review; settles once the outcome is reported. */ + readonly markReady?: ((entry: ForgePanelChangeRequest) => Promise) | undefined + /** Merge the change request as `request` describes; settles once the outcome is reported. */ + readonly merge?: ((entry: ForgePanelChangeRequest, request: MergeRequest) => Promise) | undefined + /** Open the changes view of the change request. */ + readonly viewChanges?: ((entry: ForgePanelChangeRequest) => void) | undefined +} + +/** Props of one change request card. */ +export interface ChangeRequestCardProps { + /** Change request with its latest snapshot and polling result. */ + readonly entry: ForgePanelChangeRequest + /** Whether the details show. */ + readonly expanded: boolean + /** Show or hide the details. */ + readonly onToggle: () => void + /** Current epoch milliseconds for relative times. */ + readonly now: number + /** Card actions. */ + readonly actions: ChangeRequestActions + /** Active UI language for numeric formatting. */ + readonly locale: string + /** Forge panel copy. */ + readonly t: Translate +} + +const REVIEW: Record = { + 'approved': 'change.review.approved', + 'changes-requested': 'change.review.changesRequested', + 'review-required': 'change.review.required', + 'none': 'change.review.none', +} + +const FAILURE: Record = { + 'not-found': 'change.failure.notFound', + 'unauthenticated': 'change.failure.unauthenticated', + 'too-large': 'change.failure.unavailable', + 'unavailable': 'change.failure.unavailable', +} + +const ROLLUP: Record = { + none: 'neutral', + pending: 'pending', + success: 'success', + failure: 'failure', +} + +const CHECK_RANK: Record = { + failure: 0, pending: 1, success: 2, neutral: 3, skipped: 3, cancelled: 3, +} + +function failureOf(entry: ForgePanelChangeRequest): ForgeFailure | undefined { + return entry.update !== null && 'failure' in entry.update ? entry.update.failure : undefined +} + +/** Failed checks first, then running ones, then the rest, each group in host order. */ +function orderedChecks(checks: readonly ChangeRequestCheck[]): ChangeRequestCheck[] { + return checks.toSorted((left, right) => CHECK_RANK[left.state] - CHECK_RANK[right.state]) +} + +function checkTime(check: ChangeRequestCheck, t: Translate): string { + if (check.state === 'pending') return t(check.startedAt === null ? 'change.check.queued' : 'change.check.running') + const elapsed = checkDuration(check) + return elapsed === undefined ? '' : formatDuration(elapsed, t) +} + +function DiffSize({ snapshot, locale }: { readonly snapshot: ChangeRequestSnapshot; readonly locale: string }): ReactNode { + return ( + + {`+${snapshot.additions.toLocaleString(locale)}`} + {`−${snapshot.deletions.toLocaleString(locale)}`} + + ) +} + +/** The changed lines, as a row that opens the changes view when the provider serves the change request. */ +function ChangesRow({ entry, snapshot, name, actions, locale, t }: { + readonly entry: ForgePanelChangeRequest + readonly snapshot: ChangeRequestSnapshot + readonly name: string + readonly actions: ChangeRequestActions + readonly locale: string + readonly t: Translate +}): ReactNode { + const { viewChanges } = actions + const parts = ( + <> + + {t('change.row.changes')} + + + ) + if (viewChanges === undefined || entry.ref === null) return
{parts}
+ return ( + + ) +} + +/** The merged or closed fact with its time, such as 已合并到 main · 2 小时前. */ +function finished(snapshot: ChangeRequestSnapshot, now: number, t: Translate): string { + const state = snapshot.state === 'merged' ? t('change.mergedTo', { base: snapshot.baseBranch }) : t('change.state.closed') + const ago = snapshot.closedAt === null ? undefined : formatAgo(snapshot.closedAt, now, t) + return ago === undefined ? state : t('time.finished', { state, time: ago }) +} + +/** Collapsed status after the number: checks and the most urgent flag, the merge or close time, or a read in progress. */ +function Summary({ snapshot, now, t }: { + readonly snapshot: ChangeRequestSnapshot | null + readonly now: number + readonly t: Translate +}): ReactNode { + if (snapshot === null) return + if (snapshot.state !== 'open') { + return {finished(snapshot, now, t)} + } + const ci = ciSummary(snapshot) + const flag = flagOf(snapshot) + return ( + <> + + {ci.kind !== 'none' && } + {ci.kind === 'failed' + ? ci.count > 0 ? t('change.ci.failed', { count: ci.count }) : t('change.ci.failedUnlisted') + : t(`change.ci.${ci.kind}`)} + + {flag !== undefined && {t(`change.flag.${flag}`)}} + + ) +} + +/** Expanded identity after the number: draft state and the head and base branches. */ +function Target({ snapshot, t }: { readonly snapshot: ChangeRequestSnapshot; readonly t: Translate }): ReactNode { + return ( + <> + {snapshot.state === 'open' && snapshot.draft && {t('change.state.draft')}} + {`${snapshot.headBranch} → ${snapshot.baseBranch}`} + + ) +} + +function ChecksRow({ snapshot, openLink, t }: { + readonly snapshot: ChangeRequestSnapshot + readonly openLink: (url: string) => void + readonly t: Translate +}): ReactNode { + const [open, setOpen] = useState(false) + if (snapshot.checks.length === 0) { + return ( +
+ + {t('change.row.checks')} + {t('change.ci.none')} +
+ ) + } + const counts = checkCounts(snapshot.checks) + const summary = [ + ...counts.failed > 0 ? [t('change.checks.failed', { count: counts.failed })] : [], + ...counts.running > 0 ? [t('change.checks.running', { count: counts.running })] : [], + ...counts.passed > 0 ? [t('change.checks.passed', { count: counts.passed })] : [], + ...counts.other > 0 ? [t('change.checks.other', { count: counts.other })] : [], + ].join(' · ') + const close = (): void => { setOpen(false) } + const truncated: MenuEntry[] = [{ type: 'label', id: 'truncated', text: t('change.checks.truncated', { count: snapshot.checks.length }) }] + return ( + { setOpen(!open) }}> + + {t('change.row.checks')} + 0 ? 'error' : undefined}> + {summary} + + + + )} + {...snapshot.checksTruncated ? { footer: truncated } : {}} + > + {orderedChecks(snapshot.checks).map((check, index) => { + const { url } = check + return ( + } + disabled={url === null} + onSelect={url === null ? close : () => { + close() + openLink(url) + }} + > + + {check.name} + {checkTime(check, t)} + + + ) + })} + + ) +} + +/** The merge button and its menu: a title row, one row per method with the chosen one checked, and the confirmation. */ +function MergeMenu({ snapshot, name, usual, busy, onMerge, t }: { + readonly snapshot: ChangeRequestSnapshot + readonly name: string + readonly usual: MergeMethod + readonly busy: boolean + readonly onMerge: (method: MergeMethod, headSha: string) => void + readonly t: Translate +}): ReactNode { + const [open, setOpen] = useState(false) + const [confirmedHead, setConfirmedHead] = useState(snapshot.headSha) + const [chosen, setChosen] = useState(usual) + const stale = confirmedHead !== snapshot.headSha + // The methods can change between polls; a choice the repository no longer allows falls back to the usual one. + const method = snapshot.mergeMethods.find(entry => entry === chosen) ?? usual + const close = (): void => { setOpen(false) } + const base = snapshot.baseBranch + const items: MenuEntry[] = [ + { type: 'label', id: 'title', text: t('change.mergeTitle', { name, base }) }, + ...snapshot.mergeMethods.map(entry => ({ + id: entry, + label: ( + + {t(`change.mergeMethod.${entry}`)} + {t(`change.mergeNote.${entry}`, { base })} + + ), + })), + ] + return ( + { + if (!open) setConfirmedHead(snapshot.headSha) + setOpen(!open) + }} + > + {t('change.merge')} + + + )} + items={items} + selectedId={method} + onSelect={setChosen} + > + { + close() + onMerge(method, confirmedHead) + }} + > + {t(stale ? 'change.mergeChanged' : 'change.mergeConfirm')} + + + ) +} + +/** The footer with the action of an open change request: mark a draft ready, or merge one the host would merge now. */ +function Primary({ entry, snapshot, name, actions, t }: { + readonly entry: ForgePanelChangeRequest + readonly snapshot: ChangeRequestSnapshot + readonly name: string + readonly actions: ChangeRequestActions + readonly t: Translate +}): ReactNode { + // One request at a time: the action stays disabled until the previous one settles. + const [busy, setBusy] = useState(false) + const run = (action: () => Promise): void => { + setBusy(true) + void action().finally(() => { setBusy(false) }) + } + const { markReady, merge } = actions + if (snapshot.state !== 'open') return null + if (snapshot.draft) { + if (markReady === undefined) return null + return ( +
+ +
+ ) + } + const [usual] = snapshot.mergeMethods + if (!snapshot.mergeReady || usual === undefined || merge === undefined) return null + return ( +
+ { run(() => merge(entry, { method, headSha })) }} + t={t} + /> +
+ ) +} + +function Details({ entry, snapshot, name, now, actions, locale, t }: { + readonly entry: ForgePanelChangeRequest + readonly snapshot: ChangeRequestSnapshot + readonly name: string + readonly now: number + readonly actions: ChangeRequestActions + readonly locale: string + readonly t: Translate +}): ReactNode { + const changes = + if (snapshot.state !== 'open') { + return ( +
+
+ + {finished(snapshot, now, t)} +
+ {changes} +
+ ) + } + const review = t(REVIEW[snapshot.reviewDecision]) + const reviewTone = snapshot.reviewDecision === 'changes-requested' ? 'error' : snapshot.reviewDecision === 'approved' ? 'success' : undefined + const merge = snapshot.mergeability === 'mergeable' + ? t('change.merge.mergeable') + : snapshot.mergeability === 'conflicting' ? t('change.merge.conflicting', { base: snapshot.baseBranch }) : t('change.merge.unknown') + return ( +
+ +
+ + {t('change.row.review')} + + {snapshot.unresolvedThreads > 0 ? t('change.review.threads', { review, count: snapshot.unresolvedThreads }) : review} + +
+
+ + {t('change.row.merge')} + {merge} +
+ {changes} +
+ ) +} + +/** + * Render one change request. + * @param props - change request, expansion, current time, actions, and copy. + * @returns the card. + */ +export function ChangeRequestCard({ entry, expanded, onToggle, now, actions, locale, t }: ChangeRequestCardProps): ReactNode { + const snapshot = entry.snapshot + const number = numberOf(entry) + const url = snapshot?.url ?? entry.url + const failure = failureOf(entry) + const notice = entry.ref === null ? 'change.unserved' : failure === undefined ? undefined : FAILURE[failure.code] + const tone = toneOf(snapshot) + const name = number === null ? snapshot?.title ?? url : t('change.number', { number }) + // A notice replaces the collapsed summary, so a failed read never shows its stale status beside it. + const meta = expanded + ? snapshot === null ? null : + : notice === undefined ? : null + const { remove } = actions + return ( +
  • +
    + +
    + { + event.preventDefault() + actions.openLink(url) + }} + > + {snapshot?.title ?? url.replace(/^https?:\/\//i, '')} + + {(number !== null || meta !== null) && ( +
    + {number !== null && {t('change.number', { number })}} + {number !== null && meta !== null && } + {meta} +
    + )} +
    + + + + + + + {remove !== undefined && ( + + + + )} + +
    + {notice !== undefined && ( +
    + {t(notice)} + {entry.ref === null + ? + : } +
    + )} + {expanded && snapshot !== null && ( +
    + )} + {expanded && snapshot !== null && } +
  • + ) +} diff --git a/packages/experimental/client-ui-forge/src/client/ChangesTab.module.css b/packages/experimental/client-ui-forge/src/client/ChangesTab.module.css new file mode 100644 index 0000000000..25fb6cc4e9 --- /dev/null +++ b/packages/experimental/client-ui-forge/src/client/ChangesTab.module.css @@ -0,0 +1,610 @@ +/* Changes view: the source header, the toolbar, notices, then one scrolling + list of file sections whose headers stick while their lines scroll. */ +.root { + --forge-changes-bg: var(--dsw-alias-bg-base); + --forge-fill: var(--dsw-static-neutral-50); + --forge-diff-empty: color-mix(in srgb, var(--dsw-alias-interactive-bg-hover) 50%, transparent); + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + background: var(--forge-changes-bg); + color: var(--dsw-alias-label-primary); + font-size: 12px; + line-height: 18px; +} + +:global(body[data-ds-dark-theme]) .root { + --forge-fill: var(--dsw-static-neutral-850); +} + +.header { + display: flex; + flex: none; + align-items: center; + gap: 8px; + box-sizing: border-box; + min-height: 44px; + padding: 0 8px 0 14px; + border-bottom: 0.5px solid var(--dsw-alias-border-l3); +} + +.headerIcon { + display: inline-flex; + flex: none; + color: var(--dsw-alias-label-secondary); +} + +.headerTitle { + min-width: 0; + overflow: hidden; + font-size: 13px; + font-weight: 500; + line-height: 20px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.headerNumber { + flex: none; + color: var(--dsw-alias-label-tertiary); + font-family: var(--ds-font-family-code); +} + +.headerNote { + min-width: 0; + overflow: hidden; + color: var(--dsw-alias-label-secondary); + text-overflow: ellipsis; + white-space: nowrap; +} + +.headerEnd { + flex: none; + margin-left: auto; +} + +.toolbar { + display: flex; + flex: none; + align-items: center; + gap: 4px; + box-sizing: border-box; + min-height: 38px; + padding: 0 8px; + border-bottom: 0.5px solid var(--dsw-alias-border-l3); +} + +.select { + display: inline-flex; + flex: 0 1 auto; + align-items: center; + gap: 4px; + box-sizing: border-box; + min-width: 0; + height: 28px; + padding: 0 6px 0 8px; + border: 0; + border-radius: var(--dsw-radius-sm); + background: transparent; + color: var(--dsw-alias-label-primary); + cursor: pointer; + font: inherit; + white-space: nowrap; +} + +.select[aria-expanded='true'] { + background: var(--dsw-alias-interactive-bg-hover); +} + +@media (hover: hover) { + .select:hover { + background: var(--dsw-alias-interactive-bg-hover); + } +} + +.select:disabled { + color: var(--dsw-alias-label-tertiary); + cursor: default; +} + +@media (hover: hover) { + .select:disabled:hover { + background: transparent; + } +} + +.select svg { + flex: none; +} + +.counts { + display: inline-flex; + flex: none; + gap: 6px; + font-family: var(--ds-font-family-code); +} + +.totals { + padding: 0 4px; +} + +.added { + color: var(--dsw-alias-state-success-primary); +} + +.deleted { + color: var(--dsw-alias-state-error-primary); +} + +.tools { + display: inline-flex; + flex: none; + align-items: center; + gap: 2px; + margin-left: auto; +} + +.tool { + display: inline-flex; + flex: none; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + border: 0; + border-radius: var(--dsw-radius-sm); + background: transparent; + color: var(--dsw-alias-label-secondary); + cursor: pointer; +} + +.tool[aria-pressed='true'], +.tool[aria-expanded='true'], +.tool[data-expanded] { + background: var(--dsw-alias-interactive-bg-hover); + color: var(--dsw-alias-label-primary); +} + +@media (hover: hover) { + .tool:hover:not(:disabled) { + background: var(--dsw-alias-interactive-bg-hover); + color: var(--dsw-alias-label-primary); + } +} + +.tool:disabled { + color: var(--dsw-alias-label-tertiary); + cursor: default; +} + +.tool svg { + width: 15px; + height: 15px; +} + +.tool[aria-pressed='true'] .compareIcon { + transform: rotate(90deg); +} + +/* A menu row with a path that shortens and its counts. */ +.menuRow { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-width: 0; +} + +.menuPath { + flex: 1; + min-width: 0; +} + +.menuList { + min-width: 280px; + max-width: min(420px, 80vw); +} + +.notice { + display: flex; + flex: none; + align-items: center; + gap: 8px; + padding: 8px 14px; + border-bottom: 0.5px solid var(--dsw-alias-border-l3); + background: var(--forge-fill); + color: var(--dsw-alias-label-secondary); +} + +.notice > svg { + flex: none; +} + +.noticeAction { + flex: none; + margin-left: auto; +} + +.files { + flex: 1; + min-height: 0; + overflow: auto; +} + +/* The actions of a failed read, side by side. */ +.actions { + display: flex; + gap: 8px; +} + +/* Loading, empty, and failed states without changes to show. */ +.centered { + display: flex; + flex: 1; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + box-sizing: border-box; + padding: 24px; + color: var(--dsw-alias-label-secondary); + font-size: var(--dsh-content-font-size-secondary, 13px); + text-align: center; +} + +.file { + border-bottom: 0.5px solid var(--dsw-alias-border-l3); +} + +.fileHeader { + position: sticky; + top: 0; + z-index: 1; + display: flex; + align-items: center; + gap: 6px; + box-sizing: border-box; + min-height: 36px; + padding: 0 4px 0 8px; + border-bottom: 0.5px solid var(--dsw-alias-border-l3); + background: var(--forge-changes-bg); +} + +/* A bare chevron: its rotation shows whether the file is expanded, so it takes no fill in any state. */ +.fileToggle { + display: inline-flex; + flex: none; + align-items: center; + justify-content: center; + width: 20px; + height: 24px; + padding: 0; + border: 0; + border-radius: var(--dsw-radius-sm); + background: transparent; + color: var(--dsw-alias-label-tertiary); + cursor: pointer; +} + +@media (hover: hover) { + .fileToggle:hover { + color: var(--dsw-alias-label-primary); + } +} + +.file:not([data-expanded]) .fileHeader { + border-bottom: 0; +} + +.fileIcon { + flex: none; +} + +.filePath { + flex: 1; + min-width: 0; +} + +.chevron { + transition: transform 120ms ease; +} + +.file:not([data-expanded]) .chevron { + transform: rotate(-90deg); +} + +.badge { + flex: none; + max-width: 50%; + overflow: hidden; + padding: 0 6px; + border-radius: 4px; + background: var(--forge-fill); + color: var(--dsw-alias-label-secondary); + font-size: 11px; + line-height: 18px; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Lines of one file: one column of grid rows, or two cells per row side by side. */ +.code { + overflow-x: auto; + font: var(--dsw-font-markdown-code-block); +} + +/* One intrinsic-width track paints every unwrapped unified row across the scrolled width. */ +.code:not([data-wrap]):not([data-split]) { + display: grid; + grid-template-columns: minmax(max-content, 100%); +} + +.line { + display: grid; + grid-template-columns: 3.5em 3.5em 1.4em minmax(0, 1fr); + box-sizing: border-box; + min-height: 22px; + line-height: 22px; + white-space: pre; +} + +.code:not([data-wrap]):not([data-split]) .line { + grid-template-columns: 3.5em 3.5em 1.4em max-content; +} + +.pair { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + min-height: 22px; + line-height: 22px; + white-space: pre; +} + +.cell { + display: grid; + grid-template-columns: 3.5em minmax(0, 1fr); + box-sizing: border-box; + min-width: 0; +} + +.cell + .cell { + border-left: 0.5px solid var(--dsw-alias-border-l3); +} + +/* Unwrapped side-by-side cells cut long lines at the column edge. */ +.cell .text { + overflow: hidden; +} + +.code[data-wrap] .line, +.code[data-wrap] .pair { + white-space: pre-wrap; +} + +.code[data-wrap] .text { + overflow-wrap: anywhere; +} + +.number { + padding-right: 8px; + color: var(--dsw-alias-label-tertiary); + text-align: right; + user-select: none; +} + +.sign { + text-align: center; + user-select: none; +} + +.text { + padding: 0 16px 0 6px; +} + +.add { + --forge-diff-gutter: var(--dsw-alias-file-diff-added-gutter); + --forge-diff-marker: var(--dsw-alias-file-diff-added-marker); + background: var(--dsw-alias-file-diff-added-bg); +} + +.del { + --forge-diff-gutter: var(--dsw-alias-file-diff-deleted-gutter); + --forge-diff-marker: var(--dsw-alias-file-diff-deleted-marker); + background: var(--dsw-alias-file-diff-deleted-bg); +} + +.add .number, +.del .number { + background: var(--forge-diff-gutter); + color: var(--forge-diff-marker); +} + +.add .sign, +.del .sign { + color: var(--forge-diff-marker); +} + +.context .text { + color: var(--dsw-alias-label-secondary); +} + +.empty { + background: var(--forge-diff-empty); +} + +/* Collapsed unchanged lines; the row expands them. */ +/* Collapsed unchanged lines, a collapsed large file, and a file whose lines cannot be drawn share one strip; + only the first two open on a click. */ +.gap, +.fileNote { + display: flex; + align-items: center; + gap: 6px; + box-sizing: border-box; + width: 100%; + min-height: 26px; + margin: 0; + padding: 0 12px; + border: 0; + background: var(--forge-fill); + color: var(--dsw-alias-label-tertiary); + font-family: var(--dsw-font-family); + font-size: 12px; + line-height: 18px; + text-align: left; +} + +.gap { + cursor: pointer; +} + +@media (hover: hover) { + .gap:hover:not(:disabled) { + background: var(--dsw-alias-interactive-bg-hover); + color: var(--dsw-alias-label-primary); + } +} + +.gap:disabled { + cursor: progress; +} + +/* The local changes card of the panel: its toggle and refs, the view link and create action, then its files. */ +.local { + min-width: 0; + border: 0.5px solid var(--dsw-alias-border-l2); + border-radius: var(--dsw-radius-md); + background: var(--dsw-alias-bg-layer-1); +} + +.localHead { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + padding: 8px 12px 2px 6px; +} + +.localToggle { + display: inline-flex; + flex: none; + align-items: center; + gap: 6px; + min-height: 26px; + padding: 0 6px; + border: 0; + border-radius: var(--dsw-radius-sm); + background: transparent; + color: var(--dsw-alias-label-primary); + cursor: pointer; + font: inherit; + font-weight: 500; +} + +@media (hover: hover) { + .localToggle:hover { + background: var(--dsw-alias-interactive-bg-hover); + } +} + +.localToggle svg { + color: var(--dsw-alias-label-tertiary); + transition: transform 120ms ease; +} + +.localToggle[aria-expanded='true'] svg { + transform: rotate(180deg); +} + +.localRefs { + min-width: 0; + margin-left: auto; + overflow: hidden; + color: var(--dsw-alias-label-tertiary); + font-family: var(--ds-font-family-code); + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.localRow { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 10px 10px 12px; + font-size: 12px; +} + +.localView { + display: inline-flex; + align-items: center; + gap: 8px; + min-width: 0; + min-height: 28px; + margin-left: -6px; + padding: 0 6px; + border: 0; + border-radius: var(--dsw-radius-sm); + background: transparent; + color: var(--dsw-alias-label-secondary); + cursor: pointer; + font: inherit; +} + +@media (hover: hover) { + .localView:hover { + background: var(--dsw-alias-interactive-bg-hover); + } +} + +.localCreate { + flex: none; + margin-left: auto; +} + +.localFiles { + margin: 0; + padding: 4px 6px 8px; + border-top: 0.5px solid var(--dsw-alias-border-l2); + list-style: none; +} + +.localFile { + display: flex; + align-items: center; + gap: 8px; + box-sizing: border-box; + width: 100%; + min-height: 28px; + padding: 0 6px; + border: 0; + border-radius: var(--dsw-radius-sm); + background: transparent; + color: var(--dsw-alias-label-primary); + cursor: pointer; + font: inherit; + font-size: 12px; + text-align: left; +} + +@media (hover: hover) { + .localFile:hover { + background: var(--dsw-alias-interactive-bg-hover); + } +} + +.localFile .menuPath { + flex: 1; +} + +.localView:focus-visible, +.localToggle:focus-visible, +.fileToggle:focus-visible, +.localFile:focus-visible, +.select:focus-visible, +.gap:focus-visible { + outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); + outline-offset: -2px; +} diff --git a/packages/experimental/client-ui-forge/src/client/ChangesTab.tsx b/packages/experimental/client-ui-forge/src/client/ChangesTab.tsx new file mode 100644 index 0000000000..b9e493bde4 --- /dev/null +++ b/packages/experimental/client-ui-forge/src/client/ChangesTab.tsx @@ -0,0 +1,563 @@ +/** + * Body of the changes tab: the local changes of the Session's checkout in a + * chosen scope, or the changes of one of its change requests, as one list of + * file sections under a toolbar that jumps between files and switches the + * layout. The view reads the changes when it opens, on each navigation, on + * refresh, and when the change request's head or the checkout's `branch` + * scope summary changes; a read keeps the previous changes on screen until it settles. + */ + +import { useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from 'react' +import clsx from 'clsx' +import { + Button, IconChevronDownOutlineRegular, IconChevronsUpDownOutlineRegular, IconCompareSplitOutlineRegular, IconInfoOutlineRegular, + IconListPenOutlineRegular, IconNowrapFillRegular, IconRightUpOutlineRegular, IconWrapFillRegular, Menu, StateDot, Tooltip, + type MenuEntry, +} from '@deepseek-ai/dsh-client-ui-primitives' +import type { HostObservable, InjectFace, PropsLocale, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' +import type {} from '@deepseek-ai/dsh-client-ui-sidebar-right/client' +import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' +import { fileAddressFor, pathPartsOf } from '@deepseek-ai/dsh-util-workspace-path' +import type { + ForgeChangeRequestDiff, ForgeDiffFile, ForgeLocalDiff, ForgeLocalScope, ForgePanelChangeRequest, ForgePanelState, +} from '../types.ts' +import { parseChangesAddress, type ChangesSource } from './changes-address.ts' +import type { createChangesStore } from './changes-store.ts' +import { Counts, DiffFile, type DiffFileAction } from './DiffFile.tsx' +import { drawnLines, matchesHunks, textLines } from './diff-rows.ts' +import { FileLabel } from './FileLabel.tsx' +import { ChangeRequestGlyph } from './glyphs.tsx' +import type { ForgePanelKey, NS } from './locales.ts' +import { RefreshGlyph } from './RefreshGlyph.tsx' +import type { PanelSnapshot } from './source.ts' +import { numberOf, openChangeRequestOf, TONE_LABEL, toneOf } from './status.ts' +import css from './ChangesTab.module.css' + +type Translate = PropsLocale['t'] + +/** Kind of a local scope. */ +type ScopeKind = ForgeLocalScope['kind'] + +/** Business face of the changes tab body. */ +export interface ChangesTabInjected { + /** Panel snapshot of the Session; the renderer binds it as `usePanel`. */ + readonly hooks: { readonly panel: HostObservable } + /** Open the panel stream again after it ended. */ + readonly reconnect: () => void + /** Read the local changes of one scope; `signal` aborts the read. */ + readonly localDiff: (scope: ForgeLocalScope, signal: AbortSignal) => Promise> + /** Read the new side of one file in a local scope. */ + readonly localText: (scope: ForgeLocalScope, path: string) => Promise> + /** Read the changes of a change request by its binding URL; `signal` aborts the read. */ + readonly changeRequestDiff: (url: string, signal: AbortSignal) => Promise> + /** Read one file of a change request at a commit. */ + readonly changeRequestText: (url: string, commit: string, path: string) => Promise> + /** Open a URL outside the app. */ + readonly openLink: (url: string) => void + /** Copy a path to the clipboard and report the outcome. */ + readonly copyPath: (path: string) => void + /** Ask the Session's Agent to create a change request for its changes, reporting the outcome. */ + readonly createChangeRequest: () => void + /** Show a failure notice. */ + readonly report: (key: ForgePanelKey, params?: Readonly>) => void +} + +/** Full props of the changes tab body. */ +export type ChangesTabProps = + Pick, 'useTabInfo' | 'sessionId'> + & PropsStore> + & InjectFace + & PropsLocale + +/** Changes of either source as the view draws them. */ +type ChangesData = + | { + readonly kind: 'local' + readonly files: readonly ForgeDiffFile[] + /** Scope the files were read in. */ + readonly scope: ScopeKind + /** Untracked files left out. */ + readonly omitted: number + } + | { + readonly kind: 'change' + readonly files: readonly ForgeDiffFile[] + /** Binding URL of the change request. */ + readonly url: string + /** Head commit the files describe, which supplies unchanged lines. */ + readonly headSha: string + /** Commits the checkout's branch has beyond the head, or null. */ + readonly localAhead: number | null + /** Web page of each file at the head. */ + readonly fileUrls: Readonly> + } + +/** Unchanged lines read for one loaded diff: each file's new side and its expanded gaps. */ +interface Expansion { + readonly data: ChangesData | null + readonly texts: Readonly> + readonly open: Readonly>> +} + +/** Files whose hunks draw more lines show the large-file notice until revealed. */ +const LARGE_FILE_LINES = 1000 + +/** Lines the files above may change before a file starts collapsed. */ +const OPEN_LINES = 5000 + +const NO_GAPS: ReadonlySet = new Set() + +const SCOPE_LABEL: Record = { + branch: 'diff.scope.branch', + uncommitted: 'diff.scope.uncommitted', + unstaged: 'diff.scope.unstaged', + staged: 'diff.scope.staged', +} + +const SCOPE_NOTE: Record, ForgePanelKey> = { + uncommitted: 'diff.scopeNote.uncommitted', + unstaged: 'diff.scopeNote.unstaged', + staged: 'diff.scopeNote.staged', +} + +/** Failures with their own message; any other failure offers a retry. */ +const FAILURE: Partial> = { + 'forgePanel/not-a-checkout': 'checkout.none', + 'forgePanel/no-fork-point': 'diff.noForkPoint', + 'forgePanel/diff-too-large': 'diff.tooLarge', +} + +async function readChanges( + source: ChangesSource, + scope: ScopeKind, + reads: Pick, + signal: AbortSignal, +): Promise> { + if (source.kind === 'local') { + const result = await reads.localDiff({ kind: scope }, signal) + if (!result.ok) return result + return { ok: true, value: { kind: 'local', files: result.value.files, scope, omitted: result.value.omitted } } + } + const result = await reads.changeRequestDiff(source.url, signal) + if (!result.ok) return result + const { files, headSha, localAhead, fileUrls } = result.value + return { ok: true, value: { kind: 'change', files, url: source.url, headSha, localAhead, fileUrls } } +} + +/** The files that start expanded: every file until those above it change more than {@link OPEN_LINES} lines. */ +function initiallyOpen(files: readonly ForgeDiffFile[]): ReadonlySet { + const open = new Set() + let above = 0 + for (const file of files) { + if (above > OPEN_LINES) break + open.add(file.path) + above += drawnLines(file) + } + return open +} + +/** What a rename badge names: the old file name, or the old path when only the directory changed. */ +function renamedFrom(previous: string, path: string): string { + const name = pathPartsOf(previous).name + return name === pathPartsOf(path).name ? previous : name +} + +/** The hover text of a file's path: both paths of a renamed file, else the path. */ +function pathTooltipOf(file: ForgeDiffFile, t: Translate): string { + return file.status === 'renamed' && file.previousPath !== null ? t('diff.renamedPath', { from: file.previousPath, path: file.path }) : file.path +} + +/** The badge after a path: how the file changed, and whether a branch view's file is untracked. */ +function badgeOf(file: ForgeDiffFile, data: ChangesData, t: Translate): string { + const parts: string[] = [] + if (file.status === 'added') parts.push(t('diff.newFile')) + else if (file.status === 'deleted') parts.push(t('diff.deletedFile')) + else if (file.status === 'renamed' && file.previousPath !== null) parts.push(t('diff.renamed', { from: renamedFrom(file.previousPath, file.path) })) + if (file.untracked && data.kind === 'local' && data.scope === 'branch') parts.push(t('diff.badgeUncommitted')) + return parts.join(' · ') +} + +function ChangeHeader({ url, entry, openLink, t }: { + readonly url: string + readonly entry: ForgePanelChangeRequest | undefined + readonly openLink: (url: string) => void + readonly t: Translate +}): ReactNode { + const snapshot = entry?.snapshot ?? null + const number = numberOf(entry ?? { ref: null, url }) + const tone = toneOf(snapshot) + const href = snapshot === null ? url : snapshot.url + return ( +
    + + {snapshot === null ? url : snapshot.title} + {number !== null && {t('change.number', { number })}} + + + +
    + ) +} + +function LocalHeader({ state, scope, createChangeRequest, t }: { + readonly state: ForgePanelState | null + readonly scope: ScopeKind + readonly createChangeRequest: () => void + readonly t: Translate +}): ReactNode { + const base = state?.local?.base ?? null + const note = scope !== 'branch' ? t(SCOPE_NOTE[scope]) : base === null ? '' : t('diff.scopeNote.branch', { base }) + const creatable = state !== null && state.repository !== null && openChangeRequestOf(state) === undefined + return ( +
    + + {note} + {creatable && ( + + )} +
    + ) +} + +function ScopeMenu({ scope, choose, t }: { + readonly scope: ScopeKind + readonly choose: (scope: ScopeKind) => void + readonly t: Translate +}): ReactNode { + const [open, setOpen] = useState(false) + const items: MenuEntry[] = [ + { id: 'uncommitted', label: t(SCOPE_LABEL.uncommitted) }, + { id: 'unstaged', label: t(SCOPE_LABEL.unstaged) }, + { id: 'staged', label: t(SCOPE_LABEL.staged) }, + { type: 'separator', id: 'separator' }, + { id: 'branch', label: t(SCOPE_LABEL.branch) }, + ] + return ( + { setOpen(false) }} + onSelect={(id) => { + setOpen(false) + choose(id as ScopeKind) + }} + anchor={( + + )} + /> + ) +} + +function FilesMenu({ files, jump, t }: { + readonly files: readonly ForgeDiffFile[] + readonly jump: (path: string) => void + readonly t: Translate +}): ReactNode { + const [open, setOpen] = useState(false) + return ( + ({ + id: file.path, + label: , + }))} + onClose={() => { setOpen(false) }} + onSelect={(path) => { + setOpen(false) + jump(path) + }} + anchor={( + + )} + /> + ) +} + +function Notice({ children, action }: { readonly children: ReactNode; readonly action?: ReactNode }): ReactNode { + return ( +
    + + {children} + {action} +
    + ) +} + +/** + * Render the changes tab of one Session. + * @param props - tab information, the tab's choices, the panel snapshot hook, reads and actions, and copy. + * @returns the view body. + */ +export function ChangesTab({ + useTabInfo, sessionId, useStore, actions, usePanel, reconnect, localDiff, localText, changeRequestDiff, changeRequestText, + openLink, copyPath, createChangeRequest, report, t, +}: ChangesTabProps): ReactNode { + const { tab } = useTabInfo() + const { navigation, signal } = tab + const target = useMemo(() => parseChangesAddress(tab.contentId), [tab.contentId]) + if (target === undefined) throw new Error(`forge-panel: not a changes address "${tab.contentId}"`) + const source = target.source + const choices = useStore(store => store.byTab[tab.id]) + const [wanted, setWanted] = useState(null) + const [chosen, setChosen] = useState>>({}) + // Each navigation applies once; its file, if it names one, opens and scrolls into view. + useEffect(() => { + if (choices?.navigated === navigation.revision) return + actions.navigated(tab.id, navigation.revision) + const path = navigation.params?.path + if (path === undefined) return + setChosen(value => ({ ...value, [path]: true })) + setWanted(path) + }, [choices, navigation.revision, navigation.params, actions, tab.id]) + // The tab's choices end with its record. + useEffect(() => { + const unmounted = new AbortController() + signal.addEventListener('abort', () => { actions.forget(tab.id) }, { once: true, signal: unmounted.signal }) + return () => { unmounted.abort() } + }, [signal, actions, tab.id]) + const [attempt, setAttempt] = useState(0) + useEffect(() => tab.actions.bindCommands({ refresh: () => { setAttempt(value => value + 1) } }), [tab.actions]) + const snapshot = usePanel(current => current) + const state = snapshot.state + const scope = choices?.scope ?? 'branch' + const split = choices?.split ?? true + const wrap = choices?.wrap ?? true + const entry = source.kind === 'change' ? state?.changeRequests.find(item => item.url === source.url) : undefined + // A read starts once the panel state arrives and again when what it describes moves. A change request's read + // also waits for its first status, which names the head, unless no provider serves it or its first poll failed. + const pending = entry !== undefined && entry.ref !== null && entry.update === null + const head = entry?.snapshot?.headSha ?? (pending ? null : '') + const version = state === null ? null : source.kind === 'local' ? `${scope}\n${JSON.stringify(state.local)}` : head + const dataKey = source.kind === 'local' ? scope : 'change' + const [loaded, setLoaded] = useState<{ readonly key: string; readonly data: ChangesData } | null>(null) + const [failure, setFailure] = useState<{ readonly key: string; readonly code: string } | null>(null) + // Names the read the inputs ask for; it is in flight until `finished` matches it. + const read = version === null ? null : `${dataKey}\n${navigation.revision}\n${attempt}\n${version}` + const [finished, setFinished] = useState(null) + useEffect(() => { + if (read === null) return + const controller = new AbortController() + void readChanges(source, scope, { localDiff, changeRequestDiff }, controller.signal).then((result) => { + if (controller.signal.aborted) return + setFinished(read) + if (result.ok) { + setLoaded({ key: dataKey, data: result.value }) + setFailure(null) + } else { + setFailure({ key: dataKey, code: result.error.code }) + } + }) + return () => { controller.abort() } + }, [read, source, scope, dataKey, localDiff, changeRequestDiff]) + const data = loaded !== null && loaded.key === dataKey ? loaded.data : null + // The centered loader marks a first read; the toolbar's glyph turns only while shown changes are read again. + const rereading = read !== null && finished !== read && data !== null + const failed = failure !== null && failure.key === dataKey ? failure.code : null + const [expansion, setExpansion] = useState({ data: null, texts: {}, open: {} }) + const current = expansion.data === data ? expansion : { data, texts: {}, open: {} } + const latest = useRef(data) + useEffect(() => { latest.current = data }, [data]) + const [revealed, setRevealed] = useState>(new Set()) + const initial = useMemo(() => initiallyOpen(data === null ? [] : data.files), [data]) + const sections = useRef(new Map()) + useLayoutEffect(() => { + if (wanted === null || data === null) return + sections.current.get(wanted)?.scrollIntoView({ block: 'start' }) + setWanted(null) + }, [wanted, data]) + const refresh = (): void => { setAttempt(value => value + 1) } + const root = state !== null && state.checkout.state === 'ready' ? state.checkout.checkout.root : null + // A failure with its own message offers no retry; a change request's failure also offers its web page. + const message = failed === null ? undefined : FAILURE[failed] + const changeUrl = source.kind === 'change' ? entry?.snapshot?.url ?? source.url : null + const retry = message === undefined + + /** Apply a change to the unchanged lines of `owner`, unless newer changes replaced it. */ + const update = (owner: ChangesData, change: (value: Expansion) => Expansion): void => { + setExpansion((previous) => { + if (latest.current !== owner) return previous + return change(previous.data === owner ? previous : { data: owner, texts: {}, open: {} }) + }) + } + const expand = (owner: ChangesData, file: ForgeDiffFile, index: number): void => { + const path = file.path + update(owner, value => ({ ...value, open: { ...value.open, [path]: new Set([...value.open[path] ?? [], index]) } })) + if (current.texts[path] !== undefined) return + update(owner, value => ({ ...value, texts: { ...value.texts, [path]: 'loading' } })) + const reading = owner.kind === 'local' ? localText({ kind: owner.scope }, path) : changeRequestText(owner.url, owner.headSha, path) + void reading.then((result) => { + const lines = result.ok ? textLines(result.value) : undefined + if (lines !== undefined && matchesHunks(lines, file.hunks)) { + update(owner, value => ({ ...value, texts: { ...value.texts, [path]: lines } })) + return + } + if (result.ok) report('toast.expandStale') + else report('toast.expandFailed', { message: result.error.message }) + update(owner, (value) => { + const { [path]: _text, ...texts } = value.texts + const { [path]: _open, ...open } = value.open + return { ...value, texts, open } + }) + }) + } + const fileActions = (owner: ChangesData, file: ForgeDiffFile): DiffFileAction[] => { + const list: DiffFileAction[] = [{ label: 'diff.copyPath', run: () => { copyPath(file.path) } }] + if (file.status === 'deleted') return list + if (owner.kind === 'local') { + if (root !== null) { + list.push({ label: 'diff.openFileInSidebar', run: () => { tab.actions.openResource(fileAddressFor(sessionId, undefined, `${root}/${file.path}`)) } }) + } + } else { + const url = owner.fileUrls[file.path] + if (url !== undefined) list.push({ label: 'diff.openFileInBrowser', run: () => { openLink(url) } }) + } + return list + } + + let body: ReactNode + if (data === null) { + if (state === null && snapshot.disconnected) { + body = ( +
    + {t('stream.failed')} + +
    + ) + } else if (failed === null) { + body =
    + } else { + body = ( +
    + {t(message ?? 'diff.error')} + {(retry || changeUrl !== null) && ( +
    + {retry && } + {changeUrl !== null && ( + + )} +
    + )} +
    + ) + } + } else if (data.files.length === 0) { + body =
    {t('diff.empty')}
    + } else { + body = ( +
    + {data.files.map((file) => { + const path = file.path + return ( + { setChosen(value => ({ ...value, [path]: !(value[path] ?? initial.has(path)) })) }} + large={drawnLines(file) > LARGE_FILE_LINES} + revealed={revealed.has(path)} + onReveal={() => { setRevealed(value => new Set([...value, path])) }} + text={current.texts[path]} + open={current.open[path] ?? NO_GAPS} + onExpand={(index) => { expand(data, file, index) }} + split={split} + wrap={wrap} + badge={badgeOf(file, data, t)} + tooltip={pathTooltipOf(file, t)} + actions={fileActions(data, file)} + sectionRef={(element) => { + if (element === null) sections.current.delete(path) + else sections.current.set(path, element) + }} + t={t} + /> + ) + })} +
    + ) + } + const files = data === null ? [] : data.files + // The toolbar collapses every file while any is expanded, as the files on screen show, and expands every file otherwise. + const anyExpanded = files.some(file => chosen[file.path] ?? initial.has(file.path)) + const expansionLabel = t(anyExpanded ? 'diff.collapseAll' : 'diff.expandAll') + let additions = 0 + let deletions = 0 + for (const file of files) { + additions += file.additions + deletions += file.deletions + } + return ( +
    + {source.kind === 'local' + ? + : } +
    + {source.kind === 'local' && { actions.scoped(tab.id, next) }} t={t} />} + { + setChosen(value => ({ ...value, [path]: true })) + setWanted(path) + }} + t={t} + /> + {files.length > 0 && } + + + + + + + + + + + + + + +
    + {data !== null && failed !== null && ( + {t('retry')}}> + {t(message ?? 'diff.error')} + + )} + {data?.kind === 'change' && data.localAhead !== null && data.localAhead > 0 && ( + {t('diff.stale', { count: data.localAhead })} + )} + {data?.kind === 'local' && data.omitted > 0 && {t('diff.omitted', { count: data.omitted })}} + {body} +
    + ) +} diff --git a/packages/experimental/client-ui-forge/src/client/ChangesTabTitle.tsx b/packages/experimental/client-ui-forge/src/client/ChangesTabTitle.tsx new file mode 100644 index 0000000000..6286d0b804 --- /dev/null +++ b/packages/experimental/client-ui-forge/src/client/ChangesTabTitle.tsx @@ -0,0 +1,28 @@ +/** Chip title of a changes tab: the changes icon and the view's name in the current language. */ + +import type { ReactNode } from 'react' +import { IconListPenOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' +import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type {} from '@deepseek-ai/dsh-client-ui-sidebar-right/client' +import { changesTitle, parseChangesAddress } from './changes-address.ts' +import type { NS } from './locales.ts' +import css from './ForgeTab.module.css' + +/** Props of the changes tab title. */ +export type ChangesTabTitleProps = Pick, 'useTabInfo'> & PropsLocale + +/** + * Render the chip title of a changes tab; an address this package did not mint keeps the captured title. + * @param props - tab information and forge panel copy. + * @returns the icon followed by the title. + */ +export function ChangesTabTitle({ useTabInfo, t }: ChangesTabTitleProps): ReactNode { + const { tab } = useTabInfo() + const target = parseChangesAddress(tab.contentId) + return ( + <> + + {target === undefined ? tab.title : changesTitle(target.source, t)} + + ) +} diff --git a/packages/experimental/client-ui-forge/src/client/DiffFile.tsx b/packages/experimental/client-ui-forge/src/client/DiffFile.tsx new file mode 100644 index 0000000000..548658c8fc --- /dev/null +++ b/packages/experimental/client-ui-forge/src/client/DiffFile.tsx @@ -0,0 +1,259 @@ +/** + * One file of the changes view: a sticky header with the path, badges, line + * counts, and file actions, then its hunks unified or side by side with the + * unchanged lines between them collapsed into rows that expand them. + */ + +import { useMemo, useState, type ReactNode } from 'react' +import clsx from 'clsx' +import { + FileTypeIcon, IconChevronDownOutlineRegular, IconChevronsUpDownOutlineRegular, IconEllipsisOutlineRegular, + languageForPath, Menu, MenuItemButton, useCodeHighlighter, type CodeHighlighter, type HighlightSpan, +} from '@deepseek-ai/dsh-client-ui-primitives' +import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' +import type { ForgeDiffFile } from '../types.ts' +import { segmentsOf, splitRows, type DiffRow, type SplitCell, type SplitRow } from './diff-rows.ts' +import { FileLabel } from './FileLabel.tsx' +import type { ForgePanelKey, NS } from './locales.ts' +import css from './ChangesTab.module.css' + +type Translate = PropsLocale['t'] + +/** One action of a file's menu. */ +export interface DiffFileAction { + /** Dictionary key of the row label. */ + readonly label: ForgePanelKey + /** Runs the action. */ + readonly run: () => void +} + +/** New side of a file: its lines once read, `loading` while a read runs, or undefined before any. */ +export type DiffFileText = readonly string[] | 'loading' | undefined + +/** Props of one file section. */ +export interface DiffFileProps { + /** The file and its hunks. */ + readonly file: ForgeDiffFile + /** Whether the section shows its lines. */ + readonly expanded: boolean + /** Expand or collapse the section. */ + readonly onToggle: () => void + /** Whether the file shows the large-file notice instead of its lines until revealed. */ + readonly large: boolean + /** Whether the user revealed a large file. */ + readonly revealed: boolean + /** Show a large file anyway. */ + readonly onReveal: () => void + /** The file's new side, which supplies collapsed unchanged lines. */ + readonly text: DiffFileText + /** Indexes of the expanded gaps. */ + readonly open: ReadonlySet + /** Expand one gap. */ + readonly onExpand: (index: number) => void + /** Whether deletions and additions show in two columns; a file with only additions or only deletions keeps one. */ + readonly split: boolean + /** Whether long lines wrap. */ + readonly wrap: boolean + /** Badge text after the path, or empty for none. */ + readonly badge: string + /** Hover text of the path: both paths of a renamed file, else the path. */ + readonly tooltip: string + /** Rows of the file's menu. */ + readonly actions: readonly DiffFileAction[] + /** Receives the section element, which scrolling to the file targets. */ + readonly sectionRef: (element: HTMLElement | null) => void + /** Forge panel copy. */ + readonly t: Translate +} + +/** + * Render added and deleted line counts, or the binary label for binary content. + * @param props - the counts and copy. + * @returns the counts. + */ +export function Counts({ file, t }: { + readonly file: Pick + readonly t: Translate +}): ReactNode { + if (file.binary) return {t('diff.binaryLabel')} + return ( + + {`+${file.additions.toLocaleString('en-US')}`} + {`−${file.deletions.toLocaleString('en-US')}`} + + ) +} + +const SIGN: Record = { add: '+', del: '-', context: '' } + +/** Highlighted tokens of one side's lines by line number, or undefined while the grammar is unavailable. */ +type SideSpans = ReadonlyMap | undefined + +/** A segment ready to draw: collapsed lines, or rows with the tokens of each side. */ +type Prepared = + | { readonly kind: 'gap'; readonly index: number; readonly count: number | null } + | { readonly kind: 'rows'; readonly rows: readonly DiffRow[]; readonly old: SideSpans; readonly new: SideSpans } + +/** Highlight one side of a run of rows as one fragment, so tokens spanning lines keep their state. */ +function sideSpans(rows: readonly DiffRow[], side: 'old' | 'new', highlighter: CodeHighlighter): SideSpans { + const numbered: { readonly no: number; readonly text: string }[] = [] + for (const row of rows) { + const no = row[side] + if (no !== null) numbered.push({ no, text: row.text }) + } + if (numbered.length === 0) return new Map() + const highlighted = highlighter(numbered.map(line => line.text).join('\n')) + if (highlighted === undefined) return undefined + // Shiki omits a final empty token row, so a final empty line gets no tokens. + return new Map(numbered.map((line, index) => [line.no, highlighted[index] ?? []])) +} + +function Code({ text, spans }: { readonly text: string; readonly spans: readonly HighlightSpan[] | undefined }): ReactNode { + return ( + + {spans === undefined ? text : spans.map((span, index) => {span.text})} + + ) +} + +function Cell({ cell, spans }: { readonly cell: SplitCell | null; readonly spans: SideSpans }): ReactNode { + if (cell === null) return + return ( + + {cell.no} + + + ) +} + +/** Whether a side-by-side row repeats an unchanged line or shows a change. */ +function pairKind(pair: SplitRow): 'context' | 'change' { + return pair.left !== null && pair.left.kind === 'context' ? 'context' : 'change' +} + +/** The hunks of one file with their collapsed and expanded unchanged lines. */ +function Lines({ file, text, open, onExpand, split, wrap, t }: Pick): ReactNode { + const lines = typeof text === 'object' ? text : undefined + const columns = split && file.additions > 0 && file.deletions > 0 + const highlighter = useCodeHighlighter(languageForPath(file.path)) + const prepared = useMemo(() => segmentsOf(file, lines, open).map((segment): Prepared => segment.kind === 'gap' + ? segment + : { ...segment, old: sideSpans(segment.rows, 'old', highlighter), new: sideSpans(segment.rows, 'new', highlighter) }), [file, lines, open, highlighter]) + return ( +
    + {prepared.map((segment, at) => { + if (segment.kind === 'gap') { + return ( + + ) + } + if (columns) { + return splitRows(segment.rows).map((pair, index) => ( +
    + + +
    + )) + } + return segment.rows.map((row, index) => ( +
    + {row.old ?? ''} + {row.new ?? ''} + {SIGN[row.kind]} + +
    + )) + })} +
    + ) +} + +function Body(props: DiffFileProps): ReactNode { + const { file, large, revealed, onReveal, t } = props + if (file.binary) return

    {t('diff.binary')}

    + if (file.oversized) return

    {t('diff.fileTooLarge')}

    + if (file.hunks.length === 0) return null + if (large && !revealed) { + // A large file starts as one collapsed strip, drawn and opened like the collapsed unchanged lines. + return ( + + ) + } + return +} + +/** + * Render one file of a diff. + * @param props - the file, its section and gap state, layout choices, menu actions, and copy. + * @returns the file section. + */ +export function DiffFile(props: DiffFileProps): ReactNode { + const { file, expanded, onToggle, badge, tooltip, actions, sectionRef, t } = props + const [menuOpen, setMenuOpen] = useState(false) + const name = file.path.slice(file.path.lastIndexOf('/') + 1) + return ( +
    +
    + + + + {badge !== '' && {badge}} + {!file.binary && } + { setMenuOpen(false) }} + anchor={( + + )} + > + {actions.map(action => ( + { + setMenuOpen(false) + action.run() + }} + > + {t(action.label)} + + ))} + +
    + {expanded && } +
    + ) +} diff --git a/packages/experimental/client-ui-forge/src/client/FileLabel.module.css b/packages/experimental/client-ui-forge/src/client/FileLabel.module.css new file mode 100644 index 0000000000..dcee927f92 --- /dev/null +++ b/packages/experimental/client-ui-forge/src/client/FileLabel.module.css @@ -0,0 +1,51 @@ +/* A changed file's name, then its directory in smaller subdued type. */ +.label { + display: flex; + min-width: 0; +} + +/* The name keeps its natural width; the directory takes only the space left, so it shortens first. */ +.parts { + display: flex; + align-items: baseline; + gap: 6px; + min-width: 0; + overflow: hidden; + white-space: nowrap; +} + +.name { + flex: 0 1 auto; + color: var(--dsw-alias-label-primary); +} + +.directory { + flex: 1 1 0; + color: var(--dsw-alias-label-tertiary); + font-size: 11px; +} + +.shortened { + display: flex; + min-width: 0; + overflow: hidden; +} + +.head { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} + +.tail { + flex: none; +} + +.visuallyHidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} diff --git a/packages/experimental/client-ui-forge/src/client/FileLabel.tsx b/packages/experimental/client-ui-forge/src/client/FileLabel.tsx new file mode 100644 index 0000000000..672e3f2760 --- /dev/null +++ b/packages/experimental/client-ui-forge/src/client/FileLabel.tsx @@ -0,0 +1,66 @@ +/** + * The label of a changed file in the files menu, the local changes list, and a + * file's header: its name, then its directory in smaller subdued type, with the + * complete path in a tooltip. + */ + +import type { ReactNode } from 'react' +import clsx from 'clsx' +import { Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' +import { pathPartsOf } from '@deepseek-ai/dsh-util-workspace-path' +import css from './FileLabel.module.css' + +/** Characters at the end of a name or directory that stay whole while its start shortens. */ +const KEPT_END = 12 + +/** Shortest start worth shortening; a text this much longer than its kept end is split. */ +const MIN_START = 4 + +/** Hover delay before the complete path shows, so moving across a list does not flash it. */ +const TOOLTIP_DELAY_MS = 400 + +/** Split off the end that stays whole, by characters so a surrogate pair is never cut. */ +function partsOf(text: string): { readonly start: string; readonly end: string } { + const characters = Array.from(text) + if (characters.length <= KEPT_END + MIN_START) return { start: text, end: '' } + return { start: characters.slice(0, -KEPT_END).join(''), end: characters.slice(-KEPT_END).join('') } +} + +/** One text whose start ends in an ellipsis when space runs out, while its end stays whole. */ +function Shortened({ text, className }: { readonly text: string; readonly className: string | undefined }): ReactNode { + const { start, end } = partsOf(text) + return ( + + {start} + {end !== '' && {end}} + + ) +} + +/** + * Render a changed file's name and directory. The directory gives up its space + * before the name does; assistive technology reads the complete path. + * @param props.path - the file's path in the repository. + * @param props.tooltip - the hover text, such as both paths of a renamed file; defaults to the path. + * @param props.className - class of the outer element, which callers size within their row. + * @returns the label. + */ +export function FileLabel({ path, tooltip, className }: { + readonly path: string + readonly tooltip?: string | undefined + readonly className?: string | undefined +}): ReactNode { + const { directory, name } = pathPartsOf(path) + const folder = directory.replace(/[/\\]$/, '') + return ( + + + + {path} + + + ) +} diff --git a/packages/experimental/client-ui-forge/src/client/ForgeTab.module.css b/packages/experimental/client-ui-forge/src/client/ForgeTab.module.css new file mode 100644 index 0000000000..ddebe94967 --- /dev/null +++ b/packages/experimental/client-ui-forge/src/client/ForgeTab.module.css @@ -0,0 +1,551 @@ +/* Forge tab body: one scrolling column with the checkout row on top and the + Session's change request cards below, at the sidebar's 13px row size. */ +.root { + --forge-fill: var(--dsw-static-neutral-50); + display: flex; + flex-direction: column; + gap: 10px; + height: 100%; + min-height: 0; + box-sizing: border-box; + padding: 12px; + overflow-y: auto; + color: var(--dsw-alias-label-primary); + font-size: var(--dsh-content-font-size-secondary, 13px); + line-height: 20px; +} + +:global(body[data-ds-dark-theme]) .root { + --forge-fill: var(--dsw-static-neutral-850); +} + +/* Loading and disconnected states of a panel without state yet. */ +.centered { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + height: 100%; + box-sizing: border-box; + padding: 24px; + color: var(--dsw-alias-label-secondary); + font-size: var(--dsh-content-font-size-secondary, 13px); + text-align: center; +} + +.disconnected { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + color: var(--dsw-alias-label-secondary); +} + +.checkout { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + padding: 8px 10px; + border-radius: var(--dsw-radius-sm); + background: var(--forge-fill); + color: var(--dsw-alias-label-secondary); + font-size: 12px; + line-height: 18px; +} + +.checkout:focus-visible { + outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); + outline-offset: 2px; +} + +/* The worktree the Session works in is gone. */ +.checkout[data-missing], +.checkout[data-missing] .checkoutBranch { + color: var(--dsw-alias-state-warn-primary); +} + +.checkoutName { + display: inline-flex; + align-items: center; + gap: 8px; + min-width: 0; + border-radius: var(--dsw-radius-sm); +} + +.checkoutIcon { + flex: none; +} + +.checkoutBranch { + min-width: 0; + overflow: hidden; + color: var(--dsw-alias-label-primary); + font-family: var(--ds-font-family-code); + text-overflow: ellipsis; + white-space: nowrap; +} + +.checkoutNote { + flex: none; + margin-left: auto; +} + +/* Smaller than the card actions, with negative block margins, so the row keeps the height of its text; + the descendant selector outranks `.iconButton`, which this button also carries. */ +.checkout .checkoutCopy { + width: 24px; + height: 24px; + margin: -3px -4px -3px 0; +} + +.section { + display: flex; + flex-direction: column; + gap: 8px; + min-width: 0; +} + +.sectionHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 4px 2px 0; +} + +.sectionTitle { + margin: 0; + min-width: 0; + overflow: hidden; + color: var(--dsw-alias-label-secondary); + font-size: 13px; + font-weight: 500; + line-height: 20px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.iconButton { + display: inline-flex; + flex: none; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + border: 0; + border-radius: var(--dsw-radius-sm); + background: transparent; + color: var(--dsw-alias-label-tertiary); + cursor: pointer; +} + +.iconButton:focus-visible { + background: var(--dsw-alias-interactive-bg-hover); + color: var(--dsw-alias-label-primary); +} + +@media (hover: hover) { + .iconButton:hover { + background: var(--dsw-alias-interactive-bg-hover); + color: var(--dsw-alias-label-primary); + } +} + +.banner { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 8px; + padding: 10px 12px; + border-radius: var(--dsw-radius-sm); + background: var(--forge-fill); + color: var(--dsw-alias-label-primary); +} + +.bannerDetail { + color: var(--dsw-alias-label-secondary); + font-size: 12px; + line-height: 18px; + overflow-wrap: anywhere; +} + +.bannerCommand { + max-width: 100%; + color: var(--dsw-alias-label-secondary); + font-family: var(--ds-font-family-code); + font-size: 12px; + line-height: 18px; + overflow-wrap: anywhere; +} + +.bannerAction { + flex: none; +} + +.empty { + padding: 32px 16px; + text-align: center; +} + +.emptyTitle { + margin: 0; + color: var(--dsw-alias-label-primary); +} + +.emptyHint { + margin: 4px 0 0; + color: var(--dsw-alias-label-tertiary); + font-size: 12px; + line-height: 18px; +} + +.list { + display: flex; + flex-direction: column; + gap: 8px; + margin: 0; + padding: 0; + list-style: none; +} + +.change { + min-width: 0; + border: 0.5px solid var(--dsw-alias-border-l2); + border-radius: var(--dsw-radius-md); + background: var(--dsw-alias-bg-layer-1); +} + +/* Glyph, title link with the number line below it, then the expanded card's + open, copy, and remove buttons and the expansion toggle. */ +.changeHead { + display: flex; + align-items: flex-start; + gap: 2px; + min-width: 0; + padding: 12px 8px 10px 14px; +} + +.changeHead > .glyph { + margin: 3px 10px 0 0; +} + +/* The buttons center on the title's first line. */ +.changeHead > .iconButton { + margin-top: -3px; +} + +.headText { + display: flex; + flex: 1; + flex-direction: column; + gap: 2px; + min-width: 0; + padding-top: 1px; +} + +.changeTitle { + display: -webkit-box; + overflow: hidden; + color: var(--dsw-alias-label-primary); + font-weight: 500; + line-height: 20px; + text-decoration: none; + overflow-wrap: anywhere; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + +.change:not([data-expanded]) .changeTitle { + -webkit-line-clamp: 1; +} + +@media (hover: hover) { + .changeTitle:hover { + color: var(--dsw-alias-label-primary); + text-decoration: underline; + text-underline-offset: 2px; + } +} + +.changeTitle:focus-visible, +.rowButton:focus-visible, +.checkoutName:focus-visible { + outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); + outline-offset: 1px; +} + +/* Number line below the title: the status while collapsed, the branches while expanded. */ +.meta { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; + padding: 1px 0; + color: var(--dsw-alias-label-tertiary); + font-size: 12px; + font-variant-numeric: tabular-nums; + line-height: 18px; +} + +/* Lifecycle glyph; drawn in the current color unless the state tints it. */ +.glyph { + display: inline-flex; + flex: none; +} + +.tinted { + color: var(--dsw-alias-label-tertiary); +} + +.tinted[data-tone='open'] { + color: var(--dsw-alias-state-success-primary); +} + +/* Orange, because the closed glyph already takes red. */ +.tinted[data-tone='blocked'] { + color: var(--dsw-alias-state-warn-primary); +} + +.tinted[data-tone='merged'] { + color: var(--dsw-alias-state-done-primary); +} + +.tinted[data-tone='closed'] { + color: var(--dsw-alias-state-error-primary); +} + +.chevron { + flex: none; + color: var(--dsw-alias-label-tertiary); + transition: transform 120ms ease; +} + +.chevronOpen { + transform: rotate(180deg); +} + +.ellipsis { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.diff { + display: inline-flex; + gap: 8px; + font-variant-numeric: tabular-nums; +} + +.added { + color: var(--dsw-alias-state-success-primary); +} + +.deleted { + color: var(--dsw-alias-state-error-primary); +} + +.ci { + display: inline-flex; + align-items: center; + gap: 5px; + color: var(--dsw-alias-label-secondary); +} + +.ci[data-tone='error'], +.flag[data-flag='changesRequested'], +.flag[data-flag='conflicting'] { + color: var(--dsw-alias-state-error-primary); +} + +.flag { + color: var(--dsw-alias-label-tertiary); +} + +.finished { + color: var(--dsw-alias-label-secondary); +} + +.finished[data-tone='merged'] { + color: var(--dsw-alias-state-done-primary); +} + +/* Read failure or unsupported host below the head, with its one action. */ +.notice { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 0 10px 10px 33px; + color: var(--dsw-alias-label-secondary); + font-size: 12px; + line-height: 18px; +} + +/* Expanded facts, one row each: an icon under the glyph, the label, the value, + and a chevron on a row that opens something. */ +.rows { + display: flex; + flex-direction: column; + gap: 2px; + padding: 4px 6px 6px; + border-top: 0.5px solid var(--dsw-alias-border-l2); +} + +.row { + display: grid; + grid-template-columns: 16px 32px minmax(0, 1fr) 12px; + align-items: center; + column-gap: 10px; + box-sizing: border-box; + width: 100%; + min-height: 34px; + margin: 0; + padding: 0 10px 0 8px; + border: 0; + border-radius: var(--dsw-radius-sm); + background: transparent; + color: var(--dsw-alias-label-primary); + font: inherit; + text-align: left; +} + +.rowWide { + grid-column: 2 / 4; +} + +.rowButton { + cursor: pointer; +} + +.rowButton[aria-expanded='true'] { + background: var(--dsw-alias-interactive-bg-hover); +} + +@media (hover: hover) { + .rowButton:hover { + background: var(--dsw-alias-interactive-bg-hover); + } +} + +.rowIcon { + display: inline-flex; + color: var(--dsw-alias-label-tertiary); +} + +.rowLabel { + color: var(--dsw-alias-label-secondary); +} + +.rowChevron { + color: var(--dsw-alias-label-tertiary); +} + +.rowValue { + display: inline-flex; + align-items: center; + gap: 6px; + min-width: 0; +} + +.rowValue[data-tone='error'] { + color: var(--dsw-alias-state-error-primary); +} + +.rowValue[data-tone='success'] { + color: var(--dsw-alias-state-success-primary); +} + +.rowValue[data-tone='merged'] { + color: var(--dsw-alias-state-done-primary); +} + +.rowValue[data-tone='closed'] { + color: var(--dsw-alias-label-secondary); +} + +/* The checks menu anchors to the whole row. */ +.checksAnchor { + display: block; +} + +.checksList { + min-width: 280px; +} + +.checkItem { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + min-width: 0; +} + +.checkTime { + flex: none; + margin-left: auto; + color: var(--dsw-alias-label-tertiary); + font-size: 12px; +} + +.checkGlyph { + display: inline-flex; + flex: none; + color: var(--dsw-alias-label-tertiary); +} + +.pass { + color: var(--dsw-alias-state-success-primary); +} + +.fail { + color: var(--dsw-alias-state-error-primary); +} + +.running { + color: var(--dsw-alias-state-warn-primary); +} + +/* The action of an open change request below its rows. */ +.footer { + display: flex; + align-items: center; + gap: 8px; + padding: 2px 14px 12px; +} + +.primaryAction { + height: 32px; + padding: 0 14px; + font-size: 13px; + line-height: 20px; +} + +/* A merge method row: its name with the note on what it does below. */ +.method { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.methodNote { + color: var(--dsw-alias-label-tertiary); + font-size: 12px; +} + +.titleIcon { + display: inline-flex; + flex: none; + color: var(--dsw-alias-label-secondary); +} + +.titleText { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/packages/experimental/client-ui-forge/src/client/ForgeTab.tsx b/packages/experimental/client-ui-forge/src/client/ForgeTab.tsx new file mode 100644 index 0000000000..55e115fadc --- /dev/null +++ b/packages/experimental/client-ui-forge/src/client/ForgeTab.tsx @@ -0,0 +1,251 @@ +/** Body of the forge sidebar tab: where the Session works, its local changes, and the change requests it created or pushed. */ + +import { useEffect, useState, type ReactNode } from 'react' +import { + Button, IconBranchOutlineRegular, IconCopyOutlineRegular, IconFolderCloseRegular, IconWarningTriangleOutlineRegular, StateDot, Tooltip, +} from '@deepseek-ai/dsh-client-ui-primitives' +import type { HostObservable, InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { RemoteHostFacts } from '@deepseek-ai/dsh-api-remotes/client' +import type { MergeRequest } from '@deepseek-ai/dsh-experimental-forge/types' +import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' +import type {} from '@deepseek-ai/dsh-client-ui-sidebar-right/client' +import type { ForgePanelChangeRequest, ForgePanelState } from '../types.ts' +import { changesAddress, type ChangesSource } from './changes-address.ts' +import { ChangeRequestCard, type ChangeRequestActions } from './ChangeRequestCard.tsx' +import { checkoutLabel } from './checkout.ts' +import { LocalChangesCard } from './LocalChangesCard.tsx' +import type { NS } from './locales.ts' +import { RefreshGlyph } from './RefreshGlyph.tsx' +import type { PanelSnapshot } from './source.ts' +import { defaultExpanded, openChangeRequestOf } from './status.ts' +import css from './ForgeTab.module.css' + +/** Business face of the tab body. */ +export interface ForgeTabInjected { + /** Panel snapshot of the Session and the Host facts; the renderer binds them as `usePanel` and `useHost`. */ + readonly hooks: { + readonly panel: HostObservable + readonly host: HostObservable + readonly locale: HostObservable + } + /** Open the stream again after it ended. */ + readonly reconnect: () => void + /** + * Reinspect the checkout, probe sign-in, and poll the change requests; + * settles once the Host has inspected and probed, after reporting a failure. + */ + readonly refresh: () => Promise + /** Remove a change request from the Session, offering to undo it. */ + readonly remove: (entry: ForgePanelChangeRequest) => void + /** Mark a draft ready for review; settles once the outcome is reported. */ + readonly markReady: (entry: ForgePanelChangeRequest) => Promise + /** Merge a change request as `request` describes; settles once the outcome is reported. */ + readonly merge: (entry: ForgePanelChangeRequest, request: MergeRequest) => Promise + /** Check the removed worktree of the Session out again, reporting the outcome. */ + readonly restore: () => void + /** Open a URL outside the app. */ + readonly openLink: (url: string) => void + /** Copy text to the clipboard and report the outcome. */ + readonly copyText: (text: string) => void + /** Copy a path to the clipboard and report the outcome. */ + readonly copyPath: (path: string) => void + /** Record whether the tab is visible, for the header entry's pressed state. */ + readonly reportVisible: (visible: boolean) => void + /** Ask the Session's Agent to create a change request for its changes, reporting the outcome. */ + readonly createChangeRequest: () => void +} + +/** Full props of the tab body. */ +export type ForgeTabProps = + Pick, 'useTabInfo' | 'sessionId'> + & InjectFace + & PropsLocale + +type Translate = ForgeTabProps['t'] + +function Checkout({ state, home, restore, copyPath, t }: { + readonly state: ForgePanelState + readonly home: string | undefined + readonly restore: () => void + readonly copyPath: (path: string) => void + readonly t: Translate +}): ReactNode { + const checkout = state.checkout + const label = checkoutLabel(state, home, t) + if (label === undefined) { + if (checkout.state === 'none') return
    {t('checkout.none')}
    + if (checkout.state !== 'failed') return null + return ( + +
    {t('checkout.failed')}
    +
    + ) + } + const copyLabel = t(label.worktree ? 'checkout.copyWorktreePath' : 'checkout.copyProjectPath') + return ( +
    + + + {label.missing + ? + : label.worktree + ? + : } + {label.detached && {t('checkout.detached')}} + {label.detached && label.name !== null && } + {label.name !== null && {label.name}} + + + {label.kind} + + + + {label.missing && ( + + )} +
    + ) +} + +function SignIn({ state, copyText, t }: { + readonly state: ForgePanelState + readonly copyText: (text: string) => void + readonly t: Translate +}): ReactNode { + const { repository, readiness } = state + if (repository === null || readiness === null || readiness.state === 'ready') return null + const host = repository.host + if (readiness.state === 'unavailable') { + return
    {t('signin.unavailable', { host, message: readiness.message ?? '' })}
    + } + const command = repository.forge === 'github' ? `gh auth login --hostname ${host}` : undefined + return ( +
    + {t('signin.title', { host })} + {command === undefined + ? readiness.message !== null && {readiness.message} + : ( + <> + {command} + + + )} +
    + ) +} + +/** + * Render the forge tab of one Session. The local changes card shows while the + * `branch` scope changes files and no open change request has the checked-out + * branch as its head. The only open change request starts expanded; once the + * user expands or collapses a card, the panel keeps that choice across later updates. + * @param props - tab information, panel snapshot and Host facts hooks, actions, and copy. + * @returns the tab body. + */ +export function ForgeTab({ + useTabInfo, sessionId, usePanel, useHost, useLocale, reconnect, refresh, remove, markReady, merge, restore, openLink, copyText, copyPath, + reportVisible, createChangeRequest, t, +}: ForgeTabProps): ReactNode { + const snapshot = usePanel(current => current) + const home = useHost(facts => facts.home) + const locale = useLocale(value => value) + const { tab } = useTabInfo() + const visible = tab.visible + const [chosen, setChosen] = useState | null>(null) + const [refreshing, setRefreshing] = useState(false) + useEffect(() => { + reportVisible(visible) + }, [reportVisible, visible]) + useEffect(() => () => { reportVisible(false) }, [reportVisible]) + const state = snapshot.state + const disconnected = snapshot.disconnected && ( +
    + {t('stream.failed')} + +
    + ) + if (state === null) return
    {disconnected || }
    + const viewChanges = (source: ChangesSource, path?: string): void => { + tab.actions.openResource(changesAddress({ sessionId, source }), path === undefined ? undefined : { params: { path } }) + } + const startRefresh = (): void => { + if (refreshing) return + setRefreshing(true) + void refresh().finally(() => { setRefreshing(false) }) + } + const actions: ChangeRequestActions = { + openLink, copyLink: copyText, retry: startRefresh, remove, markReady, merge, + viewChanges: (entry) => { viewChanges({ kind: 'change', url: entry.url }) }, + } + const local = state.local !== null && state.local.files.length > 0 && openChangeRequestOf(state) === undefined ? state.local : null + const expanded = chosen ?? defaultExpanded(state.changeRequests) + const toggle = (url: string): void => { + const next = new Set(expanded) + if (next.has(url)) next.delete(url) + else next.add(url) + setChosen(next) + } + const now = Date.now() + const count = state.changeRequests.length + return ( +
    + {disconnected} + + {local !== null && ( + { viewChanges({ kind: 'local' }, path) }} + create={createChangeRequest} + t={t} + /> + )} +
    +
    +

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

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

    {t('changes.empty')}

    +

    {t('changes.emptyHint')}

    +
    + ) + : ( +
      + {state.changeRequests.map(entry => ( + { toggle(entry.url) }} + now={now} + actions={actions} + locale={locale} + t={t} + /> + ))} +
    + )} +
    +
    + ) +} diff --git a/packages/experimental/client-ui-forge/src/client/ForgeTabTitle.tsx b/packages/experimental/client-ui-forge/src/client/ForgeTabTitle.tsx new file mode 100644 index 0000000000..9de4027a1f --- /dev/null +++ b/packages/experimental/client-ui-forge/src/client/ForgeTabTitle.tsx @@ -0,0 +1,21 @@ +/** Chip title of the forge tab: the change request glyph and the localized tab name. */ + +import type { ReactNode } from 'react' +import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' +import { ChangeRequestGlyph } from './glyphs.tsx' +import type { NS } from './locales.ts' +import css from './ForgeTab.module.css' + +/** + * Render the tab chip title in the current UI language. + * @param props - forge panel copy. + * @returns the glyph followed by the tab name. + */ +export function ForgeTabTitle({ t }: PropsLocale): ReactNode { + return ( + <> + + {t('tab.title')} + + ) +} diff --git a/packages/experimental/client-ui-forge/src/client/HeaderButton.module.css b/packages/experimental/client-ui-forge/src/client/HeaderButton.module.css new file mode 100644 index 0000000000..e958cfd64b --- /dev/null +++ b/packages/experimental/client-ui-forge/src/client/HeaderButton.module.css @@ -0,0 +1,29 @@ +/* Icon-only header entry: the shared compact Button owns the radius, height, + and interaction fills, as for the header's other actions; the status dot + sits at the glyph's top-right corner. */ +.trigger { + position: relative; + flex: none; + width: 28px; + padding: 0; + color: var(--dsw-alias-label-secondary); +} + +/* The forge tab is visible; the pressed fill outranks the Button's hover fill. */ +.trigger[aria-pressed='true'] { + color: var(--dsw-alias-label-primary); + background: var(--dsw-alias-interactive-bg-active); +} + +@media (hover: hover) { + .trigger[aria-pressed='true']:hover:not(:disabled) { + color: var(--dsw-alias-label-primary); + background: var(--dsw-alias-interactive-bg-active); + } +} + +.dot { + position: absolute; + top: 6px; + right: 6px; +} diff --git a/packages/experimental/client-ui-forge/src/client/HeaderButton.tsx b/packages/experimental/client-ui-forge/src/client/HeaderButton.tsx new file mode 100644 index 0000000000..de31c35706 --- /dev/null +++ b/packages/experimental/client-ui-forge/src/client/HeaderButton.tsx @@ -0,0 +1,68 @@ +/** Session header entry that shows or hides the forge tab and marks change requests that need attention. */ + +import type { ReactNode } from 'react' +import { Button, StateDot, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' +import type { HostObservable, InjectFace, PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' +import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' +import { ChangeRequestGlyph } from './glyphs.tsx' +import type { NS } from './locales.ts' +import type { PanelSnapshot } from './source.ts' +import { needsAttention } from './status.ts' +import css from './HeaderButton.module.css' + +/** Business face of the header entry. */ +export interface HeaderButtonInjected { + /** Panel snapshot and tab visibility of the Session; the renderer binds them as `usePanel` and `useVisible`. */ + readonly hooks: { readonly panel: HostObservable; readonly visible: HostObservable } + /** + * Hide the forge tab when it is visible, otherwise open or reveal it. + * @param visible - whether the tab is visible now. + */ + readonly toggle: (visible: boolean) => void +} + +/** Full props of the header entry. */ +export type HeaderButtonProps = InjectFace & PropsLocale + +/** + * Show the entry once the Session has bound change requests or its directory + * is known to be inside a Git checkout: after an inspection found one or + * failed, or while a later inspection runs for a directory whose repository + * is already known. + * @param snapshot - panel snapshot. + * @returns whether the entry shows. + */ +function shown(snapshot: PanelSnapshot): boolean { + const state = snapshot.state + if (state === null) return false + return state.changeRequests.length > 0 || state.checkout.state === 'ready' || state.checkout.state === 'failed' + || (state.checkout.state === 'pending' && state.repository !== null) +} + +/** + * Render the header entry. + * @param props - panel snapshot and visibility hooks, the toggle, and copy. + * @returns the entry, or nothing outside a Git checkout without bound change requests. + */ +export function HeaderButton({ usePanel, useVisible, toggle, t }: HeaderButtonProps): ReactNode { + const visible = usePanel(shown) + const attention = usePanel(snapshot => snapshot.state?.changeRequests.some(needsAttention) === true) + const pressed = useVisible(current => current) + if (!visible) return null + const label = t(attention ? 'header.attention' : 'header.label') + return ( + + + + ) +} diff --git a/packages/experimental/client-ui-forge/src/client/LocalChangesCard.tsx b/packages/experimental/client-ui-forge/src/client/LocalChangesCard.tsx new file mode 100644 index 0000000000..5669bb775d --- /dev/null +++ b/packages/experimental/client-ui-forge/src/client/LocalChangesCard.tsx @@ -0,0 +1,83 @@ +/** + * The panel card of the checkout's changes before a change request carries + * them: the files the `branch` scope changes, their size, which opens the + * changes view, and the action that asks the Agent for a change request. + */ + +import { useState, type ReactNode } from 'react' +import { Button, IconChevronDownOutlineRegular, IconListPenOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' +import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' +import type { ForgeLocalChanges } from '../types.ts' +import { Counts } from './DiffFile.tsx' +import { FileLabel } from './FileLabel.tsx' +import type { NS } from './locales.ts' +import css from './ChangesTab.module.css' + +/** Props of the local changes card. */ +export interface LocalChangesCardProps { + /** Files the `branch` scope changes and the ref it compares with. */ + readonly local: ForgeLocalChanges + /** Whether the card offers to create a change request. */ + readonly creatable: boolean + /** Open the changes view, scrolled to `path` when given. */ + readonly view: (path?: string) => void + /** Ask the Agent to create a change request for the changes. */ + readonly create: () => void + /** Forge panel copy. */ + readonly t: PropsLocale['t'] +} + +/** + * Render the local changes card; the file list starts collapsed. + * @param props - the changes, the view and create actions, and copy. + * @returns the card. + */ +export function LocalChangesCard({ local, creatable, view, create, t }: LocalChangesCardProps): ReactNode { + const [open, setOpen] = useState(false) + let additions = 0 + let deletions = 0 + for (const file of local.files) { + additions += file.additions + deletions += file.deletions + } + const count = local.files.length + return ( +
    +
    + + {t('changes.local.refs', { head: 'HEAD', base: local.base })} +
    +
    + + {creatable && ( + + )} +
    + {open && ( +
      + {local.files.map(file => ( +
    • + +
    • + ))} +
    + )} +
    + ) +} diff --git a/packages/experimental/client-ui-forge/src/client/RefreshGlyph.module.css b/packages/experimental/client-ui-forge/src/client/RefreshGlyph.module.css new file mode 100644 index 0000000000..f20132be0b --- /dev/null +++ b/packages/experimental/client-ui-forge/src/client/RefreshGlyph.module.css @@ -0,0 +1,22 @@ +.glyph { + display: inline-flex; +} + +.glyph[data-turning] { + animation: forge-refresh-turn 1s linear infinite; +} + +@keyframes forge-refresh-turn { + to { transform: rotate(360deg); } +} + +/* Reduced motion dims the glyph instead; the dim still fires the iteration events that end a turn. */ +@media (prefers-reduced-motion: reduce) { + .glyph[data-turning] { + animation-name: forge-refresh-dim; + } +} + +@keyframes forge-refresh-dim { + 50% { opacity: 0.4; } +} diff --git a/packages/experimental/client-ui-forge/src/client/RefreshGlyph.tsx b/packages/experimental/client-ui-forge/src/client/RefreshGlyph.tsx new file mode 100644 index 0000000000..56713e276b --- /dev/null +++ b/packages/experimental/client-ui-forge/src/client/RefreshGlyph.tsx @@ -0,0 +1,35 @@ +/** Refresh glyph that turns while a refresh runs. */ + +import { useState, type ReactNode } from 'react' +import { IconRefreshOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' +import css from './RefreshGlyph.module.css' + +/** Props of the refresh glyph. */ +export interface RefreshGlyphProps { + /** Whether the refresh runs. */ + readonly busy: boolean + /** Glyph size in pixels. */ + readonly size: number +} + +/** + * Render the refresh glyph. It turns while `busy` and, once `busy` ends, + * completes the turn under way, so a quick refresh still shows a whole turn + * and the glyph stops upright. Under reduced motion it dims and returns instead of turning. + * @param props - whether the refresh runs, and the glyph size. + * @returns the glyph. + */ +export function RefreshGlyph({ busy, size }: RefreshGlyphProps): ReactNode { + // Set when a turn starts and cleared at the first turn boundary after `busy` ends. + const [turning, setTurning] = useState(false) + return ( + { setTurning(true) }} + onAnimationIteration={() => { if (!busy) setTurning(false) }} + > + + + ) +} diff --git a/packages/experimental/client-ui-forge/src/client/SessionHoverForge.tsx b/packages/experimental/client-ui-forge/src/client/SessionHoverForge.tsx new file mode 100644 index 0000000000..5a4f69749e --- /dev/null +++ b/packages/experimental/client-ui-forge/src/client/SessionHoverForge.tsx @@ -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 & InjectFace + +/** + * 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 ( +
    + {worktree !== null && ( +
    + + + {worktree.branch} + {t('checkout.worktreeFrom', { base: worktree.from ?? worktree.baseCommit.slice(0, 7) })} + +
    + )} + {shown.map((entry) => { + const checks = rowChecks(entry) + return ( +
    + + + {entry.state === undefined ? entry.url : `#${entry.state.number} ${entry.state.title}`} + + + {stateLine(entry, t)} + + +
    + ) + })} + {omitted > 0 &&
    {t('list.more', { count: omitted })}
    } +
    + ) +} diff --git a/packages/experimental/client-ui-forge/src/client/SessionList.module.css b/packages/experimental/client-ui-forge/src/client/SessionList.module.css new file mode 100644 index 0000000000..d501a9823c --- /dev/null +++ b/packages/experimental/client-ui-forge/src/client/SessionList.module.css @@ -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; +} diff --git a/packages/experimental/client-ui-forge/src/client/SessionRowForge.tsx b/packages/experimental/client-ui-forge/src/client/SessionRowForge.tsx new file mode 100644 index 0000000000..1072148a89 --- /dev/null +++ b/packages/experimental/client-ui-forge/src/client/SessionRowForge.tsx @@ -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['t'] + +/** Copy of each checks outcome a row and its hover card name. */ +export const CHECKS_LABEL: Record, 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 & InjectFace + +/** + * 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 && ( + + + + )} + {top !== undefined && ( + + + {rowChecks(top) !== 'none' && } + + )} + + ) +} diff --git a/packages/experimental/client-ui-forge/src/client/WorktreeProgress.module.css b/packages/experimental/client-ui-forge/src/client/WorktreeProgress.module.css new file mode 100644 index 0000000000..eb700d6aff --- /dev/null +++ b/packages/experimental/client-ui-forge/src/client/WorktreeProgress.module.css @@ -0,0 +1,226 @@ +/* Worktree creation card in the composer context stack: the dock column and + panel surface of the Goal, Todo, and Queue cards. */ +.dock { + box-sizing: border-box; + width: calc( + 100% - + var(--dsh-composer-side-clearance) - + var(--dsh-composer-side-clearance) - + var(--dsh-composer-dock-inset) - + var(--dsh-composer-dock-inset) - + var(--dsh-composer-dock-inset) - + var(--dsh-composer-dock-inset) + ); + margin: 0 auto; +} + +.card { + position: relative; + isolation: isolate; + box-sizing: border-box; + display: flex; + flex-direction: column; + gap: 6px; + width: 100%; + max-width: calc(var(--dsh-composer-card-max-width) - 4 * var(--dsh-composer-dock-inset)); + margin: 0 auto; + padding: 10px 8px 6px 12px; + border: 0; + --dsw-elevation-stroke-color: var(--dsw-alias-border-l1); + border-radius: var(--dsw-radius-md); + box-shadow: var(--dsw-elevation-panel); + color: var(--dsw-alias-label-primary); + font-size: 13px; + line-height: 20px; + /* Elevated surface, as the menu background below makes it: the failure text + scrolls once it reaches its height cap, so the thumb takes the l2 pair. + Declared on the card, whose custom properties inherit down to the box that + scrolls (see ui-theme styles/scrollbar.css for the rebinding contract). */ + --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2); + --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); +} + +.card::before { + position: absolute; + z-index: -1; + inset: 0; + border-radius: inherit; + background: var(--dsw-specific-menu); + backdrop-filter: var(--dsw-menu-backdrop-filter); + content: ''; + pointer-events: none; +} + +.header { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + padding-right: 4px; +} + +.glyph { + flex: none; + color: var(--dsw-alias-label-tertiary); +} + +.warning { + flex: none; + color: var(--dsw-alias-state-warn-primary); +} + +.title { + flex: none; + font-weight: 500; +} + +.branch, +.code { + min-width: 0; + overflow: hidden; + color: var(--dsw-alias-label-tertiary); + font-family: var(--ds-font-family-code); + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.steps { + display: flex; + flex-direction: column; + gap: 2px; + margin: 0; + padding: 0 0 0 22px; + list-style: none; +} + +.step { + display: flex; + align-items: center; + gap: 8px; + min-height: 24px; + color: var(--dsw-alias-label-secondary); +} + +.step[data-state='running'] { + color: var(--dsw-alias-label-primary); +} + +.step[data-state='failed'] { + color: var(--dsw-alias-state-error-primary); +} + +.step svg { + flex: none; +} + +.done { + color: var(--dsw-alias-state-success-primary); +} + +.failed { + color: var(--dsw-alias-state-error-primary); +} + +/* An empty ring for a step that has not started, the size of the step glyphs. */ +.waiting { + flex: none; + box-sizing: border-box; + width: 14px; + height: 14px; + border: 1.5px solid var(--dsw-alias-label-dimmed); + border-radius: 50%; + corner-shape: round; +} + +.details { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 2px 12px; + margin: 2px 0 0 22px; + color: var(--dsw-alias-label-secondary); + font-size: 12px; + line-height: 18px; +} + +.details dt { + color: var(--dsw-alias-label-tertiary); +} + +.details dd { + margin: 0; +} + +.message { + max-height: 120px; + overflow-y: auto; + overflow-wrap: anywhere; + white-space: pre-wrap; +} + +.footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + min-height: 28px; +} + +.disclosure { + display: inline-flex; + align-items: center; + gap: 4px; + height: 28px; + padding: 0 6px; + border: 0; + border-radius: var(--dsw-radius-sm); + background: transparent; + color: var(--dsw-alias-label-tertiary); + font: inherit; + font-size: 12px; + cursor: pointer; +} + +.disclosure:focus-visible { + background: var(--dsw-alias-interactive-bg-hover); + color: var(--dsw-alias-label-secondary); +} + +@media (hover: hover) { + .disclosure:hover { + background: var(--dsw-alias-interactive-bg-hover); + color: var(--dsw-alias-label-secondary); + } +} + +.chevron, +.chevronOpen { + flex: none; + transition: transform 120ms ease; +} + +.chevronOpen { + transform: rotate(90deg); +} + +.actions { + display: inline-flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 4px; + margin-left: auto; +} + +/* Explanation and restore failure under the header of the removed-worktree card. */ +.note, +.error { + margin: 0 0 0 22px; + color: var(--dsw-alias-label-secondary); + font-size: 12px; + line-height: 18px; +} + +.error { + color: var(--dsw-alias-state-error-primary); + overflow-wrap: anywhere; +} diff --git a/packages/experimental/client-ui-forge/src/client/WorktreeProgress.tsx b/packages/experimental/client-ui-forge/src/client/WorktreeProgress.tsx new file mode 100644 index 0000000000..bc3cc19c71 --- /dev/null +++ b/packages/experimental/client-ui-forge/src/client/WorktreeProgress.tsx @@ -0,0 +1,280 @@ +/** + * Card above the composer while a Session's worktree is created, while a message + * waits after a failed or stopped attempt, and when its worktree is gone or + * being restored. + */ + +import { useState, type ReactNode } from 'react' +import { + Button, IconBranchOutlineRegular, IconCheckOutlineRegular, IconChevronRightOutlineRegular, IconCloseOutlineRegular, StateDot, +} from '@deepseek-ai/dsh-client-ui-primitives' +import type { HostObservable, InjectFace, PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' +import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' +import { assertNever } from '@deepseek-ai/dsh-util-values' +import type { ForgeMissingWorktree, ForgePanelWorktree, ForgeSessionWorktree, ForgeWorktreeRestoration } from '../types.ts' +import type { ForgePanelKey, NS } from './locales.ts' +import type { PanelSnapshot } from './source.ts' +import css from './WorktreeProgress.module.css' + +/** Business face of the card. */ +export interface WorktreeProgressInjected { + /** Panel snapshot of the Session; the renderer binds it as `usePanel`. */ + readonly hooks: { readonly panel: HostObservable } + /** Stop the running attempt a message waits for; the message keeps waiting. */ + readonly stop: () => void + /** Make another attempt at the worktree a message waits for. */ + readonly retry: () => void + /** Withdraw the worktree request, so the waiting message goes on without a worktree. */ + readonly withdraw: () => void + /** + * Check out the removed worktree again. + * @returns the failure text, or undefined once it is restored. + */ + readonly restore: () => Promise + /** Move the Session back to the source checkout of its removed worktree. */ + readonly leave: () => void +} + +/** Full props of the card. */ +export type WorktreeProgressProps = InjectFace & PropsLocale + +type Translate = WorktreeProgressProps['t'] + +type StepState = 'done' | 'running' | 'waiting' | 'failed' + +type Phase = 'prepare' | 'checkout' + +type StepList = readonly { readonly phase: Phase; readonly label: ForgePanelKey }[] + +const CREATION_STEPS: StepList = [ + { phase: 'prepare', label: 'creation.prepare' }, + { phase: 'checkout', label: 'creation.checkout' }, +] + +const RESTORATION_STEPS: StepList = [ + { phase: 'prepare', label: 'restoration.prepare' }, + { phase: 'checkout', label: 'creation.checkout' }, +] + +function stepState(current: 'prepare' | 'checkout', failed: boolean, phase: 'prepare' | 'checkout'): StepState { + if (phase === current) return failed ? 'failed' : 'running' + return phase === 'prepare' ? 'done' : 'waiting' +} + +function Steps({ steps, phase, failed, t }: { + readonly steps: StepList + readonly phase: Phase + readonly failed: boolean + readonly t: Translate +}): ReactNode { + return ( +
      + {steps.map(step => ( +
    • + + {t(step.label)} +
    • + ))} +
    + ) +} + +function StepIcon({ state }: { readonly state: StepState }): ReactNode { + switch (state) { + case 'done': return + case 'running': return + case 'failed': return + case 'waiting': return