diff --git a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.i18n.yaml index ef8926e7d2..7342572d23 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.i18n.yaml @@ -9,11 +9,11 @@ en: d94bb81479bfa4a1 zh: af44ceaecf84ee51 /agent-note-one-ordering-for-configuration-sources-and-what-a-discovered-file-may-not-decide/decision: - en: aea80ece13f2905b - zh: a880bc1a7839cf12 + en: f2e0559e9cf0eddd + zh: 704138e08a93979d /agent-note-one-ordering-for-configuration-sources-and-what-a-discovered-file-may-not-decide/consequences: - en: 41ebe22077560090 - zh: da3475e4c196c3f9 + en: 15d57cfa8c37ddc9 + zh: e16becb173d87579 /agent-note-one-ordering-for-configuration-sources-and-what-a-discovered-file-may-not-decide/alternatives-considered: en: ac43499cfe7b8495 zh: e5ab599e7d3ed753 diff --git a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md index aeb9a25081..5fb8f38a18 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md +++ b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md @@ -42,9 +42,9 @@ inherited process environment (read-only, wins) The launching environment wins because `DEEPSEEK_API_KEY=… dsh`, a CI secret, and a container `-e` are the one override an operator must be able to apply per run without editing machine state, and because it cannot be edited from inside it must be *visibly* read-only. Configuration is meant to carry only the *reference* — which name to resolve — and that name follows the non-secret ordering above. -**The project the harness is launched in is trusted, by default and without a prompt.** A checkout may carry its own endpoint, its own ordinary variables, and its own key; the key ranks below the managed store, so a key stored through the Models page is never displaced by one a checkout happens to contain. `LaunchEnvironmentSnapshot.getFrom(name, sources)` still searches only the layers a caller names, and omitting one is a refusal rather than a demotion — the mechanism exists for decisions where a layer must be unreachable; this decision includes the project layer. +**The project the harness is launched in is trusted, by default and without a prompt.** A checkout may carry its own ordinary variables and its own key, but not the launch-only names the next paragraph lists, which include the DeepSeek endpoints; the key ranks below the managed store, so a key stored through the Models page is never displaced by one a checkout happens to contain. `LaunchEnvironmentSnapshot.getFrom(name, sources)` still searches only the layers a caller names, and omitting one is a refusal rather than a demotion — the mechanism exists for decisions where a layer must be unreachable; this decision includes the project layer. -**Trust does not extend to changing the harness itself.** `loadLayeredEnv` rejects, at load and before anything is materialized, any `.env` that sets a variable governing how a process launches (`PATH`, `SHELL`, `NODE_OPTIONS`, `LD_PRELOAD`), which ambient program handles an operation (`EDITOR`, `PAGER`, `BROWSER`), what code a runtime executes before the program it was asked to run (`BASH_ENV`, `PERL5OPT`, `PYTHONSTARTUP`, `RUBYOPT`, `JAVA_TOOL_OPTIONS`, the Git hook commands), where model-visible instructions load from (the whole `DSH_*` namespace, `HOME`, `XDG_*`), or how the network is reached and trusted (proxy and CA variables). Matching is case-insensitive, so `https_proxy` is not a bypass. One exemption, recorded in [the proxy policy note](2026-08-27-outbound-proxy-policy.md): the four proxy names are accepted from `$DSH_HOME/.env`, which no `.env` can relocate, and still refused from the invoking directory's file. +**Trust does not extend to changing the harness itself.** `loadLayeredEnv` rejects, at load and before anything is materialized, any `.env` that sets a variable governing how a process launches (`PATH`, `SHELL`, `NODE_OPTIONS`, `LD_PRELOAD`), which ambient program handles an operation (`EDITOR`, `PAGER`, `BROWSER`), what code a runtime executes before the program it was asked to run (`BASH_ENV`, `PERL5OPT`, `PYTHONSTARTUP`, `RUBYOPT`, `JAVA_TOOL_OPTIONS`, the Git hook commands), where model-visible instructions load from (the whole `DSH_*` namespace, `HOME`, `XDG_*`), or how the network is reached and trusted (proxy, CA, and DeepSeek endpoint variables). Matching is case-insensitive, so `https_proxy` is not a bypass. Two exemptions accept names from `$DSH_HOME/.env`, which no `.env` can relocate, and still refuse them from the invoking directory's file: the four proxy names, recorded in [the proxy policy note](2026-08-27-outbound-proxy-policy.md), and `DEEPSEEK_BASE_URL` and `DEEPSEEK_SEARCH_BASE_URL`. The endpoint names choose where the model and search requests that carry the user's DeepSeek credential go. The profile patches in the same home can already set those endpoints as plugin `baseURL` fields, so accepting the names from the home file gives a writer of that directory nothing it lacks. The line is that these take effect with no user action, before any turn, outside the permission policy and the sandbox. `DSH_PERMISSION_MODE` would switch off the approvals that make trusting a project meaningful at all, and `BASH_ENV` runs a file of the project's choosing on every single `bash -c` the bash tool issues — the project's code running under the agent's policy is the deal; the project rewriting that policy is not. Enumerating these is a losing game one variable at a time, which is why the whole `DSH_*` namespace is denied rather than an audited subset, and why the list is organised by what a variable *does* rather than by which runtime owns it. There is no opt-out: an escape hatch would have to be readable from somewhere, and anything a discovered file could set is the hole itself. @@ -55,7 +55,7 @@ The line is that these take effect with no user action, before any turn, outside ## Consequences - The web credential form now takes effect against an older key in the user's `.env`; only a key exported in the launching shell still makes it read-only, and the diagnostic says so. -- A `.env` holding `DSH_*`, `PATH`, `BROWSER`, or — in the invoking directory — a proxy variable fails the launch instead of being applied. Developers keeping switches in a repository `.env` move them to their shell — a deliberate, loud break. +- A `.env` holding `DSH_*`, `PATH`, `BROWSER`, or — in the invoking directory — a proxy or DeepSeek endpoint variable fails the launch instead of being applied. Developers keeping switches in a repository `.env` move them to their shell — a deliberate, loud break. - Composition is no longer overridable by a stale shell endpoint. It is still overridable by a user's stored `settings.yaml`, which is the settings seam's layering and not something this note changes; the product CLI offers no flag above it, so a deployment that must win against stored settings owns its own bin or loader tree. - Not solved: the layers are still materialized into `process.env`, so ordinary project variables continue to reach child processes under the subprocess scrub. Bootstrap variables cannot come from a file at all; the environment package records the remaining subprocess reach as a limitation. diff --git a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md index 36c3dda624..66226e21da 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md @@ -43,9 +43,9 @@ inherited process environment (read-only, wins) 继承环境优先,因为 `DEEPSEEK_API_KEY=… dsh`、CI 机密与容器 `-e` 是运维必须能按次施加、且无需改动机器状态的那一种覆盖;而它无法从进程内部修改,就必须*可见地*只读。配置本应只携带*引用*——解析哪个名字——该名字本身遵循上面的非机密顺序。 -**harness 被启动于其中的项目默认可信,且不做询问。** 一个 checkout 可以携带自己的 endpoint、自己的普通变量和自己的密钥;密钥排在受管存储之下,因此通过 Models 页存下的密钥绝不会被 checkout 中恰好带有的那一个顶掉。`LaunchEnvironmentSnapshot.getFrom(name, sources)` 仍然只搜索调用方点名的层,省略某层仍是拒绝而不是降级——该机制供要求某一层不可达的决策使用;本决策包含项目层。 +**harness 被启动于其中的项目默认可信,且不做询问。** 一个 checkout 可以携带自己的普通变量和自己的密钥,但不能设置下一段列出的启动专用变量名,其中包括 DeepSeek 端点;密钥排在受管存储之下,因此通过 Models 页存下的密钥绝不会被 checkout 中恰好带有的那一个顶掉。`LaunchEnvironmentSnapshot.getFrom(name, sources)` 仍然只搜索调用方点名的层,省略某层仍是拒绝而不是降级——该机制供要求某一层不可达的决策使用;本决策包含项目层。 -**信任不延伸到改变 harness 本身。** `loadLayeredEnv` 会在加载时、且在物化任何内容之前,拒绝任何设置了下列变量的 `.env`:决定进程如何启动的(`PATH`、`SHELL`、`NODE_OPTIONS`、`LD_PRELOAD`)、决定由哪个环境程序处理一项操作的(`EDITOR`、`PAGER`、`BROWSER`)、决定运行时在执行被要求运行的程序之前先执行哪些代码的(`BASH_ENV`、`PERL5OPT`、`PYTHONSTARTUP`、`RUBYOPT`、`JAVA_TOOL_OPTIONS`、Git 的钩子命令)、决定模型可见指令从哪里加载的(整个 `DSH_*` 命名空间、`HOME`、`XDG_*`),以及决定网络如何访问以及如何建立信任的(proxy 与 CA 变量)。匹配不区分大小写,因此 `https_proxy` 不是绕过手段。唯一的豁免记录在[代理策略笔记](2026-08-27-outbound-proxy-policy.zh.md)中:四个代理名可从 `$DSH_HOME/.env` 接受——没有任何 `.env` 能挪动该文件——但仍拒绝来自调用目录文件的同名变量。 +**信任不延伸到改变 harness 本身。** `loadLayeredEnv` 会在加载时、且在物化任何内容之前,拒绝任何设置了下列变量的 `.env`:决定进程如何启动的(`PATH`、`SHELL`、`NODE_OPTIONS`、`LD_PRELOAD`)、决定由哪个环境程序处理一项操作的(`EDITOR`、`PAGER`、`BROWSER`)、决定运行时在执行被要求运行的程序之前先执行哪些代码的(`BASH_ENV`、`PERL5OPT`、`PYTHONSTARTUP`、`RUBYOPT`、`JAVA_TOOL_OPTIONS`、Git 的钩子命令)、决定模型可见指令从哪里加载的(整个 `DSH_*` 命名空间、`HOME`、`XDG_*`),以及决定网络如何访问以及如何建立信任的(proxy、CA 与 DeepSeek 端点变量)。匹配不区分大小写,因此 `https_proxy` 不是绕过手段。有两项豁免从 `$DSH_HOME/.env` 接受变量名——没有任何 `.env` 能挪动该文件——但仍拒绝来自调用目录文件的同名变量:四个代理名,记录在[代理策略笔记](2026-08-27-outbound-proxy-policy.zh.md)中;以及 `DEEPSEEK_BASE_URL` 与 `DEEPSEEK_SEARCH_BASE_URL`。这两个端点变量名决定携带用户 DeepSeek 凭据的模型请求与搜索请求发往何处。同一 home 中的 profile patch 本就能以插件 `baseURL` 字段设置这些端点,因此从 home 文件接受它们,不会给能写入该目录的一方任何它原本没有的能力。 这条界线在于:它们无需任何用户动作、在任何轮次开始之前、且在权限策略与沙箱之外就生效。`DSH_PERMISSION_MODE` 会关掉让「信任项目」根本成立的那道审批,而 `BASH_ENV` 会在 bash 工具每次发出 `bash -c` 时执行项目指定的文件——项目的代码在 agent(智能体)的策略下运行是约定,项目改写那份策略不是。一个变量一个变量地枚举是必输的游戏,所以整个 `DSH_*` 命名空间被拒绝而不是只拒绝一份经审查的子集,也所以这份清单是按变量*做什么*而不是按哪个运行时拥有它来组织的。不设逃生门:逃生门本身总得从某处读取,而任何被发现的文件能设置的东西,就是那个漏洞本身。 @@ -56,7 +56,7 @@ inherited process environment (read-only, wins) ## Consequences - Web 凭据表单现在能压过用户 `.env` 里更旧的密钥;只有在启动 shell 里 export 的密钥才会让它变成只读,诊断信息也会这么说。 -- 含 `DSH_*`、`PATH`、`BROWSER` 或(在调用目录中)proxy 变量的 `.env` 会导致启动失败而不是被应用。把开关放在仓库 `.env` 里的开发者需要改放到 shell——这是一次刻意且响亮的破坏。 +- 含 `DSH_*`、`PATH`、`BROWSER` 或(在调用目录中)proxy 或 DeepSeek 端点变量的 `.env` 会导致启动失败而不是被应用。把开关放在仓库 `.env` 里的开发者需要改放到 shell——这是一次刻意且响亮的破坏。 - composition 不再会被陈旧的 shell endpoint 覆盖。但它仍然会被用户已存的 `settings.yaml` 覆盖,这是 settings seam 的分层方式,本 Note 不改变它;产品 CLI 没有高于它的标志,因此需要压过已存 settings 的部署方要自带 bin 或 loader 配置树。 - 未解决的:各层仍然会被物化进 `process.env`,因此普通项目变量继续按子进程清洗规则抵达子进程。bootstrap 变量完全不能来自文件;环境包将其余变量仍可抵达子进程这一点记录为一项限制。 diff --git a/.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.i18n.yaml index 1169daf37a..636bd6815c 100644 --- a/.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.i18n.yaml @@ -9,8 +9,8 @@ en: 6379f0a2522f4c46 zh: c45ffeb5089be274 /agent-note-one-outbound-proxy-policy-installed-before-anything-can-request/decision: - en: 6435c336ab53ca0f - zh: 57070799a7061f85 + en: 5168301af05473ed + zh: 30b840cb41998bee /agent-note-one-outbound-proxy-policy-installed-before-anything-can-request/alternatives-considered: en: df6c078f239a11f5 zh: 914da26836aa373b diff --git a/.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.md b/.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.md index fb2d4ae3a6..338f7f192e 100644 --- a/.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.md +++ b/.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.md @@ -16,7 +16,7 @@ That sentence could not have worked anyway, for three measured reasons. `NODE_US **One policy, resolved once from the launch environment, installed as the global dispatcher.** `packages/util/http-proxy` resolves a `ProxyPolicy` and installs it in `runProfile` immediately after the environment snapshot is provided and before any entry mounts. Node's `fetch` resolves undici's global dispatcher, so every plain `fetch()` and every SDK that reaches `globalThis.fetch` is covered without touching its code — nine call sites at the time of writing, and every future one for free. `loadLayeredEnv` has exactly one caller and `apps/web` ships no bin, so this single site covers every profile including `sdk-minimal`, which does not layer over `base`. -Resolution reads the launcher's snapshot rather than `process.env`, which is what makes a proxy in `$DSH_HOME/.env` work — the capability the environment-variable approach cannot have. Only that file: `loadLayeredEnv` refuses a proxy name in the project `.env` exactly as it refuses `PATH` or `NODE_OPTIONS` there, because that file arrives with a clone and must not choose the harness's route. The home file is exempt for the four proxy names alone, and `DSH_HOME` is itself bootstrap-only, so no `.env` can point the exemption at a directory a repository controls. +Resolution reads the launcher's snapshot rather than `process.env`, which is what makes a proxy in `$DSH_HOME/.env` work — the capability the environment-variable approach cannot have. Only that file: `loadLayeredEnv` refuses a proxy name in the project `.env` exactly as it refuses `PATH` or `NODE_OPTIONS` there, because that file arrives with a clone and must not choose the harness's route. The home file is exempt for the four proxy names, and `DSH_HOME` is itself bootstrap-only, so no `.env` can point the exemption at a directory a repository controls. **A library in `util/`, not a plugin.** Transport policy has one answer per process: nothing to swap, and no scope narrower than the process to give one. The package exports functions and mounts nothing — `boot`, `web`, `subprocess`, and `workflow` all consume it, and `util/` is the group every other group may depend on. diff --git a/.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.zh.md b/.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.zh.md index bd48a6e6b5..78b019ae71 100644 --- a/.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.zh.md @@ -16,7 +16,7 @@ Node 内置的 `fetch` 会忽略 `HTTP_PROXY` 与 `HTTPS_PROXY`。开发者运 **一份策略,从启动环境解析一次,装为全局 dispatcher。** `packages/util/http-proxy` 解析出 `ProxyPolicy`,并在 `runProfile` 中于环境快照提供之后、任何 entry 挂载之前完成安装。Node 的 `fetch` 解析的正是 undici 的全局 dispatcher,因此每一处普通 `fetch()` 以及每一个最终落到 `globalThis.fetch` 的 SDK 都无需改动即被覆盖——撰写时是九个调用点,未来新增的也自动覆盖。`loadLayeredEnv` 只有一个调用方,且 `apps/web` 不提供 bin,因此这一处即覆盖全部 profile,包括不叠加 `base` 的 `sdk-minimal`。 -解析读取的是启动器的快照而非 `process.env`,这正是让 `$DSH_HOME/.env` 中的代理生效的原因——也是环境变量方案不可能具备的能力。仅限该文件:`loadLayeredEnv` 拒绝项目 `.env` 里的代理名,正如它在那里拒绝 `PATH` 或 `NODE_OPTIONS`,因为那个文件随 clone 一起到来,不得替 Harness 选择路由。home 文件仅对这四个代理名豁免,而 `DSH_HOME` 本身是 bootstrap-only,因此没有任何 `.env` 能把这份豁免指向仓库控制的目录。 +解析读取的是启动器的快照而非 `process.env`,这正是让 `$DSH_HOME/.env` 中的代理生效的原因——也是环境变量方案不可能具备的能力。仅限该文件:`loadLayeredEnv` 拒绝项目 `.env` 里的代理名,正如它在那里拒绝 `PATH` 或 `NODE_OPTIONS`,因为那个文件随 clone 一起到来,不得替 Harness 选择路由。home 文件对这四个代理名豁免,而 `DSH_HOME` 本身是 bootstrap-only,因此没有任何 `.env` 能把这份豁免指向仓库控制的目录。 **放在 `util/` 的库,而非插件。** 传输策略每个进程只有一个答案:没有可替换的实现,也没有比进程更窄的作用域可赋予。因此本包只导出函数、不挂载任何东西——`boot`、`web`、`subprocess` 与 `workflow` 都消费它,而 `util/` 正是其他所有组都可以依赖的那一组。 diff --git a/.agents/notes/implemented/architecture/2026-10-13-cua-continuous-hover.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-13-cua-continuous-hover.i18n.yaml index 04a78f70d3..1dbc38b5ae 100644 --- a/.agents/notes/implemented/architecture/2026-10-13-cua-continuous-hover.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-10-13-cua-continuous-hover.i18n.yaml @@ -9,10 +9,10 @@ en: cdc04139c8d69415 zh: a3fe3e78ada08d95 /agent-note-continuous-cua-hover-without-cross-call-ownership/decision: - en: 125c860c1c568960 - zh: 8a2ece1101358200 + en: 193acf8c23672457 + zh: e614884d52585b16 /agent-note-continuous-cua-hover-without-cross-call-ownership/alternatives-considered: - en: 18d917ee2e909b93 + en: df67e547bce2f289 zh: 5b5ad0530f138cda /agent-note-continuous-cua-hover-without-cross-call-ownership/consequences: en: 714789d0a472a308 diff --git a/.agents/notes/implemented/architecture/2026-10-13-cua-continuous-hover.md b/.agents/notes/implemented/architecture/2026-10-13-cua-continuous-hover.md index 2cce428cff..29adedf224 100644 --- a/.agents/notes/implemented/architecture/2026-10-13-cua-continuous-hover.md +++ b/.agents/notes/implemented/architecture/2026-10-13-cua-continuous-hover.md @@ -10,7 +10,7 @@ Hover interactions span movement, delayed content, observation and input inside ## Decision -An explicit Window exposes `mouseMove` for coordinates or an observed element ID. The result reports `hoverRoute` and `systemPointerMoved`. It never presses a mouse button; disabled controls can use system-pointer movement with `systemPointerHover` enabled. Completion, AX reads and screenshots do not restore a previous pointer position. Successive calls can visit intermediate points before entering a popup. Clicks deliver button events at their target without an off-window priming click. The [input-routing decision](2026-10-13-cua-hover-routes-and-coordinates.md) selects AX alternate UI before allowing opt-in system movement with foreground and mouse-hit checks. It never activates or raises the app implicitly. Other input or window changes can race that check. +An explicit Window exposes `mouseMove` for coordinates or an observed element ID. The result reports `hoverRoute` and `systemPointerMoved`. It never presses a mouse button. With session approval, system-pointer movement also works on disabled controls. Completion, AX reads and screenshots do not restore a previous pointer position. Successive calls can visit intermediate points before entering a popup. Clicks deliver button events at their target without an off-window priming click. The [input-routing decision](2026-10-13-cua-hover-routes-and-coordinates.md) requires foreground and mouse-hit checks for system movement. It never activates or raises the app implicitly. Other input or window changes can race that check. The [shared helper](2026-10-09-cua-shared-native-helper.md) serializes complete individual operations. It grants no application ownership across calls. Earlier arrival, a retained app object and Session lifetime do not establish task priority. Waiting for the model does not reserve the application. Explicit task coordination determines any required execution order. @@ -20,7 +20,7 @@ The [shared helper](2026-10-09-cua-shared-native-helper.md) serializes complete **Click or drag as hover** introduces button input that can activate a control, select text or dismiss a popup. Hover requires a separate unpressed movement operation. -**PID-directed virtual movement** can display Chromium hover content after prior interaction, but a fresh window can ignore it, and later pointer checks can dismiss the content. Native AppKit tracking also requires real pointer movement in the measured fixture. The system-pointer fallback avoids reporting this unreliable event route as continuous hover. +**PID-directed virtual movement** can display Chromium hover content after prior interaction, but a fresh window can ignore it, and later pointer checks can dismiss the content. Native AppKit tracking also requires real pointer movement in the measured fixture. The approved system-pointer route avoids reporting this unreliable event route as continuous hover. ## Consequences diff --git a/.agents/notes/implemented/architecture/2026-10-13-cua-continuous-hover.zh.md b/.agents/notes/implemented/architecture/2026-10-13-cua-continuous-hover.zh.md index cc2457a70a..22668bd215 100644 --- a/.agents/notes/implemented/architecture/2026-10-13-cua-continuous-hover.zh.md +++ b/.agents/notes/implemented/architecture/2026-10-13-cua-continuous-hover.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -显式窗口提供 `mouseMove`,接受窗口坐标或已观察元素的字符串 ID,结果用 `hoverRoute` 和 `systemPointerMoved` 报告执行路径。悬停不按下鼠标按钮,启用 `systemPointerHover` 后可通过系统鼠标悬停禁用控件。调用结束、AX 读取和截图不恢复先前的鼠标位置。进入浮层前可连续调用并经过中间位置。点击只在目标位置发送按键事件,不先在窗口外点击。 [输入路径决定](2026-10-13-cua-hover-routes-and-coordinates.zh.md)优先选择 AX 替代界面,只有启用该配置时才回退到经过前台和鼠标命中检查的系统移动,不隐式激活或抬起应用。其他输入或窗口变化仍可能与检查交错。 +显式窗口提供 `mouseMove`,接受窗口坐标或已观察元素的字符串 ID,结果用 `hoverRoute` 和 `systemPointerMoved` 报告执行路径。悬停不按下鼠标按钮,用户授权当前会话移动鼠标后,也可悬停在禁用的控件上。调用结束、AX 读取和截图不恢复先前的鼠标位置。进入浮层前可连续调用并经过中间位置。点击只在目标位置发送按键事件,不先在窗口外点击。 [输入路径决定](2026-10-13-cua-hover-routes-and-coordinates.zh.md)要求移动系统鼠标前检查前台应用和鼠标命中的窗口,不隐式激活或抬起应用。其他输入或窗口变化仍可能与检查交错。 [共享 helper](2026-10-09-cua-shared-native-helper.zh.md)串行执行完整的单次操作,不分配跨调用的应用所有权。先发起请求、持有 app 对象和 Session 寿命均不代表任务优先级。等待模型期间不占用应用。需要确定执行顺序时,由明确的任务协调决定。 diff --git a/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.i18n.yaml index 9775baf3a4..d510b342e3 100644 --- a/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.i18n.yaml @@ -9,8 +9,8 @@ en: f96bf1ebc0719fb0 zh: e9a1144f0e76572d /agent-note-background-hover-and-screenshot-coordinates/decision: - en: bdf4d3386521ce00 - zh: 96db93177ad96a76 + en: 36188669cfa9af11 + zh: f1f0cba1ebb18f49 /agent-note-background-hover-and-screenshot-coordinates/alternatives-considered: en: 5fc4beaf79761c50 zh: 4948333b0020be28 diff --git a/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.md b/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.md index c385525731..5d5dc2a785 100644 --- a/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.md +++ b/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.md @@ -10,7 +10,7 @@ Hover-only controls can sometimes be revealed without moving the user's pointer. ## Decision -Native hover first uses a same-window element or ancestor advertising both `AXShowAlternateUI` and `AXShowDefaultUI`. Entering its revealed descendants preserves the parent UI; leaving restores it. An unavailable route returns `HOVER_UNAVAILABLE` by default. Enabling `systemPointerHover` permits the real system pointer fallback. An AX action failure propagates because received effects may be uncertain. The result records the route and whether the system pointer moved. System movement requires the selected foreground app and AppKit's mouse hit test, which excludes input-transparent overlays. No route grants [cross-call ownership](2026-10-13-cua-continuous-hover.md). +By default, native hover uses a same-window element or ancestor advertising both `AXShowAlternateUI` and `AXShowDefaultUI`. Entering its revealed descendants preserves the parent UI; leaving restores it. An unavailable route returns `HOVER_UNAVAILABLE` by default. With `requireSystemPointerApproval` enabled, the first explicit `mouseMove(target, { useSystemPointer: true })` asks the user. Each physical move requires an explicit request, even after approval. The live session remembers the decision until the session closes or DSH restarts. This lets a caller reach pointer-tracking controls even when the same target also advertises AX alternate UI. The Host selects real input only for a move that requests it and meets the approval policy. Physical movement leaves the visibility of previously revealed controls to the application, so switching routes cannot dismiss a target before dispatch. An AX action failure propagates because received effects may be uncertain. The result records the route and whether the system pointer moved. System movement requires the selected foreground app and AppKit's mouse hit test, which excludes input-transparent overlays. No route grants [cross-call ownership](2026-10-13-cua-continuous-hover.md). Model-facing coordinate input defaults to pixels in the returned screenshot. `relative: true` accepts integer thousandths from 0 to 999 for `click`, `mouseMove`, `scroll` and `drag`; it does not apply to opaque element IDs. The Host owns the mapping to native window points. An application-wide observation refreshes its inventory; a scoped observation preserves other windows' image mappings. Window resizing invalidates the old image, while movement preserves local coordinates. Relative endpoints remain inside the window and do not depend on screenshot resolution. diff --git a/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.zh.md b/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.zh.md index 0d3db32400..bedcc2740d 100644 --- a/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.zh.md +++ b/.agents/notes/implemented/architecture/2026-10-13-cua-hover-routes-and-coordinates.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -原生悬停优先寻找同一窗口内同时声明 `AXShowAlternateUI` 和 `AXShowDefaultUI` 的元素或祖先。进入其展开区域的子控件时保持父控件展开,离开后恢复。没有可用路径时默认返回 `HOVER_UNAVAILABLE`。启用 `systemPointerHover` 后才允许移动系统光标。AX 动作失败直接返回,因为已产生的效果可能不确定。结果记录实际路径及是否移动系统鼠标。系统移动要求所选应用处于前台,并使用 AppKit 的鼠标命中查询排除不接收输入的覆盖窗口。所有路径都遵守[跨调用不分配所有权](2026-10-13-cua-continuous-hover.zh.md)的决定。 +默认情况下,原生悬停会寻找同一窗口内同时声明 `AXShowAlternateUI` 和 `AXShowDefaultUI` 的元素或祖先。进入其展开区域的子控件时保持父控件展开,离开后恢复。没有可用路径时默认返回 `HOVER_UNAVAILABLE`。启用 `requireSystemPointerApproval` 时,首次显式调用 `mouseMove(target, { useSystemPointer: true })` 会询问用户。即使用户已授权,每次移动系统光标仍需显式请求。会话记住授权决定,直到关闭会话或重启 DSH。有些控件既支持 AX 备用界面,又跟踪真实鼠标。只有本次调用显式请求系统鼠标并满足授权条件时,Host 才使用真实鼠标触发跟踪。切换到系统鼠标后,由应用决定是否收起先前展开的控件,以免派发前目标消失。AX 动作失败直接返回,因为已产生的效果可能不确定。结果记录实际路径及是否移动系统鼠标。系统移动要求所选应用处于前台,并使用 AppKit 的鼠标命中查询排除不接收输入的覆盖窗口。所有路径都遵守[跨调用不分配所有权](2026-10-13-cua-continuous-hover.zh.md)的决定。 面向模型的坐标默认使用返回截图中的像素。`click`、`mouseMove`、`scroll` 和 `drag` 传入 `relative: true` 时接受 0~999 的千分比整数,仅用于坐标目标。Host 负责换算原生窗口 point。应用级观察刷新窗口清单,单窗口观察保留其他窗口的图片映射。窗口尺寸变化使旧图片失效,移动窗口保留局部坐标。相对坐标的端点保持在窗口内,不随截图分辨率改变。 diff --git a/.agents/notes/implemented/bug-fix/2026-11-07-forge-changes-view-responsiveness.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-11-07-forge-changes-view-responsiveness.i18n.yaml new file mode 100644 index 0000000000..e1e734251c --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-11-07-forge-changes-view-responsiveness.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-11-07-forge-changes-view-responsiveness.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/bug-fix/2026-11-07-forge-changes-view-responsiveness.md +/agent-note-forge-changes-view-responsiveness: + en: e809ffea0d880a4b + zh: 6725e6a9bd1c3bd1 +/agent-note-forge-changes-view-responsiveness/problem: + en: 77fab27f3d4a4a59 + zh: 46e0fa8b9ef978a7 +/agent-note-forge-changes-view-responsiveness/decision: + en: 40550cf129ab4c0c + zh: 2e9a3597d03ce185 +/agent-note-forge-changes-view-responsiveness/alternatives-considered: + en: c9833999164e8b67 + zh: 9459a95f307db854 +/agent-note-forge-changes-view-responsiveness/consequences: + en: 2d74147e306ed174 + zh: f1369020f27f92c0 diff --git a/.agents/notes/implemented/bug-fix/2026-11-07-forge-changes-view-responsiveness.md b/.agents/notes/implemented/bug-fix/2026-11-07-forge-changes-view-responsiveness.md new file mode 100644 index 0000000000..9ceef3f98f --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-11-07-forge-changes-view-responsiveness.md @@ -0,0 +1,41 @@ +# Agent Note: Forge changes view responsiveness + +Status: implemented + +English | [中文](2026-11-07-forge-changes-view-responsiveness.zh.md) + +## Problem + +The forge changes view of a large change request froze the browser. Opening #7985 (366 files, 37,563 drawn lines) kept the main thread busy for about five seconds, "expand all" for fifteen, and every later state change, such as a poll result, a tool result, a file jump, or a keystroke in the file filter, re-rendered every file section. While the view was visible, the whole app slowed down: two subject-less `:has()` rules in `ui-sidebar-files` made each DOM change restyle the entire document, and the view's hundred thousand nodes made each restyle cost 170–260 ms, so a streaming Agent turn kept the main thread saturated. + +## Decision + +**Scoped `:has()` rules.** The navigator's covering rules name their row through `data-files-navigator-row`, which both hosts of `sidebar.files.navigator` set, so a DOM change outside such a row schedules no document-wide invalidation. + +**Sections draw what changed and what the reader reaches.** `ChangesTab` passes each memoized `DiffFile` values whose identity survives its renders: one handler object, one comment-actions object, and per-file strings and booleans. Tab visibility is a prop of every section, so a visibility change redraws them all; leaving the view measured +57 ms against reading it from a React context, which the client packages forbid business components to do. The scrollport's reach tracker travels in the handler object. An expanded file holds a 22 px row per drawn line until its section first comes within 1200 px of the scrollport, then draws its lines and keeps them. After a jump, the browser's scroll anchoring keeps the target in place while the sections above it draw. + +**Highlighting in the drawing render.** A section highlights its lines in the same render that draws them, as before, so the drawn text is always the file's current text and a native selection keeps its text nodes. + +**Fewer serial reads.** A panel state equal to the published one reaches no follower. `changeRequestDiff` reads the patch and `.gitattributes` together. Snapshots carry the base commit as `baseSha`; `ForgeService.diff` passes it to the provider, and forge-github starts the compare read while its GraphQL read confirms the head. + +**Pixel measurement.** Whether a section is near the scrollport decides whether React renders its lines at all, which CSS cannot express: `content-visibility` only skips layout and paint of nodes that already exist. The client packages offer no injected measurement hook, so one `IntersectionObserver` on the scrollport measures it off the main thread. + +## Alternatives considered + +**`content-visibility: auto` alone** cut style work as much as scoping the rules did, but kept the React render and Shiki tokenization of every expanded file at open, about 3 s of script, and its placeholder sizes would need estimates for every collapsed and expanded section. + +**Full virtualization that also unmounts far sections** would bound the DOM after long scrolls, but a section holds a comment draft and a text selection, which unmounting loses. + +**Deferred idle-time highlighting** drew plain text and applied tokens once the page was idle and the scrollport still. It kept fast scrolling smooth, but tokens carry their own text, so a file whose content changed at the same path showed its previous text until the next pass, and replacing the plain text nodes collapsed a selection made in the meantime. Against it, highlighting while drawing raised the blocking time of scrolling all of #7985 from 22 ms to 1,634 ms and its longest frame gap from 34 ms to 817 ms; other operations stayed within 0.1 s. + +**The CSS Custom Highlight API** would paint token colors over unchanged text nodes and keep deferral without either defect, but it cannot apply the themes' bold and italic, and its cost over tens of thousands of ranges is unmeasured. + +**Re-aligning a jumped-to section every frame for a second** kept the last file flush with the end of the list, but it also pulled back any scroll it did not start, such as one from find in page; scroll anchoring already holds every other target at the top. + +**Highlighting in a Web Worker** removes tokenization from the main thread entirely, including a heavy grammar's first use, but the shared highlighter in `ui-primitives` owns Shiki; moving it is a separate change for every consumer. + +**A provider-private cache of base commits** would avoid the optional `baseSha` field, but it adds retained state without an owner that knows when a change request stops being watched. + +## Consequences + +Opening #7985 now takes 0.5 s of main-thread work instead of 5.2 s, and file jumps, filtering, polling, and Agent turns no longer produce long tasks. In exchange, the browser's find in page does not see lines of files the reader has not approached, and scrolling onto files in a slow grammar's language, such as C++, highlights them during the scroll, up to about 0.8 s of main-thread work when that grammar is first used. A diff read costs a second compare request when the base commit changed after the latest status. diff --git a/.agents/notes/implemented/bug-fix/2026-11-07-forge-changes-view-responsiveness.zh.md b/.agents/notes/implemented/bug-fix/2026-11-07-forge-changes-view-responsiveness.zh.md new file mode 100644 index 0000000000..fda2dc4b11 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-11-07-forge-changes-view-responsiveness.zh.md @@ -0,0 +1,41 @@ +# Agent Note: Forge 变更视图的响应速度 + +Status: implemented + +[English](2026-11-07-forge-changes-view-responsiveness.md) | 中文 + +## Problem + +大型变更请求的 forge 变更视图会让浏览器卡住。打开 #7985(366 个文件、37,563 个绘制行)时主线程忙约 5 秒,“全部展开”约 15 秒;之后的每次状态变化,例如轮询结果、工具结果、跳转文件或在文件筛选框中按键,都会重渲染所有文件段。视图可见时整个应用都变慢:`ui-sidebar-files` 中两条没有主体的 `:has()` 规则让每次 DOM 变化都重算整个文档的样式,而视图的十万个节点让每次重算耗时 170–260 ms,所以 Agent 流式输出的回合会让主线程一直饱和。 + +## Decision + +**限定 `:has()` 规则。** 导航器的遮盖规则通过 `data-files-navigator-row` 指定所在行,`sidebar.files.navigator` 的两个宿主都会设置它,因此这类行之外的 DOM 变化不再触发全文档失效。 + +**文件段只绘制变化的部分和读者接近的部分。** `ChangesTab` 传给每个 memo 化的 `DiffFile` 的值在多次渲染之间保持同一标识:一个处理函数对象、一个评论操作对象,以及每个文件的字符串和布尔值。标签页可见性作为每个文件段的参数传入,可见性变化时全部文件段都会重绘;离开视图实测比从 React context 读取多 57 ms,而客户端包禁止业务组件读取 context。滚动区域的接近跟踪对象放在处理函数对象中传递。展开的文件在它的段第一次进入滚动区域 1200 px 范围之前,按每个绘制行 22 px 占位,之后绘制各行并一直保留。跳转后,上方文件段绘制时,浏览器的滚动锚定让目标保持原位。 + +**在绘制的渲染中高亮。** 文件段与修复前一样,在绘制各行的同一次渲染中完成高亮,所以画出的始终是文件当前的文本,原生选区所在的文本节点也不会被替换。 + +**减少串行读取。** 与已发布状态相同的面板状态不会通知任何关注方。`changeRequestDiff` 同时读取补丁和 `.gitattributes`。快照把基准提交作为 `baseSha` 携带;`ForgeService.diff` 把它传给提供方,forge-github 在 GraphQL 读取确认 head 的同时就开始 compare 读取。 + +**像素测量。** 文件段是否接近滚动区域,决定 React 是否渲染它的各行,这一点 CSS 无法表达:`content-visibility` 只能跳过已有节点的布局与绘制。客户端包没有可注入的测量 hook,所以在滚动区域上用一个 `IntersectionObserver`,在主线程之外测量。 + +## Alternatives considered + +**只用 `content-visibility: auto`** 减少的样式工作与限定规则相当,但打开时仍要对每个展开文件做 React 渲染和 Shiki 分词,约 3 秒脚本时间,而且每个折叠和展开的段都需要估计占位尺寸。 + +**同时卸载远处文件段的完整虚拟化** 可以在长距离滚动后限制 DOM 规模,但文件段持有评论草稿和文本选区,卸载会丢失它们。 + +**空闲时延迟高亮** 先画纯文本,等页面空闲且滚动区域静止后再应用 token。它让快速滚动保持流畅,但 token 自带文本,同一路径内容变化后会显示旧文本直到下一轮高亮完成;替换纯文本节点还会清掉期间做出的选区。与它相比,绘制时高亮让滚动整个 #7985 的阻塞时间从 22 ms 增到 1,634 ms,最长帧间隔从 34 ms 增到 817 ms;其他操作的差异在 0.1 秒以内。 + +**CSS Custom Highlight API** 可以在不变的文本节点上绘制 token 颜色,保留延迟高亮又没有上述两个问题,但它无法应用主题中的粗体和斜体,几万个范围的绘制成本也没有测过。 + +**跳转后一秒内每帧重新对齐** 能让最后一个文件贴紧列表末端,但它也会把不是它发起的滚动拉回去,例如页内查找引起的滚动;而对其他目标,滚动锚定已经能让它们保持在顶部。 + +**在 Web Worker 中高亮** 能把分词完全移出主线程,包括重型语法的首次使用,但 Shiki 由 `ui-primitives` 中的共享高亮器持有;迁移它是影响所有使用方的另一项改动。 + +**由提供方私有缓存基准提交** 可以省去可选的 `baseSha` 字段,但会引入保留状态,而且没有一个知道变更请求何时不再被 watch 的所有者。 + +## Consequences + +打开 #7985 的主线程工作从 5.2 秒降到 0.5 秒,跳转文件、筛选、轮询和 Agent 回合不再产生长任务。代价是:浏览器的页内查找看不到读者尚未接近的文件中的行;滚动到慢语法(例如 C++)的文件时会在滚动中完成高亮,该语法首次使用时最多约 0.8 秒的主线程工作。最新状态之后基准提交有变化时,一次 diff 读取会多一次 compare 请求。 diff --git a/.agents/notes/implemented/feature/2026-09-13-explicit-worktree-creation.i18n.yaml b/.agents/notes/implemented/feature/2026-09-13-explicit-worktree-creation.i18n.yaml index b5373db47c..201593ebbe 100644 --- a/.agents/notes/implemented/feature/2026-09-13-explicit-worktree-creation.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-13-explicit-worktree-creation.i18n.yaml @@ -9,8 +9,8 @@ en: 12ab239ab0c58e31 zh: c32874442ce66041 /agent-note-explicit-worktree-creation/decision: - en: 92782d5964d1a021 - zh: 37b385836e9ae2b9 + en: 571a2d4c5352170b + zh: aeb12b849c5f8d31 /agent-note-explicit-worktree-creation/alternatives-considered: en: 72d42e892a37b94c zh: 92a14a51c6a89c53 diff --git a/.agents/notes/implemented/feature/2026-09-13-explicit-worktree-creation.md b/.agents/notes/implemented/feature/2026-09-13-explicit-worktree-creation.md index 33cabb9b8e..9facc68061 100644 --- a/.agents/notes/implemented/feature/2026-09-13-explicit-worktree-creation.md +++ b/.agents/notes/implemented/feature/2026-09-13-explicit-worktree-creation.md @@ -18,7 +18,7 @@ Git and directory creation use the mounted subprocess and sandbox providers with Leaving uses the shared working-directory operation and retains the checkout and branch. Failed setup can retain partial artifacts and reports their location. The service does not own a branch merge policy or a task association database. -The service records each creation as a `plugin:worktree/created` record and owns archive-triggered cleanup of those checkouts, as the job and Agent registries own their archive-admission families. A Session's inherited prefix decides whether it created a checkout or merely refers to one; cwd alone never grants cleanup ownership. Admission registers the attempt inside the Workspace write queue and returns; inspection reads cold Sessions through the projection cache or their log, without starting an Agent, and runs afterwards. Only a clean, unreferenced checkout is removed, without force and without deleting its branch; tracked changes and untracked files retain it, ignored files do not, matching Git's own non-force refusal. Processes and terminals are not consulted. A volatile `retainOnArchive` setting, read at admission, turns the cleanup off without unmounting the service; the Forge client edits it on the bundle's worktree row of the Plugins page because the service package has no Client half. Unarchiving cancels an unadmitted removal and waits for an admitted one, and directory entry into a checkout being removed waits for that removal. No startup sweep deletes previously archived worktrees. +The service records each creation as a `plugin:worktree/created` record and owns archive-triggered cleanup of those checkouts, as the job and Agent registries own their archive-admission families. A Session's inherited prefix decides whether it created a checkout or merely refers to one; cwd alone never grants cleanup ownership. Admission registers the attempt inside the Workspace write queue and returns; inspection reads cold Sessions through the projection cache or their log, without starting an Agent, and runs afterwards. Only a clean, unreferenced checkout is removed, without force and without deleting its branch; tracked changes and untracked files retain it, ignored files do not, matching Git's own non-force refusal. Inspection trusts `git status` alone, as peer agent products do: edits to skip-worktree or assume-unchanged files are discarded with the checkout, because sparse checkout sets skip-worktree on every file outside its cone and inspecting those bits would retain every sparse checkout. A checkout whose administrative directory holds submodule repositories is retained; a committed embedded repository is not inspected, and Git's refusal of that removal settles the attempt as failed. Processes and terminals are not consulted. A volatile `retainOnArchive` setting, read at admission, turns the cleanup off without unmounting the service; the Forge client edits it on the bundle's worktree row of the Plugins page because the service package has no Client half. Unarchiving cancels an unadmitted removal and waits for an admitted one, and directory entry into a checkout being removed waits for that removal. No startup sweep deletes previously archived worktrees. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-09-13-explicit-worktree-creation.zh.md b/.agents/notes/implemented/feature/2026-09-13-explicit-worktree-creation.zh.md index a3c7313ddd..bc9fc0670c 100644 --- a/.agents/notes/implemented/feature/2026-09-13-explicit-worktree-creation.zh.md +++ b/.agents/notes/implemented/feature/2026-09-13-explicit-worktree-creation.zh.md @@ -18,7 +18,7 @@ Git 与目录创建使用挂载的子进程及沙箱提供方,不扩大调用 离开使用共享工作目录操作,保留检出目录与分支。初始化失败可能留下部分产物,并报告其位置。该服务不拥有分支合并策略或任务关联数据库。 -服务把每次创建记录为 `plugin:worktree/created` Session 事件,并像 job 与 Agent 注册表各自拥有归档准入家族那样,拥有这些检出目录的归档触发清理。Session 的继承前缀决定它创建了某个检出目录还是仅引用它;当前工作目录本身不授予清理所有权。准入在 Workspace 写队列内登记尝试并返回;检查通过投影缓存或日志读取冷 Session,不启动 Agent,并在之后执行。只删除干净且无人引用的检出目录,不带 force 也不删除分支;跟踪文件改动与未跟踪文件保留目录,被忽略的文件不保留,与 Git 自身不带 force 时的拒绝条件一致。不咨询进程与终端。volatile 的 `retainOnArchive` 设置在准入时读取,无需卸载服务即可关闭清理;由于服务包没有 Client 半部,Forge 客户端在插件页该套件的 worktree 行上编辑它。取消归档会取消尚未获准的删除并等待已获准的删除,进入正在删除的检出目录会等待该删除结束。不在启动时扫描删除此前已归档的工作树。 +服务把每次创建记录为 `plugin:worktree/created` Session 事件,并像 job 与 Agent 注册表各自拥有归档准入家族那样,拥有这些检出目录的归档触发清理。Session 的继承前缀决定它创建了某个检出目录还是仅引用它;当前工作目录本身不授予清理所有权。准入在 Workspace 写队列内登记尝试并返回;检查通过投影缓存或日志读取冷 Session,不启动 Agent,并在之后执行。只删除干净且无人引用的检出目录,不带 force 也不删除分支;跟踪文件改动与未跟踪文件保留目录,被忽略的文件不保留,与 Git 自身不带 force 时的拒绝条件一致。检查与同类 Agent 产品一样只依据 `git status`:skip-worktree 或 assume-unchanged 文件的改动随检出目录一起丢弃,因为 sparse checkout 会给稀疏范围之外的每个文件设置 skip-worktree,检查这些标记会保留每个稀疏检出目录。管理目录中含子模块仓库的检出目录会保留;已提交的内嵌仓库不在检查范围内,Git 拒绝这类删除,本次尝试以失败结束。不咨询进程与终端。volatile 的 `retainOnArchive` 设置在准入时读取,无需卸载服务即可关闭清理;由于服务包没有 Client 半部,Forge 客户端在插件页该套件的 worktree 行上编辑它。取消归档会取消尚未获准的删除并等待已获准的删除,进入正在删除的检出目录会等待该删除结束。不在启动时扫描删除此前已归档的工作树。 ## Alternatives considered 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 index f779b7be73..83daa37ac0 100644 --- a/.agents/notes/implemented/feature/2026-10-09-forge-panel.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-10-09-forge-panel.i18n.yaml @@ -9,8 +9,8 @@ en: 344bcc7e9825bbc8 zh: c1d0f4b54f5b3786 /agent-note-change-request-panel-and-worktree-sessions/decision: - en: 66bde24e7c601eb6 - zh: 5091591f3dc1ac85 + en: 4695a1e4f8aa7a25 + zh: e17be0e36ea3eaa3 /agent-note-change-request-panel-and-worktree-sessions/deferred: en: 32ef8af85f4d2f66 zh: b82b67f94346399e diff --git a/.agents/notes/implemented/feature/2026-10-09-forge-panel.md b/.agents/notes/implemented/feature/2026-10-09-forge-panel.md index 2b852ad824..32b6fb4edc 100644 --- a/.agents/notes/implemented/feature/2026-10-09-forge-panel.md +++ b/.agents/notes/implemented/feature/2026-10-09-forge-panel.md @@ -28,7 +28,7 @@ The panel does not move a Session between existing worktrees. Removing a change 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 `git diff` using the Session's permissions and 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. -Review comments capture the file, side, range, complete hunk, and reviewed head when the user selects code. Agent references enter the existing composer quote mechanism, so the user controls batching and the sent message records exactly what the model receives. GitHub writes belong to the optional forge provider operation: preflight checks the current head, the write addresses that commit, and an ambiguous outcome blocks another write until the user reconciles it. Review drafts and inline records belong to a browser tab occurrence, not to the Session log or a GitHub pending review. Switching Sessions retains that occurrence; closing it discards the local state and rejects late write completions. Published GitHub comments remain external records. +Review comments capture the file, side, range, complete hunk, and reviewed head when the user selects code. Agent references enter the existing composer quote mechanism, so the user controls batching and the sent message records exactly what the model receives. GitHub writes belong to the optional forge provider operation: preflight checks the current head, the write addresses that commit, and an ambiguous outcome blocks another write until the user reconciles it. Inline records of Agent references and published comments, and the review summary, belong to a browser tab occurrence, not to the Session log; a pull request comment kept for a later review goes to the code host's pending review, as [the pending review decision](2026-11-05-forge-pending-review-drafts.md) records. Switching Sessions retains that occurrence; closing it discards the local state and rejects late write completions. Published GitHub comments remain external records. ## Deferred 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 index 660bd81d38..6e8438080c 100644 --- a/.agents/notes/implemented/feature/2026-10-09-forge-panel.zh.md +++ b/.agents/notes/implemented/feature/2026-10-09-forge-panel.zh.md @@ -28,7 +28,7 @@ Session 所在的 worktree 可能在 Session 之外被删除。每次检查都 面板还通过变更视图展示 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`。 -评审评论在用户选择代码时固定文件、侧、行范围、完整 hunk 与所评审的 head。Agent 引用进入既有输入框引用机制,由用户决定批量发送,发送后的消息记录模型实际收到的内容。GitHub 写入属于 forge 提供方的可选操作:预检核对当前 head,写入明确指向该提交;结果不明时阻止再次写入,直到用户核对。评审草稿与行内记录归浏览器页签实例所有,不进入 Session 日志,也不创建 GitHub 待提交评审。切换 Session 保留该实例;关闭时丢弃本地状态并拒绝迟到的写入结果。已发布的 GitHub 评论仍保留在外部平台。 +评审评论在用户选择代码时固定文件、侧、行范围、完整 hunk 与所评审的 head。Agent 引用进入既有输入框引用机制,由用户决定批量发送,发送后的消息记录模型实际收到的内容。GitHub 写入属于 forge 提供方的可选操作:预检核对当前 head,写入明确指向该提交;结果不明时阻止再次写入,直到用户核对。Agent 引用与已发布评论的行内记录以及评审总结归浏览器页签实例所有,不进入 Session 日志;留待之后一起提交的 PR 评论写入代码托管平台的待提交评审,见[待提交评审决策](2026-11-05-forge-pending-review-drafts.zh.md)。切换 Session 保留该实例;关闭时丢弃本地状态并拒绝迟到的写入结果。已发布的 GitHub 评论仍保留在外部平台。 ## Deferred diff --git a/.agents/notes/implemented/feature/2026-11-05-forge-pending-review-drafts.i18n.yaml b/.agents/notes/implemented/feature/2026-11-05-forge-pending-review-drafts.i18n.yaml new file mode 100644 index 0000000000..eb6f1712fb --- /dev/null +++ b/.agents/notes/implemented/feature/2026-11-05-forge-pending-review-drafts.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-11-05-forge-pending-review-drafts.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-11-05-forge-pending-review-drafts.md +/agent-note-pull-request-comment-drafts-in-the-code-host-s-pending-review: + en: 25b4da2d12f15b82 + zh: 6d9fec68e8bb0baa +/agent-note-pull-request-comment-drafts-in-the-code-host-s-pending-review/problem: + en: 2674866671bd0d4b + zh: e60d41a24d20d7bf +/agent-note-pull-request-comment-drafts-in-the-code-host-s-pending-review/decision: + en: 3df55965423b6d33 + zh: 2e8f0fbb4f7a311f +/agent-note-pull-request-comment-drafts-in-the-code-host-s-pending-review/alternatives-considered: + en: ef67e05cb9442f05 + zh: d4433ddd090a9cec +/agent-note-pull-request-comment-drafts-in-the-code-host-s-pending-review/consequences: + en: 198f6cf5a2bc2230 + zh: 92eb0a349356f100 diff --git a/.agents/notes/implemented/feature/2026-11-05-forge-pending-review-drafts.md b/.agents/notes/implemented/feature/2026-11-05-forge-pending-review-drafts.md new file mode 100644 index 0000000000..d0c4cf0d89 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-11-05-forge-pending-review-drafts.md @@ -0,0 +1,31 @@ +# Agent Note: Pull request comment drafts in the code host's pending review + +Status: implemented + +English | [中文](2026-11-05-forge-pending-review-drafts.zh.md) + +## Problem + +The [forge panel](2026-10-09-forge-panel.md) kept a pull request's comment drafts in a browser tab occurrence, published them as one review, and recorded what it published. Once the changes view shows the code host's review threads and lets the user reply to them, browser drafts leave four gaps. A reply cannot join a later review, because the drafts and the threads live in different places. A review the user started on the GitHub page, or in another browser, does not appear, and GitHub refuses a review submitted while the user has one. Drafts are lost with the tab and invisible on another device. And GitHub keeps one pending review per user and pull request, so two stores of one review would disagree. + +## Decision + +A pull request comment that the user keeps for a later review goes to the user's pending review on the code host through the provider's optional `writeReview`. The first such comment or reply starts the pending review at the head the user saw; later ones join it. The changes view reads the pending review with the threads, shows its comments as threads tagged Pending, and Submit review submits or discards that review with the summary and decision. Replies, resolving and unresolving, and editing and deleting the user's own comments use the same write, which re-checks the head and is never retried. + +The browser keeps what the code host does not hold: the review summary and decision until submission, the records of Agent references and published comments, and the outgoing direct write whose outcome needs confirmation. Comments on local changes never had a host destination; they go to the main input as before. Comment drafts an earlier panel kept in the browser stay readable: they show beside their code as browser drafts, can move into the pending review, and keep Submit review disabled while a pending review exists; without one, Submit review publishes them as one review as before. + +GitHub measurements on a private test repository decided three rules. While the user has a pending review, GitHub refuses a directly created review and a single comment (HTTP 422, "can only have one pending review per pull request"), so the view offers neither; a reply sent without the review's id still joins the review, so the view offers no reply at once and the provider refuses one. GitHub refuses Approve and Request changes on the user's own pull request, so the dialog offers only Comment there. + +## Alternatives considered + +**Keeping drafts in the browser tab.** It needs no host write until submission, but replies cannot join the same review, a review started elsewhere blocks submission without explanation, and the drafts do not follow the user to another device. + +**Keeping both stores.** Mirroring browser drafts into the pending review or the reverse gives one review two owners whose contents can diverge, and every edit would need reconciliation of two writes. + +**Recording drafts in the Session log.** The log records what reaches the model; review drafts are host state the model never sees, and other Sessions on the same pull request would not see them. + +**Retrying a write after an unknown outcome.** A lost response does not show whether GitHub applied the mutation. A submission of the pending review is kept as the write to reconcile, as a direct submission is, and blocks another submission until the user checks GitHub; a reply, resolution, or comment change reads the threads again and asks the user to check them before trying again. + +## Consequences + +A comment kept for later is visible on the GitHub page to its author before submission, and is lost only when the user discards the review. Each comment, reply, or thread change costs one GraphQL query for the head check and one mutation. Resolving a thread does not change the pull request's update time, so every write from the panel drops the panel's kept threads; another view of the same pull request shows the change at its next status or after `reviewRefreshMs`. A provider without `writeReview` reports no write permissions, so its threads stay read-only and its pull request comments are published at once or added to the input. Comment drafts from an earlier panel remain until the user moves, publishes, or deletes them, or closes their tab. diff --git a/.agents/notes/implemented/feature/2026-11-05-forge-pending-review-drafts.zh.md b/.agents/notes/implemented/feature/2026-11-05-forge-pending-review-drafts.zh.md new file mode 100644 index 0000000000..0dc9c99e2a --- /dev/null +++ b/.agents/notes/implemented/feature/2026-11-05-forge-pending-review-drafts.zh.md @@ -0,0 +1,31 @@ +# Agent Note: PR 评论草稿保存在代码托管平台的待提交评审中 + +Status: implemented + +[English](2026-11-05-forge-pending-review-drafts.md) | 中文 + +## Problem + +[forge 面板](2026-10-09-forge-panel.zh.md)原先把 PR 的评论草稿保存在浏览器页签实例中,作为一次评审发布,并记录已发布的内容。变更视图显示代码托管平台的评审讨论并允许用户回复之后,浏览器草稿留下四个缺口。回复无法加入之后的评审,因为草稿与讨论保存在不同的地方。用户在 GitHub 页面或另一个浏览器中开始的评审不会显示,而用户已有待提交评审时,GitHub 会拒绝直接提交的评审。草稿随页签丢失,在其他设备上也看不到。GitHub 每个用户在每个 PR 上只保留一个待提交评审,两处保存同一个评审会出现不一致。 + +## Decision + +用户留待之后一起提交的 PR 评论,通过提供方的可选能力 `writeReview` 写入代码托管平台上该用户的待提交评审。第一条这样的评论或回复在用户看到的 head 上开始待提交评审,之后的评论或回复加入其中。变更视图随讨论一起读取待提交评审,把其中的评论显示为带“待提交”标签的讨论;“提交评审”带着总结和评审决定提交或丢弃这个评审。回复、解决与取消解决、编辑与删除用户自己的评论也使用同一写操作;写操作会重新检查 head,且从不重试。 + +浏览器只保存代码托管平台不保存的内容:提交前的评审总结与评审决定、Agent 引用与已发布评论的记录,以及结果有待确认的直接写入。本地变更上的评论本来就没有托管平台上的去处,仍像以前一样进入主输入框。早期面板保存在浏览器中的评论草稿仍可读取:它们作为浏览器草稿显示在对应代码旁,可以加入待提交评审;存在待提交评审时,它们会让“提交评审”保持不可用;没有待提交评审时,“提交评审”像以前一样把它们作为一次评审发布。 + +在一个私有测试仓库上对 GitHub 的实测确定了三条规则。用户已有待提交评审时,GitHub 拒绝直接创建的评审和单条评论(HTTP 422,“每个 PR 只能有一个待提交评审”),因此视图两者都不提供;不带评审 id 发送的回复仍会加入该评审,因此视图不提供立即回复,提供方也拒绝这种回复。GitHub 拒绝用户批准或请求更改自己的 PR,因此在这种 PR 上,弹窗只提供“评论”。 + +## Alternatives considered + +**把草稿保存在浏览器页签中。** 提交之前不需要写入托管平台,但回复无法加入同一个评审,在别处开始的评审会无说明地阻止提交,草稿也无法随用户换到另一台设备。 + +**两处同时保存。** 把浏览器草稿同步到待提交评审,或反过来同步,会让一个评审有两个所有者,内容可能分歧,每次编辑都要核对两次写入。 + +**把草稿记录在 Session 日志中。** 日志记录的是到达模型的内容;评审草稿是模型永远看不到的托管平台状态,同一 PR 上的其他 Session 也看不到它们。 + +**结果不明时自动重试写操作。** 响应丢失并不能说明 GitHub 是否执行了 mutation。提交待提交评审时,与直接提交一样,这次提交会作为待核对的写入保留下来,在用户到 GitHub 核对之前阻止再次提交;回复、解决讨论或修改评论时,视图重新读取讨论,并请用户核对后再重试。 + +## Consequences + +留待之后提交的评论,在提交前其作者就能在 GitHub 页面上看到,只有用户丢弃评审时才会丢失。每条评论、回复或讨论状态的修改,都消耗一次用于检查 head 的 GraphQL 查询和一个 mutation。解决讨论不会改变 PR 的更新时间,因此面板发出的每个写操作都会丢弃面板保留的讨论;同一 PR 的其他视图在收到下一次状态时,或在 `reviewRefreshMs` 之后显示这一变化。未实现 `writeReview` 的提供方不报告任何写入权限,因此它的讨论保持只读,它的 PR 评论只能立即发布或添加到输入框。早期面板留下的评论草稿会一直保留,直到用户把它们加入评审、发布、删除,或关闭所在页签。 diff --git a/apps/web/tests/expected/forge-panel/discard-review.expected.md b/apps/web/tests/expected/forge-panel/discard-review.expected.md new file mode 100644 index 0000000000..a31563daba --- /dev/null +++ b/apps/web/tests/expected/forge-panel/discard-review.expected.md @@ -0,0 +1,19 @@ +- dialog "提交评审": + - heading "提交评审" [level=2] + - button "关闭" + - text: 待提交评论(1) + - group "丢弃评审": + - text: 丢弃待提交的评审及其中的 1 条评论?此操作无法撤销 + - button "取消" + - button "丢弃评审" + - textbox "评审总结…" + - status + - radio "评论" [checked] + - text: 评论 + - radio "批准" + - text: 批准 + - radio "请求更改" + - text: 请求更改 + - button "丢弃评审" [disabled] + - button "取消" + - button "提交评审" [disabled] diff --git a/apps/web/tests/expected/forge-panel/review-record.expected.md b/apps/web/tests/expected/forge-panel/review-record.expected.md index 3e678d6071..aef059b24e 100644 --- a/apps/web/tests/expected/forge-panel/review-record.expected.md +++ b/apps/web/tests/expected/forge-panel/review-record.expected.md @@ -1,4 +1,8 @@ -- region "评论 R2–4": - - text: 你的评论 R2–4 已发布到 GitHub 请核对边界条件 +- region "R2–4": + - text: R2–4 · 在 aaaaaaa 上 lin 1 个月前 + - button "lin 的评论操作" + - paragraph: 请核对边界条件 + - button "回复…" - button "添加到输入框" - - button "在 GitHub 查看" + - button "在 GitHub 中查看" + - button "解决讨论" diff --git a/apps/web/tests/expected/forge-panel/review-summary-required.expected.md b/apps/web/tests/expected/forge-panel/review-summary-required.expected.md index 3e384b28e1..2a56eddd38 100644 --- a/apps/web/tests/expected/forge-panel/review-summary-required.expected.md +++ b/apps/web/tests/expected/forge-panel/review-summary-required.expected.md @@ -10,5 +10,6 @@ - text: 批准 - radio "请求更改" [checked] - text: 请求更改 + - button "丢弃评审" - button "取消" - button "提交评审" [disabled] diff --git a/apps/web/tests/expected/forge-panel/review-thread-list.expected.md b/apps/web/tests/expected/forge-panel/review-thread-list.expected.md new file mode 100644 index 0000000000..bdfb2dc803 --- /dev/null +++ b/apps/web/tests/expected/forge-panel/review-thread-list.expected.md @@ -0,0 +1,15 @@ +- complementary "讨论列表,共 5 个": + - text: 讨论 5 + - button "关闭讨论列表" + - radiogroup "筛选讨论": + - radio "未解决 4" + - radio "已解决 1" + - radio "全部 5" [checked] + - heading "docs/panel.md" [level=3] + - button "文件级讨论 Add a screenshot." + - heading "src/panel.ts" [level=3] + - button "R2 · 1 条回复 Why two?" + - button "L15 The old fifteen was a number." + - button "R15 Settled. 已解决" + - button "原在 R9 · 在 fffffff 上 This line moved. 已过期" + - button "在 GitHub 中查看" diff --git a/apps/web/tests/expected/forge-panel/review-thread-message.expected.md b/apps/web/tests/expected/forge-panel/review-thread-message.expected.md new file mode 100644 index 0000000000..0ea820a2af --- /dev/null +++ b/apps/web/tests/expected/forge-panel/review-thread-message.expected.md @@ -0,0 +1,19 @@ +> ## Review Thread 1 +> File: src/panel.ts +> Side: R +> Lines: 2 +> Commit: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +> State: unresolved +> Source: https://github.com/octo/demo/pull/7#discussion_on-line_0 +> Diff hunk: +> ```diff +> @@ -1,5 +1,5 @@ +> export const n1 = 1 +> -export const n2 = 2 +> +export const n2 = 'two' +> ``` +> Comments: +> @lin (2026-10-09T00:00:00Z): +> > Why **two**? +> @octo (2026-10-09T00:00:00Z): +> > It reads better. diff --git a/apps/web/tests/expected/forge-panel/review-thread-reply.expected.md b/apps/web/tests/expected/forge-panel/review-thread-reply.expected.md new file mode 100644 index 0000000000..0a6b4b489c --- /dev/null +++ b/apps/web/tests/expected/forge-panel/review-thread-reply.expected.md @@ -0,0 +1,16 @@ +- region "R2": + - text: R2 · 在 aaaaaaa 上 lin 2 小时前 + - button "lin 的评论操作" + - paragraph: + - text: Why + - strong: two + - text: "?" + - text: octo 2 小时前 作者 + - paragraph: It reads better. + - textbox "回复…": 已改为字符串 + - button "取消" + - button "回复" + - button "回复选项" + - button "添加到输入框" + - button "在 GitHub 中查看" + - button "解决讨论" diff --git a/apps/web/tests/expected/forge-panel/review-threads.expected.md b/apps/web/tests/expected/forge-panel/review-threads.expected.md new file mode 100644 index 0000000000..ff5dc738a5 --- /dev/null +++ b/apps/web/tests/expected/forge-panel/review-threads.expected.md @@ -0,0 +1,91 @@ +- button "收起 panel.ts" [expanded] +- text: src/panel.ts +- button "panel.ts 有 3 条未解决的讨论,跳到下一条": 3 条未解决 +- text: +2 −2 +- checkbox "将 panel.ts 标记为已审阅" +- button "panel.ts 的更多操作" +- button "选择第 L1 行": "1" +- text: export const n1 = 1 +- button "选择第 R1 行": "1" +- text: export const n1 = 1 +- button "选择第 L2 行": "2" +- text: export const n2 = 2 +- button "选择第 R2 行": "2" +- text: export const n2 = 'two' +- region "R2": + - text: R2 · 在 aaaaaaa 上 lin 2 小时前 + - button "lin 的评论操作" + - paragraph: + - text: Why + - strong: two + - text: "?" + - text: octo 2 小时前 作者 + - paragraph: It reads better. + - button "回复…" + - button "添加到输入框" + - button "在 GitHub 中查看" + - button "解决讨论" +- button "选择第 L3 行": "3" +- text: export const n3 = 3 +- button "选择第 R3 行": "3" +- text: export const n3 = 3 +- button "选择第 L4 行": "4" +- text: export const n4 = 4 +- button "选择第 R4 行": "4" +- text: export const n4 = 4 +- button "选择第 L5 行": "5" +- text: export const n5 = 5 +- button "选择第 R5 行": "5" +- text: export const n5 = 5 +- button "6 行未变更" +- button "选择第 L12 行": "12" +- text: export const n12 = 12 +- button "选择第 R12 行": "12" +- text: export const n12 = 12 +- button "选择第 L13 行": "13" +- text: export const n13 = 13 +- button "选择第 R13 行": "13" +- text: export const n13 = 13 +- button "选择第 L14 行": "14" +- text: export const n14 = 14 +- button "选择第 R14 行": "14" +- text: export const n14 = 14 +- button "选择第 L15 行": "15" +- text: export const n15 = 15 +- button "选择第 R15 行": "15" +- text: export const n15 = 'fifteen' +- region "L15": + - text: L15 · 在 aaaaaaa 上 lin 2 小时前 + - button "lin 的评论操作" + - paragraph: The old fifteen was a number. + - button "回复…" + - button "添加到输入框" + - button "在 GitHub 中查看" + - button "解决讨论" +- text: 已解决的讨论 · lin +- button "展开" +- button "选择第 L16 行": "16" +- text: export const n16 = 16 +- button "选择第 R16 行": "16" +- text: export const n16 = 16 +- button "选择第 L17 行": "17" +- text: export const n17 = 17 +- button "选择第 R17 行": "17" +- text: export const n17 = 17 +- button "选择第 L18 行": "18" +- text: export const n18 = 18 +- button "选择第 R18 行": "18" +- text: export const n18 = 18 +- button "其余未变更的行" +- text: 1 条讨论已过期:所在代码已被后续提交修改 +- button "收起" [expanded] +- region "原在 R9": + - text: 原在 R9 · 在 fffffff 上 已过期 + - button "查看当时的文件" + - text: "@@ -1,5 +1,5 @@ export const n1 = 1 -export const n2 = 2 +export const n2 = 'two' lin 2 小时前" + - button "lin 的评论操作" + - paragraph: This line moved. + - button "回复…" + - button "添加到输入框" + - button "在 GitHub 中查看" + - button "解决讨论" diff --git a/apps/web/tests/expected/forge-panel/submit-review.expected.md b/apps/web/tests/expected/forge-panel/submit-review.expected.md index 0687f7ba99..8affefb880 100644 --- a/apps/web/tests/expected/forge-panel/submit-review.expected.md +++ b/apps/web/tests/expected/forge-panel/submit-review.expected.md @@ -10,5 +10,6 @@ - text: 批准 - radio "请求更改" [checked] - text: 请求更改 + - button "丢弃评审" - button "取消" - button "提交评审" diff --git a/apps/web/tests/forge-panel.e2e.ts b/apps/web/tests/forge-panel.e2e.ts index f05b31e50d..60642f1499 100644 --- a/apps/web/tests/forge-panel.e2e.ts +++ b/apps/web/tests/forge-panel.e2e.ts @@ -1,5 +1,6 @@ /** 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 { existsSync } from 'node:fs' import { access, chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { homedir, tmpdir } from 'node:os' import { basename, join } from 'node:path' @@ -49,8 +50,14 @@ const run = promisify(execFile) * `pr-N.diff`, or, when `pr-N.refused` exists, fails with that JSON error body * and the matching diagnostic as GitHub's HTTP 406 refusal. The raw contents API * serves `contents.json`, keyed by `path@commit`, and the commit listing of pull - * request 7 serves `commits.json`, oldest first. Every invocation is appended to - * `calls.jsonl`. + * request 7 serves `commits.json`, oldest first. The review thread read serves + * `threads.json` as its one page of threads, with the viewer `lin`, who may write + * to the repository and did not open either pull request, or no threads without it. Review mutations, which arrive as + * JSON on stdin, change what later reads report through `state.json`: replies, + * which join the pending review whenever one exists, resolution, comment edits and + * deletions, and the pending review of `lin` with its new threads, which a + * submission publishes. Each mutation is appended to + * `mutations.jsonl`, and every invocation to `calls.jsonl`. */ const GH = `#!/usr/bin/env node import { appendFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs' @@ -131,6 +138,56 @@ if (args[0] === 'auth' && args[1] === 'status') { process.stdout.write('github.com\\n Logged in to github.com account octo\\n') process.exit(0) } +const review = () => ({ pending: null, published: [], replies: {}, resolved: {}, bodies: {}, deleted: [], ...state.review }) +/** The signed-in reviewer, who did not open the pull requests. */ +const VIEWER = 'lin' +/** One review comment node by \`login\`, which the viewer may change when it is the viewer's own. */ +const commentNode = (id, body, login, pending) => ({ id, state: pending ? 'PENDING' : 'SUBMITTED', createdAt: '2026-10-09T01:00:00Z', + url: pending ? null : 'https://github.com/octo/demo/pull/7#discussion_' + id, body, + viewerCanUpdate: login === VIEWER, viewerCanDelete: login === VIEWER, author: { __typename: 'User', login, avatarUrl: null } }) +if (args[0] === 'api' && args[1] === 'graphql' && args.includes('--input')) { + const { query, variables } = JSON.parse(readFileSync(0, 'utf8')) + const field = query.match(/\\{\\s*(\\w+)\\(input:/)[1] + appendFileSync(join(here, 'mutations.jsonl'), JSON.stringify({ field, variables }) + '\\n') + const current = review() + const draft = thread => ({ id: 'new-' + (current.published.length + (current.pending?.threads.length ?? 0) + 1), + path: thread.path, side: thread.side, line: thread.line, startLine: thread.startLine ?? null, body: thread.body }) + let payload + if (field === 'addPullRequestReview') { + current.pending = { id: 'review-1', commit: variables.commit, threads: [] } + for (const thread of variables.threads ?? []) current.pending.threads.push(draft(thread)) + payload = { pullRequestReview: { id: 'review-1' } } + } else if (field === 'addPullRequestReviewThread') { + current.pending.threads.push(draft(variables)) + payload = { thread: { id: current.pending.threads.at(-1).id } } + } else if (field === 'addPullRequestReviewThreadReply') { + const replies = current.replies[variables.thread] ??= [] + // As on GitHub, a reply joins the pending review whenever one exists. + replies.push({ id: variables.thread + '-reply-' + replies.length, body: variables.body, pending: variables.review !== null || current.pending !== null }) + payload = { comment: { id: replies.at(-1).id } } + } else if (field === 'resolveReviewThread' || field === 'unresolveReviewThread') { + current.resolved[variables.thread] = field === 'resolveReviewThread' + payload = { thread: { id: variables.thread } } + } else if (field === 'updatePullRequestReviewComment') { + current.bodies[variables.comment] = variables.body + payload = { pullRequestReviewComment: { id: variables.comment } } + } else if (field === 'deletePullRequestReviewComment') { + current.deleted.push(variables.comment) + payload = { clientMutationId: null } + } else if (field === 'submitPullRequestReview' || field === 'deletePullRequestReview') { + if (field === 'submitPullRequestReview') current.published.push(...current.pending.threads) + for (const replies of Object.values(current.replies)) { + for (const reply of replies) if (reply.pending && field === 'deletePullRequestReview') current.deleted.push(reply.id) + for (const reply of replies) reply.pending = false + } + current.pending = null + payload = { pullRequestReview: { id: variables.review } } + } + state.review = current + save() + process.stdout.write(JSON.stringify({ data: { [field]: payload } })) + 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, databaseId = 101) => ({ @@ -141,7 +198,7 @@ if (args[0] === 'api' && args[1] === 'graphql') { const pulls = { 7: { id: 'pull-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), + updatedAt: '2026-10-09T00:00:00Z', closedAt: null, author: { login: 'octo' }, headRefName: 'feat', headRefOid: 'a'.repeat(40), baseRefOid: BASE, baseRefName: 'main', additions: 128, deletions: 14, mergeable: 'MERGEABLE', mergeStateStatus: 'BLOCKED', reviewDecision: 'REVIEW_REQUIRED', reviewThreads: { pageInfo: { hasNextPage: false, endCursor: null }, nodes: [{ isResolved: false }] }, commits: { nodes: [{ commit: { statusCheckRollup: { id: 'rollup-7', state: 'FAILURE', contexts: { @@ -152,7 +209,7 @@ if (args[0] === 'api' && args[1] === 'graphql') { 9: { id: 'pull-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: heads()[9], baseRefName: 'main', additions: 30, deletions: 2, mergeable: 'MERGEABLE', + headRefName: 'feat/merge', headRefOid: heads()[9], baseRefName: 'main', baseRefOid: BASE, additions: 30, deletions: 2, mergeable: 'MERGEABLE', mergeStateStatus: state.ready ? 'CLEAN' : 'BLOCKED', reviewDecision: 'APPROVED', reviewThreads: { pageInfo: { hasNextPage: false, endCursor: null }, nodes: [] }, commits: { nodes: [{ commit: { statusCheckRollup: { id: 'rollup-9', state: 'SUCCESS', contexts: { @@ -171,6 +228,41 @@ if (args[0] === 'api' && args[1] === 'graphql') { } } } } })) process.exit(0) } + if (query.includes('viewer { login }')) { + const number = Number(args.find(arg => arg.startsWith('number=')).slice('number='.length)) + const current = review() + const page = nodes => ({ pageInfo: { hasNextPage: false, endCursor: null }, nodes }) + const written = (thread, pending) => ({ + id: thread.id, isOutdated: false, isResolved: false, path: thread.path, diffSide: thread.side, startDiffSide: thread.startLine === null ? null : thread.side, + line: thread.line, startLine: thread.startLine, originalLine: thread.line, originalStartLine: thread.startLine, subjectType: 'LINE', + viewerCanReply: true, viewerCanResolve: true, viewerCanUnresolve: false, + root: { nodes: [{ diffHunk: '@@ -1,5 +1,5 @@', commit: { oid: pulls[number].headRefOid }, originalCommit: { oid: pulls[number].headRefOid } }] }, + comments: page([commentNode(thread.id + '-0', thread.body, VIEWER, pending)]), + }) + const stored = existsSync(join(here, 'threads.json')) ? JSON.parse(readFileSync(join(here, 'threads.json'), 'utf8')) : [] + const threads = [...stored, ...current.published.map(thread => written(thread, false)), + ...(current.pending?.threads ?? []).map(thread => written(thread, true))].map((thread) => { + const replies = (current.replies[thread.id] ?? []).map(reply => commentNode(reply.id, reply.body, VIEWER, reply.pending)) + const comments = [...thread.comments.nodes, ...replies].filter(comment => !current.deleted.includes(comment.id)) + .map(comment => ({ ...comment, body: current.bodies[comment.id] ?? comment.body })) + return { ...thread, isResolved: current.resolved[thread.id] ?? thread.isResolved, comments: page(comments) } + }).filter(thread => thread.comments.nodes.length > 0) + const pendingComments = threads.flatMap(thread => thread.comments.nodes).filter(comment => comment.state === 'PENDING').length + process.stdout.write(JSON.stringify({ data: { viewer: { login: VIEWER }, repository: { isArchived: false, viewerPermission: 'WRITE', + pullRequest: { id: 'pull-' + number, headRefOid: pulls[number].headRefOid, locked: false, + reviews: { nodes: current.pending === null ? [] : [{ id: current.pending.id, viewerDidAuthor: true, comments: { totalCount: pendingComments } }] }, + reviewThreads: page(threads) } } } })) + process.exit(0) + } + if (query.includes('reviews(states: PENDING')) { + const number = Number(args.find(arg => arg.startsWith('number=')).slice('number='.length)) + const pending = review().pending + // Every thread and comment the double serves belongs to the pull request it was read from. + const target = args.some(arg => arg.startsWith('target=')) ? { target: { pullRequest: { id: 'pull-' + number } } } : {} + process.stdout.write(JSON.stringify({ data: { repository: { pullRequest: { id: 'pull-' + number, headRefOid: pulls[number].headRefOid, + reviews: { nodes: pending === null ? [] : [{ id: pending.id, viewerDidAuthor: true }] } } }, ...target } })) + process.exit(0) + } if (query.includes('pullRequest(number: $number)')) { const number = Number(args.find(arg => arg.startsWith('number=')).slice('number='.length)) process.stdout.write(JSON.stringify({ data: { repository: { pullRequest: { headRefOid: pulls[number].headRefOid, baseRefOid: BASE } } } })) @@ -1021,7 +1113,10 @@ it.skipIf(MODE === 'record')('shows the local changes of the Session by scope an expect(narrowComment.x + narrowComment.width).toBeLessThanOrEqual(390) await page.setViewportSize({ width: 1680, height: 1000 }) await panelFile.getByRole('button', { name: '开始评审', exact: true }).click() - await panelFile.locator('[data-forge-review-thread="pending"]').getByText('请核对边界条件', { exact: true }).waitFor() + // The comment starts the pending review on GitHub, and the threads read again show it there. + const pendingThread = panelFile.locator('[data-forge-thread="new-1"]') + await pendingThread.getByText('请核对边界条件', { exact: true }).waitFor({ timeout: 20_000 }) + await pendingThread.getByText('待提交', { exact: true }).waitFor() await page.setViewportSize({ width: 1680, height: 500 }) const changesScroll = view.locator('[data-forge-changes-scroll]') await changesScroll.evaluate((element) => { element.scrollTop = element.scrollHeight }) @@ -1052,11 +1147,16 @@ it.skipIf(MODE === 'record')('shows the local changes of the Session by scope an await captureStableAria(page, '[role="dialog"][aria-label="提交评审"]', scaffold.workspaceCwd), MODE) await reviewDialog.getByRole('button', { name: '提交评审', exact: true }).click() await page.getByRole('alert').filter({ hasText: '已提交评审' }).waitFor() - await panelFile.locator('[data-forge-review-thread="published"]').getByText('请核对边界条件', { exact: true }).waitFor() - await compareOrRefreshGolden(join(EXPECTED, 'review-record.expected.md'), await captureStableAria(page, '[data-forge-review-thread="published"]', scaffold.workspaceCwd), MODE) - const published: unknown = JSON.parse((await readFile(join(root, 'reviews.jsonl'), 'utf8')).trim()) - expect(published).toEqual({ endpoint: 'repos/octo/demo/pulls/7/reviews', body: { commit_id: HEAD7, event: 'REQUEST_CHANGES', body: '需要补充边界检查', - comments: [{ path: 'src/panel.ts', side: 'RIGHT', line: 4, start_line: 2, start_side: 'RIGHT', body: '请核对边界条件' }] } }) + await expect.poll(() => pendingThread.getByText('待提交', { exact: true }).count(), { timeout: 20_000 }).toBe(0) + await compareOrRefreshGolden(join(EXPECTED, 'review-record.expected.md'), + await captureStableAria(page, '[data-forge-thread="new-1"]', scaffold.workspaceCwd), MODE) + const mutations = (await readFile(join(root, 'mutations.jsonl'), 'utf8')).trim().split('\n').map((line): unknown => JSON.parse(line)) + expect(mutations).toEqual([ + { field: 'addPullRequestReview', variables: { pull: 'pull-7', commit: HEAD7, + threads: [{ path: 'src/panel.ts', body: '请核对边界条件', line: 4, side: 'RIGHT', startLine: 2, startSide: 'RIGHT' }] } }, + { field: 'submitPullRequestReview', variables: { review: 'review-1', event: 'REQUEST_CHANGES', body: '需要补充边界检查' } }, + ]) + expect(existsSync(join(root, 'reviews.jsonl'))).toBe(false) await page.getByRole('button', { name: '退出全屏', exact: true }).click() await panelFile.getByRole('button', { name: '6 行未变更', exact: true }).click() await panelFile.getByText('export const n6 = 6', { exact: true }).first().waitFor({ timeout: 20_000 }) @@ -1080,7 +1180,7 @@ it.skipIf(MODE === 'record')('shows the local changes of the Session by scope an expect(await panelFile.evaluate((element, original) => element === original, originalFile)).toBe(true) await expect.poll(() => docsFile.getAttribute('data-expanded')).toBe(null) await expect.poll(() => scrollport.evaluate(element => element.scrollTop)).toBe(position) - expect(await panelFile.locator('[data-forge-review-thread="published"]').count()).toBe(1) + expect(await panelFile.locator('[data-forge-thread="new-1"]').count()).toBe(1) await page.setViewportSize({ width: 1680, height: 1000 }) await page.locator('[data-dockkit-tab]').filter({ hasText: '#7 的变更' }).locator('[data-dockkit-tab-close]').click() await expect.poll(() => view.count()).toBe(0) @@ -1175,6 +1275,8 @@ it.skipIf(MODE === 'record')('retains a wrapped code line and tree width across return { path: element.getAttribute('data-forge-diff-file'), line: line.dataset.newLine, offset: line.getBoundingClientRect().top - top, scrollTop: viewport.scrollTop } }) + // An expanded file draws its lines once the reader scrolls near it. + await file.scrollIntoViewIfNeeded() await code.waitFor() for (const width of [240, 320]) { if (width === 320) { @@ -1252,6 +1354,11 @@ it.skipIf(MODE === 'record')('uses horizontal arrows to navigate files while pre expect(await scroll.evaluate(element => document.activeElement === element)).toBe(true) await page.keyboard.press('ArrowRight') await expect.poll(() => tree.getByRole('button', { name: 'README.md', exact: true }).getAttribute('aria-current')).toBe('true') + // The last file draws its lines after the jump, and scroll anchoring adjusts the position at the next layout. + await c.locator('[data-review-code]').first().waitFor() + await scroll.evaluate(() => new Promise((resolve) => { + requestAnimationFrame(() => { requestAnimationFrame(() => { resolve() }) }) + })) const lastPosition = await scroll.evaluate(element => element.scrollTop) const lastBox = await c.boundingBox(), viewport = await scroll.boundingBox() if (lastBox === null || viewport === null) throw new Error('The last file and scroll viewport must be rendered') @@ -1712,7 +1819,7 @@ it.skipIf(MODE === 'record').each([120, 4000])('types a review comment beside %i expect(await file.getByRole('button', { name: '开始评审', exact: true }).isDisabled()).toBe(true) await input.fill('检查边界条件') await file.getByRole('button', { name: '开始评审', exact: true }).click() - await file.locator('[data-forge-review-thread="pending"]').getByText('检查边界条件', { exact: true }).waitFor() + await file.locator('[data-forge-thread]').getByText('检查边界条件', { exact: true }).waitFor({ timeout: 20_000 }) expect(tripwire.pageErrors).toEqual([]) }) @@ -1849,6 +1956,170 @@ it.skipIf(MODE === 'record')('keeps local viewed marks across reloads, clears a expect(tripwire.pageErrors).toEqual([]) }) +/** + * One review thread node of pull request 7 as the GraphQL thread read returns it, its comments by `lin`, the viewer, then + * `octo`, the pull request's author; the viewer may reply, resolve, and change their own comments. + */ +function threadNode( + id: string, fields: Record, bodies: readonly string[], commit: string = HEAD7, +): Record { + return { + id, isOutdated: false, isResolved: false, path: 'src/panel.ts', diffSide: 'RIGHT', startDiffSide: null, line: null, startLine: null, + originalLine: null, originalStartLine: null, subjectType: 'LINE', viewerCanReply: true, viewerCanResolve: true, viewerCanUnresolve: true, ...fields, + root: { nodes: [{ diffHunk: '@@ -1,5 +1,5 @@\n export const n1 = 1\n-export const n2 = 2\n+export const n2 = \'two\'', + commit: { oid: commit }, originalCommit: { oid: commit } }] }, + comments: { pageInfo: { hasNextPage: false, endCursor: null }, nodes: bodies.map((body, index) => ({ + id: `${id}-${index}`, state: 'SUBMITTED', createdAt: '2026-10-09T00:00:00Z', url: `${PULL}#discussion_${id}_${index}`, body, + viewerCanUpdate: index === 0, viewerCanDelete: index === 0, author: { __typename: 'User', login: index === 0 ? 'lin' : 'octo', avatarUrl: null }, + })) }, + } +} + +/** Threads of pull request 7: on new line 2, on old line 15, resolved on new line 15, outdated, and on the whole of `docs/panel.md`. */ +const THREADS = [ + threadNode('on-line', { line: 2, originalLine: 2 }, ['Why **two**?', 'It reads better.']), + threadNode('old-side', { diffSide: 'LEFT', line: 15, originalLine: 15 }, ['The old fifteen was a number.']), + threadNode('resolved', { isResolved: true, line: 15, originalLine: 15 }, ['Settled.']), + threadNode('outdated', { isOutdated: true, originalLine: 9 }, ['This line moved.'], 'f'.repeat(40)), + threadNode('whole-file', { path: 'docs/panel.md', subjectType: 'FILE' }, ['Add a screenshot.']), +] + +it.skipIf(MODE === 'record')('overlays the review threads of a change request and opens them from the thread list', async () => { + const { scaffold, page, root, tripwire } = await launchPanel('web-e2e-forge-review-threads', 'follow-up') + await writeFile(join(root, 'threads.json'), JSON.stringify(THREADS)) + // Comments were written two hours before the browser's clock, so their relative times stay fixed. + await page.clock.setFixedTime(Date.parse('2026-10-09T02:00:00Z')) + const { view, sessionId } = await openReview(page, scaffold) + const panel = view.locator('[data-forge-diff-file="src/panel.ts"]') + const onLine = panel.locator('[data-forge-thread="on-line"]') + await onLine.getByText('two', { exact: true }).waitFor({ timeout: 20_000 }) + // The thread follows the row of its last line, under the new side. + expect(await onLine.evaluate(card => card.previousElementSibling?.querySelector('[data-new-line]')?.textContent)).toBe('export const n2 = \'two\'') + expect(await panel.locator('[data-forge-thread="old-side"]').evaluate(card => card.previousElementSibling?.querySelector('[data-old-line]')?.textContent)) + .toBe('export const n15 = 15') + await panel.locator('[data-forge-thread="resolved"]').getByText('已解决的讨论 · lin', { exact: true }).waitFor() + await panel.locator('[data-forge-outdated-threads]').getByText('1 条讨论已过期:所在代码已被后续提交修改', { exact: true }).waitFor() + await view.locator('[data-forge-diff-file="docs/panel.md"] [data-forge-thread="whole-file"]').getByText('文件级讨论', { exact: true }).waitFor() + const count = panel.getByRole('button', { name: 'panel.ts 有 3 条未解决的讨论,跳到下一条', exact: true }) + await count.waitFor() + await view.getByRole('navigation').getByTitle('3 条未解决的讨论', { exact: true }).waitFor() + await compareOrRefreshGolden(join(EXPECTED, 'review-threads.expected.md'), + await captureStableAria(page, '[data-forge-diff-file="src/panel.ts"]', scaffold.workspaceCwd), MODE) + + // The count steps through the file's unresolved threads in display order: R2, L15, then the outdated one. + const centered = async (id: string): Promise => await panel.locator(`[data-forge-thread="${id}"]`).evaluate((card) => { + const viewport = card.closest('[data-forge-changes-scroll]')!.getBoundingClientRect() + const box = card.getBoundingClientRect() + return box.top + box.height / 2 > viewport.top && box.top + box.height / 2 < viewport.bottom + }) + for (const id of ['on-line', 'old-side', 'outdated']) { + await count.click() + await expect.poll(() => centered(id)).toBe(true) + } + expect(await view.locator('[data-forge-thread-list]').count()).toBe(0) + + await view.getByRole('button', { name: '讨论列表,共 5 个', exact: true }).click() + const list = view.getByRole('complementary', { name: '讨论列表,共 5 个' }) + await expect.poll(() => list.getByRole('radio', { checked: true }).allTextContents()).toEqual(['未解决4']) + await expect.poll(() => list.locator('[data-forge-thread-item]').evaluateAll(items => items.map(item => item.getAttribute('data-forge-thread-item')))) + .toEqual(['whole-file', 'on-line', 'old-side', 'outdated']) + await list.getByRole('radio', { name: /^已解决/ }).click() + await list.locator('[data-forge-thread-item="resolved"]').click() + // Opening a resolved thread unfolds it inside its file, and the docked list stays. + await panel.locator('[data-forge-thread="resolved"]').getByText('Settled.', { exact: true }).waitFor() + await list.getByRole('radio', { name: /^全部/ }).click() + await expect.poll(() => list.locator('[data-forge-thread-item]').count()).toBe(5) + await compareOrRefreshGolden(join(EXPECTED, 'review-thread-list.expected.md'), + await captureStableAria(page, '[data-forge-thread-list]', scaffold.workspaceCwd), MODE) + // A view narrower than 480px gives the list the files' whole width and closes it before opening a thread. + await view.evaluate((element) => { element.style.width = '420px'; element.style.flex = 'none' }) + await expect.poll(() => list.evaluate(element => [getComputedStyle(element).position, element.getBoundingClientRect().width])) + .toEqual(['absolute', 420]) + await list.locator('[data-forge-thread-item="on-line"]').click() + await list.waitFor({ state: 'detached' }) + await view.evaluate((element) => { element.style.width = ''; element.style.flex = '' }) + + await onLine.getByRole('button', { name: '添加到输入框', exact: true }).click() + await page.locator('[data-composer-input]').first().getByText('panel.ts R2', { exact: true }).first().waitFor() + const sent = scaffold.whenTurnSettled() + await page.locator('[data-composer-input]').first().press('Enter') + await sent + await page.getByText(FOLLOW_UP_REPLY, { exact: true }).waitFor() + // The quoted thread reaches the model only inside the ordinary logged user message. + const quotes = (await readPersistedEvents(scaffold, sessionId)).flatMap(event => event.type === 'user/message' + ? event.data.content.flatMap(block => block.type === 'text' && block.text.includes('## Review Thread') ? [block.text] : []) : []) + expect(quotes).toHaveLength(1) + await compareOrRefreshGolden(join(EXPECTED, 'review-thread-message.expected.md'), quotes.join('').trimEnd(), MODE) + const calls = (await readFile(join(root, 'calls.jsonl'), 'utf8')).split('\n').filter(line => line.length > 0).map(line => JSON.parse(line) as string[]) + const reads = calls.filter(args => args.some(arg => arg.startsWith('query=') && arg.includes('viewer { login }'))) + expect(reads.length).toBeGreaterThan(0) + expect(reads[0]).toEqual(expect.arrayContaining(['owner=octo', 'name=demo', 'number=7', 'first=100'])) + expect(tripwire.pageErrors).toEqual([]) +}) + +it.skipIf(MODE === 'record')('replies to, edits, deletes, and resolves review threads, and discards a pending review started from a reply', async () => { + const { scaffold, page, root, tripwire } = await launchPanel('web-e2e-forge-review-writes') + await writeFile(join(root, 'threads.json'), JSON.stringify(THREADS)) + await page.clock.setFixedTime(Date.parse('2026-10-09T02:00:00Z')) + const { view } = await openReview(page, scaffold) + const panel = view.locator('[data-forge-diff-file="src/panel.ts"]') + const onLine = panel.locator('[data-forge-thread="on-line"]') + await onLine.getByText('two', { exact: true }).waitFor({ timeout: 20_000 }) + + await onLine.getByRole('button', { name: '回复…', exact: true }).click() + await onLine.getByRole('textbox', { name: '回复…', exact: true }).fill('已改为字符串') + await compareOrRefreshGolden(join(EXPECTED, 'review-thread-reply.expected.md'), + await captureStableAria(page, '[data-forge-thread="on-line"]', scaffold.workspaceCwd), MODE) + await onLine.getByRole('button', { name: '回复', exact: true }).click() + await onLine.getByText('已改为字符串', { exact: true }).waitFor({ timeout: 20_000 }) + + await onLine.getByRole('button', { name: 'lin 的评论操作', exact: true }).first().click() + await page.getByRole('menuitem', { name: '编辑', exact: true }).click() + await onLine.getByRole('textbox', { name: '编辑评论', exact: true }).fill('Why a **word**?') + await onLine.getByRole('button', { name: '保存', exact: true }).click() + await onLine.getByText('word', { exact: true }).waitFor({ timeout: 20_000 }) + + await onLine.getByRole('button', { name: 'lin 的评论操作', exact: true }).last().click() + await page.getByRole('menuitem', { name: '删除', exact: true }).click() + await onLine.getByRole('group', { name: '删除这条评论?', exact: true }).getByRole('button', { name: '删除', exact: true }).click() + await expect.poll(() => onLine.getByText('已改为字符串', { exact: true }).count(), { timeout: 20_000 }).toBe(0) + + await onLine.getByRole('button', { name: '解决讨论', exact: true }).click() + await onLine.getByText('已解决的讨论 · lin', { exact: true }).waitFor({ timeout: 20_000 }) + + const oldSide = panel.locator('[data-forge-thread="old-side"]') + await oldSide.getByRole('button', { name: '回复…', exact: true }).click() + await oldSide.getByRole('textbox', { name: '回复…', exact: true }).fill('稍后一起提交') + await oldSide.getByRole('button', { name: '回复选项', exact: true }).click() + await page.getByRole('menuitem', { name: '开始评审', exact: true }).click() + await oldSide.getByText('待提交', { exact: true }).waitFor({ timeout: 20_000 }) + await view.getByRole('button', { name: '提交评审 (1)', exact: true }).click() + const dialog = page.getByRole('dialog', { name: '提交评审', exact: true }) + await dialog.getByRole('button', { name: '丢弃评审', exact: true }).click() + await compareOrRefreshGolden(join(EXPECTED, 'discard-review.expected.md'), + await captureStableAria(page, '[role="dialog"][aria-label="提交评审"]', scaffold.workspaceCwd), MODE) + await dialog.getByRole('group', { name: '丢弃评审', exact: true }).getByRole('button', { name: '丢弃评审', exact: true }).click() + await page.getByRole('alert').filter({ hasText: '已丢弃评审' }).waitFor() + await expect.poll(() => oldSide.getByText('稍后一起提交', { exact: true }).count(), { timeout: 20_000 }).toBe(0) + await view.getByRole('button', { name: '提交评审', exact: true }).waitFor() + + const mutations = (await readFile(join(root, 'mutations.jsonl'), 'utf8')).trim().split('\n').map((line): unknown => JSON.parse(line)) + expect(mutations).toEqual([ + { field: 'addPullRequestReviewThreadReply', variables: { thread: 'on-line', body: '已改为字符串', review: null } }, + { field: 'updatePullRequestReviewComment', variables: { comment: 'on-line-0', body: 'Why a **word**?' } }, + { field: 'deletePullRequestReviewComment', variables: { comment: 'on-line-reply-0' } }, + { field: 'resolveReviewThread', variables: { thread: 'on-line' } }, + { field: 'addPullRequestReview', variables: { pull: 'pull-7', commit: HEAD7, threads: null } }, + { field: 'addPullRequestReviewThreadReply', variables: { thread: 'old-side', body: '稍后一起提交', review: 'review-1' } }, + { field: 'deletePullRequestReview', variables: { review: 'review-1' } }, + ]) + // Every write checked the head of pull request 7 first. + const calls = (await readFile(join(root, 'calls.jsonl'), 'utf8')).split('\n').filter(line => line.length > 0).map(line => JSON.parse(line) as string[]) + expect(calls.filter(args => args.some(arg => arg.startsWith('query=') && arg.includes('reviews(states: PENDING') && !arg.includes('viewer { login }')))) + .toHaveLength(mutations.length - 1) + expect(tripwire.pageErrors).toEqual([]) +}) + it.skipIf(MODE === 'record')('keeps a narrow file header\'s viewed mark and menu inside its card and its name under the pointer', async () => { const { scaffold, page, project, tripwire } = await launchPanel('web-e2e-forge-narrow-header', 'normal', { branch: 'feat' }) const from = 'src/original-review-module-with-a-long-name.ts' diff --git a/docs/config-catalog.md b/docs/config-catalog.md index c177d064c4..e7d8ba3478 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1121,6 +1121,13 @@ export interface Config { * `testPaths` and before the built-in rules. */ interfacePaths?: string[] + /** Most UTF-8 bytes, as JSON, of change request review threads kept in memory for later reads at the same head commit and update time. */ + maxCachedReviewBytes?: number + /** + * Age in milliseconds at which review threads kept in memory are read from the code host again; a GitHub pull + * request's update time does not change when a thread is resolved. + */ + reviewRefreshMs?: number } ``` @@ -1156,15 +1163,13 @@ export interface Config { - `inject`: `computerUse` · `tools` · `systemPrompt` · `subprocess` · `agents` - `refs`: [`CuaConfig`](../packages/experimental/cua/src/index.ts) -- `source`: [`packages/experimental/cua-macos/src/index.ts:34`](../packages/experimental/cua-macos/src/index.ts) +- `source`: [`packages/experimental/cua-macos/src/index.ts:33`](../packages/experimental/cua-macos/src/index.ts) ```ts config-catalog /** Native preparation, source-build signing, operation bounds, and profile-local application grants. */ export interface Config extends CuaConfig { /** Interval in milliseconds between permission and position checks while the native guide is open. */ permissionPollIntervalMs: number - /** Allow mouseMove to move the visible system pointer when AX hover is unavailable. Off by default to avoid interrupting the user. */ - systemPointerHover: boolean /** * Source-build signing: `ad-hoc` (default) binds grants to the code hash; * an object selects an existing certificate in an explicitly configured keychain. @@ -1188,7 +1193,7 @@ export type SourceSigning = 'ad-hoc' | { ## `@deepseek-ai/dsh-experimental-forge` -- `source`: [`packages/experimental/forge/src/types.ts:273`](../packages/experimental/forge/src/types.ts) +- `source`: [`packages/experimental/forge/src/types.ts:432`](../packages/experimental/forge/src/types.ts) ```ts config-catalog /** Poll cadence and fetch deadline for watched change requests. */ @@ -1222,9 +1227,12 @@ export interface Config { hosts?: string[] /** Deadline in milliseconds for one `gh` invocation. */ timeoutMs?: number - /** Maximum captured bytes per `gh` output stream and combined JSON bytes of one complete check read. */ + /** Maximum captured bytes per `gh` output stream and combined JSON bytes of one complete check read or review read. */ maxOutputBytes?: number - /** Most review threads counted per pull request in one status read; a pull request with more reports a lower bound. */ + /** + * Most review threads counted per pull request in one status read, which reports a lower bound past it, and read in + * one review read, which reports itself truncated past it. + */ maxReviewThreads?: number /** Most commits one pull request commit listing returns; GitHub lists at most 250 commits of a pull request. */ maxCommits?: number @@ -1561,7 +1569,7 @@ export type PaidTranslationProvider = 'deepseek-account' | 'deepseek-official' - `inject`: `workingDirectory` · `fs` · `subprocess` · `sandbox` · `sandboxPolicy` · `sessionProjections` - `refs`: `Volatile` (`@deepseek-ai/cordis`) -- `source`: [`packages/experimental/worktree/src/index.ts:35`](../packages/experimental/worktree/src/index.ts) +- `source`: [`packages/experimental/worktree/src/index.ts:36`](../packages/experimental/worktree/src/index.ts) ```ts config-catalog /** Worktree location, generated names, executables, subprocess resource limits, and the archive cleanup switch. */ @@ -1580,7 +1588,11 @@ export interface Config { timeoutMs?: number /** Subprocess termination grace in milliseconds. */ graceMs?: number - /** Maximum captured bytes per subprocess output stream. */ + /** + * Maximum captured bytes per subprocess output stream; archive inspection + * streams `git status`, and inspection and restoration stream the worktree + * listing, so it bounds only their stderr. + */ maxOutputBytes?: number } ``` @@ -2391,7 +2403,7 @@ export type Config = Readonly> ## `@deepseek-ai/dsh-lsp-stdio` - `inject`: `fs` · `lsp` · `subprocess` -- `source`: [`packages/lsp/lsp-stdio/src/index.ts:82`](../packages/lsp/lsp-stdio/src/index.ts) +- `source`: [`packages/lsp/lsp-stdio/src/index.ts:83`](../packages/lsp/lsp-stdio/src/index.ts) ```ts config-catalog /** Plugin configuration: provider id → local language-server configuration. */ @@ -2990,7 +3002,7 @@ export interface Config { ## `@deepseek-ai/dsh-sandbox-local` -- `source`: [`packages/sandbox/sandbox-local/src/index.ts:45`](../packages/sandbox/sandbox-local/src/index.ts) +- `source`: [`packages/sandbox/sandbox-local/src/index.ts:63`](../packages/sandbox/sandbox-local/src/index.ts) ```ts config-catalog /** Plugin config. All optional — `static Config` supplies the defaults. */ @@ -3414,7 +3426,7 @@ export interface Config extends SessionTitleLlmConfig { ## `@deepseek-ai/dsh-shell-env` -- `source`: [`packages/shell/shell-env/src/index.ts:31`](../packages/shell/shell-env/src/index.ts) +- `source`: [`packages/shell/shell-env/src/index.ts:34`](../packages/shell/shell-env/src/index.ts) ```ts config-catalog /** Plugin config (all optional — the built-in facts resolve without defaults). */ @@ -4135,7 +4147,7 @@ export interface Config { ## `@deepseek-ai/dsh-tool-bash` - `inject`: `tools` · `shell` · `systemPrompt` · `shellEnv` · `workingDirectory` -- `source`: [`packages/shell/tool-bash/src/index.ts:37`](../packages/shell/tool-bash/src/index.ts) +- `source`: [`packages/shell/tool-bash/src/index.ts:40`](../packages/shell/tool-bash/src/index.ts) ```ts config-catalog /** Configuration for the bash tool. */ @@ -4165,7 +4177,7 @@ export interface Config { ## `@deepseek-ai/dsh-tool-bash-persistent` - `inject`: `tools` · `terminals` · `workingDirectory` -- `source`: [`packages/shell/tool-bash-persistent/src/index.ts:447`](../packages/shell/tool-bash-persistent/src/index.ts) +- `source`: [`packages/shell/tool-bash-persistent/src/index.ts:448`](../packages/shell/tool-bash-persistent/src/index.ts) ```ts config-catalog /** Configuration for the persistent Bash tool. */ @@ -4400,7 +4412,7 @@ export interface Config { ## `@deepseek-ai/dsh-tool-pwsh` - `inject`: `tools` · `shell` · `systemPrompt` · `shellEnv` · `workingDirectory` -- `source`: [`packages/shell/tool-pwsh/src/index.ts:54`](../packages/shell/tool-pwsh/src/index.ts) +- `source`: [`packages/shell/tool-pwsh/src/index.ts:57`](../packages/shell/tool-pwsh/src/index.ts) ```ts config-catalog /** Configuration for the pwsh tool. */ @@ -4512,7 +4524,7 @@ export interface Config { ## `@deepseek-ai/dsh-tool-str-replace-editor` - `inject`: `tools` · `fs` -- `source`: [`packages/fs/tool-str-replace-editor/src/index.ts:506`](../packages/fs/tool-str-replace-editor/src/index.ts) +- `source`: [`packages/fs/tool-str-replace-editor/src/index.ts:511`](../packages/fs/tool-str-replace-editor/src/index.ts) ```ts config-catalog /** Configuration for the string-replacement editor tool. */ @@ -4994,7 +5006,7 @@ export interface Config { ## `@deepseek-ai/dsh-working-directory` - `inject`: `fs` · `sessionProjections` · `systemPrompt` -- `source`: [`packages/session/working-directory/src/index.ts:67`](../packages/session/working-directory/src/index.ts) +- `source`: [`packages/session/working-directory/src/index.ts:70`](../packages/session/working-directory/src/index.ts) ```ts config-catalog /** Deployment default for Sessions without an original project directory. */ diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index a2a379cf99..8932eeed75 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -1123,6 +1123,13 @@ export interface Config { * `testPaths` and before the built-in rules. */ interfacePaths?: string[] + /** Most UTF-8 bytes, as JSON, of change request review threads kept in memory for later reads at the same head commit and update time. */ + maxCachedReviewBytes?: number + /** + * Age in milliseconds at which review threads kept in memory are read from the code host again; a GitHub pull + * request's update time does not change when a thread is resolved. + */ + reviewRefreshMs?: number } ``` @@ -1158,15 +1165,13 @@ export interface Config { - `inject`: `computerUse` · `tools` · `systemPrompt` · `subprocess` · `agents` - `refs`: [`CuaConfig`](../packages/experimental/cua/src/index.ts) -- `source`: [`packages/experimental/cua-macos/src/index.ts:34`](../packages/experimental/cua-macos/src/index.ts) +- `source`: [`packages/experimental/cua-macos/src/index.ts:33`](../packages/experimental/cua-macos/src/index.ts) ```ts config-catalog /** Native preparation, source-build signing, operation bounds, and profile-local application grants. */ export interface Config extends CuaConfig { /** Interval in milliseconds between permission and position checks while the native guide is open. */ permissionPollIntervalMs: number - /** Allow mouseMove to move the visible system pointer when AX hover is unavailable. Off by default to avoid interrupting the user. */ - systemPointerHover: boolean /** * Source-build signing: `ad-hoc` (default) binds grants to the code hash; * an object selects an existing certificate in an explicitly configured keychain. @@ -1190,7 +1195,7 @@ export type SourceSigning = 'ad-hoc' | { ## `@deepseek-ai/dsh-experimental-forge` -- `source`: [`packages/experimental/forge/src/types.ts:273`](../packages/experimental/forge/src/types.ts) +- `source`: [`packages/experimental/forge/src/types.ts:432`](../packages/experimental/forge/src/types.ts) ```ts config-catalog /** Poll cadence and fetch deadline for watched change requests. */ @@ -1224,9 +1229,12 @@ export interface Config { hosts?: string[] /** Deadline in milliseconds for one `gh` invocation. */ timeoutMs?: number - /** Maximum captured bytes per `gh` output stream and combined JSON bytes of one complete check read. */ + /** Maximum captured bytes per `gh` output stream and combined JSON bytes of one complete check read or review read. */ maxOutputBytes?: number - /** Most review threads counted per pull request in one status read; a pull request with more reports a lower bound. */ + /** + * Most review threads counted per pull request in one status read, which reports a lower bound past it, and read in + * one review read, which reports itself truncated past it. + */ maxReviewThreads?: number /** Most commits one pull request commit listing returns; GitHub lists at most 250 commits of a pull request. */ maxCommits?: number @@ -1563,7 +1571,7 @@ export type PaidTranslationProvider = 'deepseek-account' | 'deepseek-official' - `inject`: `workingDirectory` · `fs` · `subprocess` · `sandbox` · `sandboxPolicy` · `sessionProjections` - `refs`: `Volatile` (`@deepseek-ai/cordis`) -- `source`: [`packages/experimental/worktree/src/index.ts:35`](../packages/experimental/worktree/src/index.ts) +- `source`: [`packages/experimental/worktree/src/index.ts:36`](../packages/experimental/worktree/src/index.ts) ```ts config-catalog /** Worktree location, generated names, executables, subprocess resource limits, and the archive cleanup switch. */ @@ -1582,7 +1590,11 @@ export interface Config { timeoutMs?: number /** Subprocess termination grace in milliseconds. */ graceMs?: number - /** Maximum captured bytes per subprocess output stream. */ + /** + * Maximum captured bytes per subprocess output stream; archive inspection + * streams `git status`, and inspection and restoration stream the worktree + * listing, so it bounds only their stderr. + */ maxOutputBytes?: number } ``` @@ -2393,7 +2405,7 @@ export type Config = Readonly> ## `@deepseek-ai/dsh-lsp-stdio` - `inject`: `fs` · `lsp` · `subprocess` -- `source`: [`packages/lsp/lsp-stdio/src/index.ts:82`](../packages/lsp/lsp-stdio/src/index.ts) +- `source`: [`packages/lsp/lsp-stdio/src/index.ts:83`](../packages/lsp/lsp-stdio/src/index.ts) ```ts config-catalog /** Plugin configuration: provider id → local language-server configuration. */ @@ -2992,7 +3004,7 @@ export interface Config { ## `@deepseek-ai/dsh-sandbox-local` -- `source`: [`packages/sandbox/sandbox-local/src/index.ts:45`](../packages/sandbox/sandbox-local/src/index.ts) +- `source`: [`packages/sandbox/sandbox-local/src/index.ts:63`](../packages/sandbox/sandbox-local/src/index.ts) ```ts config-catalog /** Plugin config. All optional — `static Config` supplies the defaults. */ @@ -3416,7 +3428,7 @@ export interface Config extends SessionTitleLlmConfig { ## `@deepseek-ai/dsh-shell-env` -- `source`: [`packages/shell/shell-env/src/index.ts:31`](../packages/shell/shell-env/src/index.ts) +- `source`: [`packages/shell/shell-env/src/index.ts:34`](../packages/shell/shell-env/src/index.ts) ```ts config-catalog /** Plugin config (all optional — the built-in facts resolve without defaults). */ @@ -4137,7 +4149,7 @@ export interface Config { ## `@deepseek-ai/dsh-tool-bash` - `inject`: `tools` · `shell` · `systemPrompt` · `shellEnv` · `workingDirectory` -- `source`: [`packages/shell/tool-bash/src/index.ts:37`](../packages/shell/tool-bash/src/index.ts) +- `source`: [`packages/shell/tool-bash/src/index.ts:40`](../packages/shell/tool-bash/src/index.ts) ```ts config-catalog /** Configuration for the bash tool. */ @@ -4167,7 +4179,7 @@ export interface Config { ## `@deepseek-ai/dsh-tool-bash-persistent` - `inject`: `tools` · `terminals` · `workingDirectory` -- `source`: [`packages/shell/tool-bash-persistent/src/index.ts:447`](../packages/shell/tool-bash-persistent/src/index.ts) +- `source`: [`packages/shell/tool-bash-persistent/src/index.ts:448`](../packages/shell/tool-bash-persistent/src/index.ts) ```ts config-catalog /** Configuration for the persistent Bash tool. */ @@ -4402,7 +4414,7 @@ export interface Config { ## `@deepseek-ai/dsh-tool-pwsh` - `inject`: `tools` · `shell` · `systemPrompt` · `shellEnv` · `workingDirectory` -- `source`: [`packages/shell/tool-pwsh/src/index.ts:54`](../packages/shell/tool-pwsh/src/index.ts) +- `source`: [`packages/shell/tool-pwsh/src/index.ts:57`](../packages/shell/tool-pwsh/src/index.ts) ```ts config-catalog /** Configuration for the pwsh tool. */ @@ -4514,7 +4526,7 @@ export interface Config { ## `@deepseek-ai/dsh-tool-str-replace-editor` - `inject`: `tools` · `fs` -- `source`: [`packages/fs/tool-str-replace-editor/src/index.ts:506`](../packages/fs/tool-str-replace-editor/src/index.ts) +- `source`: [`packages/fs/tool-str-replace-editor/src/index.ts:511`](../packages/fs/tool-str-replace-editor/src/index.ts) ```ts config-catalog /** Configuration for the string-replacement editor tool. */ @@ -4996,7 +5008,7 @@ export interface Config { ## `@deepseek-ai/dsh-working-directory` - `inject`: `fs` · `sessionProjections` · `systemPrompt` -- `source`: [`packages/session/working-directory/src/index.ts:67`](../packages/session/working-directory/src/index.ts) +- `source`: [`packages/session/working-directory/src/index.ts:70`](../packages/session/working-directory/src/index.ts) ```ts config-catalog /** Deployment default for Sessions without an original project directory. */ diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index 591bcdd5ce..cea7bf07bb 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.i18n.yaml @@ -24,8 +24,8 @@ en: 16d67911ecaa50d3 zh: f5259b0fbf022db3 /development-guide/contributor-reference/environment-variables: - en: dba2c72ee6ffa930 - zh: b65c27fbb9129ec8 + en: 9a0b2f1a520cfc68 + zh: 62c9ec565909f605 /development-guide/contributor-reference/git-integrations: en: 075658ecfc10da20 zh: 69e664417bbde87e diff --git a/docs/development.md b/docs/development.md index 9aec6e601d..aebf2245a5 100644 --- a/docs/development.md +++ b/docs/development.md @@ -133,7 +133,7 @@ DEEPSEEK_API_KEY=sk-... DEEPSEEK_BASE_URL=https://... # optional ``` -`DEEPSEEK_BASE_URL` is optional and defaults to the public API. Never commit real credentials. The real-API e2e suites self-skip when `DEEPSEEK_API_KEY` is not set. +`DEEPSEEK_BASE_URL` is optional and defaults to the public API. The e2e and Web Vitest configurations, and the snapshot configuration when `DSH_SNAPSHOT=record`, load the root `.env` themselves; `pnpm dsh` and `pnpm run demo:ptc` launched from the repository root read it as a project `.env`, which may not set `DEEPSEEK_BASE_URL`, so export that variable or put it in `$DSH_HOME/.env` for those launches. Never commit real credentials. The real-API e2e suites self-skip when `DEEPSEEK_API_KEY` is not set. ### Git integrations diff --git a/docs/development.zh.md b/docs/development.zh.md index 92f0b71b99..e343a4cd11 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -138,7 +138,7 @@ DEEPSEEK_API_KEY=sk-... DEEPSEEK_BASE_URL=https://... # optional ``` -`DEEPSEEK_BASE_URL` 可选,默认为公开 API。请勿提交真实凭证。未设置 `DEEPSEEK_API_KEY` 时,真实 API 的 e2e 套件会自动跳过。 +`DEEPSEEK_BASE_URL` 可选,默认为公开 API。e2e 与 Web 的 Vitest 配置,以及 `DSH_SNAPSHOT=record` 时的快照配置,会自行加载根目录 `.env`;而从仓库根目录启动的 `pnpm dsh` 和 `pnpm run demo:ptc` 会把它当作项目 `.env` 读取,项目 `.env` 不能设置 `DEEPSEEK_BASE_URL`,因此这些启动需要导出该变量,或把它写入 `$DSH_HOME/.env`。请勿提交真实凭证。未设置 `DEEPSEEK_API_KEY` 时,真实 API 的 e2e 套件会自动跳过。 ### Git 集成 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index eb32364097..cdc25843df 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -47,11 +47,11 @@ Application listeners are outside this package matrix. Desktop Host subscribes t | `deepseek-account/signed-out` | `emit` | [`packages/credentials/deepseek-account/src/index.ts:29`](../packages/credentials/deepseek-account/src/index.ts) | [`deepseek-account`](../packages/credentials/deepseek-account) (`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/delete-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:82`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:74`](../packages/fs/fs/src/index.ts) | `liveshare` (`waterfall`), [`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:92`](../packages/fs/fs/src/index.ts) | `liveshare` (`emit`), [`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:66`](../packages/fs/fs/src/index.ts) | `liveshare` (`waterfall`), [`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) | +| `forge/providers-changed` | `parallel` | [`packages/experimental/forge/src/index.ts:28`](../packages/experimental/forge/src/index.ts) | `forge` (`parallel`) | `forge-session` | +| `fs/delete-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:83`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`workspace-changes`](../packages/deliverables/workspace-changes) | +| `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:75`](../packages/fs/fs/src/index.ts) | `liveshare` (`waterfall`), [`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:93`](../packages/fs/fs/src/index.ts) | `liveshare` (`emit`), [`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:67`](../packages/fs/fs/src/index.ts) | `liveshare` (`waterfall`), [`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) | | `goal/activation-changed` | `emit` | [`packages/goal/goal/src/types.ts:147`](../packages/goal/goal/src/types.ts) | [`goal`](../packages/goal/goal) (`emit`) | `remotes`, `server` | | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | | `hmr/change` | `emit` | [`packages/boot/hmr/src/index.ts:35`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`emit`) | - | @@ -98,7 +98,7 @@ Application listeners are outside this package matrix. Desktop Host subscribes t | `workflow/log` | `emit` | [`packages/workflow/workflow/src/index.ts:58`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) | | `workflow/phase` | `emit` | [`packages/workflow/workflow/src/index.ts:51`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) | | `workflow/start` | `emit` | [`packages/workflow/workflow/src/index.ts:43`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | -| `working-directory/access` | `waterfall` | [`packages/session/working-directory/src/index.ts:29`](../packages/session/working-directory/src/index.ts) | [`working-directory`](../packages/session/working-directory) (`waterfall`) | `worktree` | +| `working-directory/access` | `waterfall` | [`packages/session/working-directory/src/index.ts:32`](../packages/session/working-directory/src/index.ts) | [`working-directory`](../packages/session/working-directory) (`waterfall`) | `worktree` | | `workspace/session-activity` | `waterfall` | [`packages/workspace/workspace/src/index.ts:129`](../packages/workspace/workspace/src/index.ts) | [`workspace`](../packages/workspace/workspace) (`waterfall`) | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`schedule`](../packages/schedule/schedule), [`subagent`](../packages/subagent/subagent) | | `workspace/session-archived` | `parallel` | [`packages/workspace/workspace/src/index.ts:156`](../packages/workspace/workspace/src/index.ts) | [`workspace`](../packages/workspace/workspace) (`parallel`) | `worktree` | | `workspace/session-stop` | `parallel` | [`packages/workspace/workspace/src/index.ts:147`](../packages/workspace/workspace/src/index.ts) | [`workspace`](../packages/workspace/workspace) (`parallel`) | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`schedule`](../packages/schedule/schedule), [`subagent`](../packages/subagent/subagent) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index ae28c03fc4..73677d93b1 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -49,11 +49,11 @@ | `deepseek-account/signed-out` | `emit` | [`packages/credentials/deepseek-account/src/index.ts:29`](../packages/credentials/deepseek-account/src/index.ts) | [`deepseek-account`](../packages/credentials/deepseek-account) (`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/delete-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:82`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:74`](../packages/fs/fs/src/index.ts) | `liveshare` (`waterfall`), [`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:92`](../packages/fs/fs/src/index.ts) | `liveshare` (`emit`), [`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:66`](../packages/fs/fs/src/index.ts) | `liveshare` (`waterfall`), [`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) | +| `forge/providers-changed` | `parallel` | [`packages/experimental/forge/src/index.ts:28`](../packages/experimental/forge/src/index.ts) | `forge` (`parallel`) | `forge-session` | +| `fs/delete-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:83`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`workspace-changes`](../packages/deliverables/workspace-changes) | +| `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:75`](../packages/fs/fs/src/index.ts) | `liveshare` (`waterfall`), [`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:93`](../packages/fs/fs/src/index.ts) | `liveshare` (`emit`), [`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:67`](../packages/fs/fs/src/index.ts) | `liveshare` (`waterfall`), [`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) | | `goal/activation-changed` | `emit` | [`packages/goal/goal/src/types.ts:147`](../packages/goal/goal/src/types.ts) | [`goal`](../packages/goal/goal) (`emit`) | `remotes`, `server` | | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | | `hmr/change` | `emit` | [`packages/boot/hmr/src/index.ts:35`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`emit`) | - | @@ -100,7 +100,7 @@ | `workflow/log` | `emit` | [`packages/workflow/workflow/src/index.ts:58`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) | | `workflow/phase` | `emit` | [`packages/workflow/workflow/src/index.ts:51`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) | | `workflow/start` | `emit` | [`packages/workflow/workflow/src/index.ts:43`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | -| `working-directory/access` | `waterfall` | [`packages/session/working-directory/src/index.ts:29`](../packages/session/working-directory/src/index.ts) | [`working-directory`](../packages/session/working-directory) (`waterfall`) | `worktree` | +| `working-directory/access` | `waterfall` | [`packages/session/working-directory/src/index.ts:32`](../packages/session/working-directory/src/index.ts) | [`working-directory`](../packages/session/working-directory) (`waterfall`) | `worktree` | | `workspace/session-activity` | `waterfall` | [`packages/workspace/workspace/src/index.ts:129`](../packages/workspace/workspace/src/index.ts) | [`workspace`](../packages/workspace/workspace) (`waterfall`) | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`schedule`](../packages/schedule/schedule), [`subagent`](../packages/subagent/subagent) | | `workspace/session-archived` | `parallel` | [`packages/workspace/workspace/src/index.ts:156`](../packages/workspace/workspace/src/index.ts) | [`workspace`](../packages/workspace/workspace) (`parallel`) | `worktree` | | `workspace/session-stop` | `parallel` | [`packages/workspace/workspace/src/index.ts:147`](../packages/workspace/workspace/src/index.ts) | [`workspace`](../packages/workspace/workspace) (`parallel`) | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`schedule`](../packages/schedule/schedule), [`subagent`](../packages/subagent/subagent) | diff --git a/docs/module-graph.md b/docs/module-graph.md index a3a28f92fc..7b28621265 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -490,6 +490,7 @@ flowchart TD pkg_session --> pkg_scope pkg_api_remotes --> pkg_scope pkg_attachment --> pkg_brand + pkg_attachment --> pkg_error pkg_app_boot --> pkg_home_paths pkg_app_boot --> pkg_launch_environment pkg_browser_use --> pkg_brand @@ -505,7 +506,9 @@ flowchart TD pkg_storage_domain --> pkg_storage pkg_storage_json --> pkg_storage pkg_storage_sqlite --> pkg_storage + pkg_subprocess --> pkg_error pkg_subprocess --> pkg_http_proxy + pkg_win32_process --> pkg_error pkg_remote_mock --> pkg_typert_protocol pkg_typert_loader --> pkg_typert_registry pkg_util_ripgrep --> pkg_subprocess @@ -540,6 +543,7 @@ flowchart TD pkg_host_frontend_static --> pkg_host_webserver pkg_lsp --> pkg_brand pkg_lsp --> pkg_llm + pkg_sandbox --> pkg_error pkg_sandbox --> pkg_llm pkg_sandbox --> pkg_session pkg_session_format_catalog --> pkg_session @@ -650,6 +654,7 @@ flowchart TD pkg_hook_protocol --> pkg_session pkg_hook_protocol --> pkg_shell pkg_lsp_stdio --> pkg_brand + pkg_lsp_stdio --> pkg_error pkg_lsp_stdio --> pkg_fs pkg_lsp_stdio --> pkg_llm pkg_lsp_stdio --> pkg_lsp @@ -740,6 +745,7 @@ flowchart TD pkg_session_title --> pkg_session pkg_session_title --> pkg_session_projection pkg_working_directory --> pkg_agent + pkg_working_directory --> pkg_error pkg_working_directory --> pkg_fs pkg_working_directory --> pkg_llm pkg_working_directory --> pkg_session @@ -859,6 +865,7 @@ flowchart TD pkg_tool_goal --> pkg_system_prompt pkg_tool_goal --> pkg_tools pkg_tool_fs --> pkg_attachment + pkg_tool_fs --> pkg_error pkg_tool_fs --> pkg_fs pkg_tool_fs --> pkg_llm pkg_tool_fs --> pkg_sandbox @@ -1057,6 +1064,7 @@ flowchart TD pkg_tool_working_directory --> pkg_tools pkg_tool_working_directory --> pkg_working_directory pkg_shell_env --> pkg_app_boot + pkg_shell_env --> pkg_error pkg_shell_env --> pkg_home_paths pkg_shell_env --> pkg_shell pkg_shell_env --> pkg_tools @@ -1174,6 +1182,7 @@ flowchart TD pkg_tool_bash --> pkg_sandbox_policy pkg_tool_bash --> pkg_shell pkg_tool_bash --> pkg_shell_env + pkg_tool_bash --> pkg_subprocess pkg_tool_bash --> pkg_system_prompt pkg_tool_bash --> pkg_tools pkg_tool_bash --> pkg_user_approval @@ -1185,6 +1194,7 @@ flowchart TD pkg_tool_pwsh --> pkg_sandbox_policy pkg_tool_pwsh --> pkg_shell pkg_tool_pwsh --> pkg_shell_env + pkg_tool_pwsh --> pkg_subprocess pkg_tool_pwsh --> pkg_system_prompt pkg_tool_pwsh --> pkg_tools pkg_tool_pwsh --> pkg_user_approval @@ -1682,7 +1692,6 @@ flowchart TD | [`session-format-v3-to-v4`](../packages/session/session-format-v3-to-v4) | `session` | — | | [`ssh-helper-runtime`](../packages/ssh/ssh-helper-runtime) | `ssh` | — | | [`storage`](../packages/storage/storage) | `storage` | — | -| [`win32-process`](../packages/subprocess/win32-process) | `subprocess` | — | | [`llm-mock-server`](../packages/test-support/llm-mock-server) | `test-support` | — | | [`typert-generator`](../packages/typert/generator) | `typert` | — | | [`typert-protocol`](../packages/typert/protocol) | `typert` | — | @@ -1690,7 +1699,7 @@ flowchart TD | [`llm`](../packages/llm/llm) | `llm` | [`error`](../packages/util/error) | | [`session`](../packages/core/session) | `core` | [`scope`](../packages/core/scope) | | [`api-remotes`](../packages/api/remotes) | `api` | [`scope`](../packages/core/scope) | -| [`attachment`](../packages/attachment/attachment) | `attachment` | [`brand`](../packages/util/brand) | +| [`attachment`](../packages/attachment/attachment) | `attachment` | [`brand`](../packages/util/brand), [`error`](../packages/util/error) | | [`app-boot`](../packages/boot/app-boot) | `boot` | [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment) | | [`browser-use`](../packages/browser-use/browser-use) | `browser-use` | [`brand`](../packages/util/brand) | | [`client-connection`](../packages/client/connection) | `client` | [`scope`](../packages/core/scope) | @@ -1701,7 +1710,8 @@ flowchart TD | [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`storage`](../packages/storage/storage) | | [`storage-json`](../packages/storage/storage-json) | `storage` | [`storage`](../packages/storage/storage) | | [`storage-sqlite`](../packages/storage/storage-sqlite) | `storage` | [`storage`](../packages/storage/storage) | -| [`subprocess`](../packages/subprocess/subprocess) | `subprocess` | [`http-proxy`](../packages/util/http-proxy) | +| [`subprocess`](../packages/subprocess/subprocess) | `subprocess` | [`error`](../packages/util/error), [`http-proxy`](../packages/util/http-proxy) | +| [`win32-process`](../packages/subprocess/win32-process) | `subprocess` | [`error`](../packages/util/error) | | [`remote-mock`](../packages/test-support/remote-mock) | `test-support` | [`typert-protocol`](../packages/typert/protocol) | | [`typert-loader`](../packages/typert/loader) | `typert` | [`typert-registry`](../packages/typert/registry) | | [`util-ripgrep`](../packages/util/ripgrep) | `util` | [`subprocess`](../packages/subprocess/subprocess) | @@ -1719,7 +1729,7 @@ flowchart TD | [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`client-connection`](../packages/client/connection), [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse), [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver) | | [`host-frontend-static`](../packages/host/frontend-static) | `host` | [`client-connection`](../packages/client/connection), [`host-webserver`](../packages/host/webserver) | | [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm) | -| [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`error`](../packages/util/error), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`session-format-catalog`](../packages/session/session-format-catalog) | `session` | [`session`](../packages/core/session) | | [`session-log-deepseek`](../packages/session/session-log-deepseek) | `session` | [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`session`](../packages/core/session) | | [`session-persistence`](../packages/session/session-persistence) | `session` | [`brand`](../packages/util/brand), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | @@ -1761,7 +1771,7 @@ flowchart TD | [`experimental-client-ui-cot-translation`](../packages/experimental/client-ui-cot-translation) | `experimental` | [`experimental-translator`](../packages/experimental/translator) | | [`experimental-speech-to-text-sensevoice`](../packages/experimental/speech-to-text-sensevoice) | `experimental` | [`experimental-speech-to-text`](../packages/experimental/speech-to-text), [`subprocess`](../packages/subprocess/subprocess) | | [`hook-protocol`](../packages/experimental/hook-protocol) | `experimental` | [`session`](../packages/core/session), [`shell`](../packages/shell/shell) | -| [`lsp-stdio`](../packages/lsp/lsp-stdio) | `lsp` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | +| [`lsp-stdio`](../packages/lsp/lsp-stdio) | `lsp` | [`brand`](../packages/util/brand), [`error`](../packages/util/error), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`bash-local`](../packages/shell/bash-local) | `shell` | [`shell`](../packages/shell/shell), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`pwsh-local`](../packages/shell/pwsh-local) | `shell` | [`shell`](../packages/shell/shell), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`llm-deepseek-api-key`](../packages/llm/llm-deepseek-api-key) | `llm` | [`credentials`](../packages/credentials/credentials), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek) | @@ -1780,7 +1790,7 @@ flowchart TD | [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`agent`](../packages/core/agent), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt) | | [`session-telemetry`](../packages/session/session-telemetry) | `session` | [`agent`](../packages/core/agent), [`session`](../packages/core/session) | | [`session-title`](../packages/session/session-title) | `session` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | -| [`working-directory`](../packages/session/working-directory) | `session` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt) | +| [`working-directory`](../packages/session/working-directory) | `session` | [`agent`](../packages/core/agent), [`error`](../packages/util/error), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt) | | [`terminal`](../packages/terminal/terminal) | `terminal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`error`](../packages/util/error) | | [`loader-smoke`](../packages/test-support/loader-smoke) | `test-support` | [`agent`](../packages/core/agent), [`http-proxy`](../packages/util/http-proxy), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | @@ -1804,7 +1814,7 @@ flowchart TD | [`tool-executor-native`](../packages/core/tool-executor-native) | `core` | [`tools`](../packages/core/tools) | | [`tool-executor-ptc`](../packages/core/tool-executor-ptc) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`working-directory`](../packages/session/working-directory) | | [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`attachment`](../packages/attachment/attachment), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`working-directory`](../packages/session/working-directory) | +| [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`attachment`](../packages/attachment/attachment), [`error`](../packages/util/error), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`working-directory`](../packages/session/working-directory) | | [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`output-retention`](../packages/util/output-retention), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) | | [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | @@ -1843,7 +1853,7 @@ flowchart TD | [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy) | `session` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`tools`](../packages/core/tools) | | [`session-title-first-prompt-llm`](../packages/session/session-title-first-prompt-llm) | `session` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | | [`tool-working-directory`](../packages/session/tool-working-directory) | `session` | [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | -| [`shell-env`](../packages/shell/shell-env) | `shell` | [`app-boot`](../packages/boot/app-boot), [`home-paths`](../packages/util/home-paths), [`shell`](../packages/shell/shell), [`tools`](../packages/core/tools) | +| [`shell-env`](../packages/shell/shell-env) | `shell` | [`app-boot`](../packages/boot/app-boot), [`error`](../packages/util/error), [`home-paths`](../packages/util/home-paths), [`shell`](../packages/shell/shell), [`tools`](../packages/core/tools) | | [`tool-bash-persistent`](../packages/shell/tool-bash-persistent) | `shell` | [`agent`](../packages/core/agent), [`output-retention`](../packages/util/output-retention), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`tool-pwsh-persistent`](../packages/shell/tool-pwsh-persistent) | `shell` | [`agent`](../packages/core/agent), [`output-retention`](../packages/util/output-retention), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`ssh`](../packages/ssh/ssh) | `ssh` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`fs-sandbox`](../packages/fs/fs-sandbox), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-local`](../packages/sandbox/sandbox-local), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session-projection`](../packages/session/session-projection), [`subprocess`](../packages/subprocess/subprocess), [`subprocess-local`](../packages/subprocess/subprocess-local), [`util-values`](../packages/util/values) | @@ -1863,8 +1873,8 @@ flowchart TD | [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`attachment`](../packages/attachment/attachment), [`credentials`](../packages/credentials/credentials), [`llm`](../packages/llm/llm), [`mcp-resources`](../packages/mcp/mcp-resources), [`scope`](../packages/core/scope), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`agent-preset`](../packages/preset/agent-preset) | `preset` | [`agent-preset-registry`](../packages/preset/agent-preset-registry) | | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`command-feedback`](../packages/feedback/command-feedback), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`otel`](../packages/telemetry/otel), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) | -| [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`working-directory`](../packages/session/working-directory) | -| [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`working-directory`](../packages/session/working-directory) | +| [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`working-directory`](../packages/session/working-directory) | +| [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`working-directory`](../packages/session/working-directory) | | [`fs-ssh`](../packages/ssh/fs-ssh) | `ssh` | [`fs`](../packages/fs/fs), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`ssh`](../packages/ssh/ssh) | | [`sandbox-ssh`](../packages/ssh/sandbox-ssh) | `ssh` | [`sandbox`](../packages/sandbox/sandbox), [`ssh`](../packages/ssh/ssh) | | [`subprocess-ssh`](../packages/ssh/subprocess-ssh) | `ssh` | [`ssh`](../packages/ssh/ssh), [`subprocess`](../packages/subprocess/subprocess), [`subprocess-local`](../packages/subprocess/subprocess-local) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 9e337229b4..4ddd93c256 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -492,6 +492,7 @@ flowchart TD pkg_session --> pkg_scope pkg_api_remotes --> pkg_scope pkg_attachment --> pkg_brand + pkg_attachment --> pkg_error pkg_app_boot --> pkg_home_paths pkg_app_boot --> pkg_launch_environment pkg_browser_use --> pkg_brand @@ -507,7 +508,9 @@ flowchart TD pkg_storage_domain --> pkg_storage pkg_storage_json --> pkg_storage pkg_storage_sqlite --> pkg_storage + pkg_subprocess --> pkg_error pkg_subprocess --> pkg_http_proxy + pkg_win32_process --> pkg_error pkg_remote_mock --> pkg_typert_protocol pkg_typert_loader --> pkg_typert_registry pkg_util_ripgrep --> pkg_subprocess @@ -542,6 +545,7 @@ flowchart TD pkg_host_frontend_static --> pkg_host_webserver pkg_lsp --> pkg_brand pkg_lsp --> pkg_llm + pkg_sandbox --> pkg_error pkg_sandbox --> pkg_llm pkg_sandbox --> pkg_session pkg_session_format_catalog --> pkg_session @@ -652,6 +656,7 @@ flowchart TD pkg_hook_protocol --> pkg_session pkg_hook_protocol --> pkg_shell pkg_lsp_stdio --> pkg_brand + pkg_lsp_stdio --> pkg_error pkg_lsp_stdio --> pkg_fs pkg_lsp_stdio --> pkg_llm pkg_lsp_stdio --> pkg_lsp @@ -742,6 +747,7 @@ flowchart TD pkg_session_title --> pkg_session pkg_session_title --> pkg_session_projection pkg_working_directory --> pkg_agent + pkg_working_directory --> pkg_error pkg_working_directory --> pkg_fs pkg_working_directory --> pkg_llm pkg_working_directory --> pkg_session @@ -861,6 +867,7 @@ flowchart TD pkg_tool_goal --> pkg_system_prompt pkg_tool_goal --> pkg_tools pkg_tool_fs --> pkg_attachment + pkg_tool_fs --> pkg_error pkg_tool_fs --> pkg_fs pkg_tool_fs --> pkg_llm pkg_tool_fs --> pkg_sandbox @@ -1059,6 +1066,7 @@ flowchart TD pkg_tool_working_directory --> pkg_tools pkg_tool_working_directory --> pkg_working_directory pkg_shell_env --> pkg_app_boot + pkg_shell_env --> pkg_error pkg_shell_env --> pkg_home_paths pkg_shell_env --> pkg_shell pkg_shell_env --> pkg_tools @@ -1176,6 +1184,7 @@ flowchart TD pkg_tool_bash --> pkg_sandbox_policy pkg_tool_bash --> pkg_shell pkg_tool_bash --> pkg_shell_env + pkg_tool_bash --> pkg_subprocess pkg_tool_bash --> pkg_system_prompt pkg_tool_bash --> pkg_tools pkg_tool_bash --> pkg_user_approval @@ -1187,6 +1196,7 @@ flowchart TD pkg_tool_pwsh --> pkg_sandbox_policy pkg_tool_pwsh --> pkg_shell pkg_tool_pwsh --> pkg_shell_env + pkg_tool_pwsh --> pkg_subprocess pkg_tool_pwsh --> pkg_system_prompt pkg_tool_pwsh --> pkg_tools pkg_tool_pwsh --> pkg_user_approval @@ -1684,7 +1694,6 @@ flowchart TD | [`session-format-v3-to-v4`](../packages/session/session-format-v3-to-v4) | `session` | — | | [`ssh-helper-runtime`](../packages/ssh/ssh-helper-runtime) | `ssh` | — | | [`storage`](../packages/storage/storage) | `storage` | — | -| [`win32-process`](../packages/subprocess/win32-process) | `subprocess` | — | | [`llm-mock-server`](../packages/test-support/llm-mock-server) | `test-support` | — | | [`typert-generator`](../packages/typert/generator) | `typert` | — | | [`typert-protocol`](../packages/typert/protocol) | `typert` | — | @@ -1692,7 +1701,7 @@ flowchart TD | [`llm`](../packages/llm/llm) | `llm` | [`error`](../packages/util/error) | | [`session`](../packages/core/session) | `core` | [`scope`](../packages/core/scope) | | [`api-remotes`](../packages/api/remotes) | `api` | [`scope`](../packages/core/scope) | -| [`attachment`](../packages/attachment/attachment) | `attachment` | [`brand`](../packages/util/brand) | +| [`attachment`](../packages/attachment/attachment) | `attachment` | [`brand`](../packages/util/brand), [`error`](../packages/util/error) | | [`app-boot`](../packages/boot/app-boot) | `boot` | [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment) | | [`browser-use`](../packages/browser-use/browser-use) | `browser-use` | [`brand`](../packages/util/brand) | | [`client-connection`](../packages/client/connection) | `client` | [`scope`](../packages/core/scope) | @@ -1703,7 +1712,8 @@ flowchart TD | [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`storage`](../packages/storage/storage) | | [`storage-json`](../packages/storage/storage-json) | `storage` | [`storage`](../packages/storage/storage) | | [`storage-sqlite`](../packages/storage/storage-sqlite) | `storage` | [`storage`](../packages/storage/storage) | -| [`subprocess`](../packages/subprocess/subprocess) | `subprocess` | [`http-proxy`](../packages/util/http-proxy) | +| [`subprocess`](../packages/subprocess/subprocess) | `subprocess` | [`error`](../packages/util/error), [`http-proxy`](../packages/util/http-proxy) | +| [`win32-process`](../packages/subprocess/win32-process) | `subprocess` | [`error`](../packages/util/error) | | [`remote-mock`](../packages/test-support/remote-mock) | `test-support` | [`typert-protocol`](../packages/typert/protocol) | | [`typert-loader`](../packages/typert/loader) | `typert` | [`typert-registry`](../packages/typert/registry) | | [`util-ripgrep`](../packages/util/ripgrep) | `util` | [`subprocess`](../packages/subprocess/subprocess) | @@ -1721,7 +1731,7 @@ flowchart TD | [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`client-connection`](../packages/client/connection), [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse), [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver) | | [`host-frontend-static`](../packages/host/frontend-static) | `host` | [`client-connection`](../packages/client/connection), [`host-webserver`](../packages/host/webserver) | | [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm) | -| [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`error`](../packages/util/error), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`session-format-catalog`](../packages/session/session-format-catalog) | `session` | [`session`](../packages/core/session) | | [`session-log-deepseek`](../packages/session/session-log-deepseek) | `session` | [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`session`](../packages/core/session) | | [`session-persistence`](../packages/session/session-persistence) | `session` | [`brand`](../packages/util/brand), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | @@ -1763,7 +1773,7 @@ flowchart TD | [`experimental-client-ui-cot-translation`](../packages/experimental/client-ui-cot-translation) | `experimental` | [`experimental-translator`](../packages/experimental/translator) | | [`experimental-speech-to-text-sensevoice`](../packages/experimental/speech-to-text-sensevoice) | `experimental` | [`experimental-speech-to-text`](../packages/experimental/speech-to-text), [`subprocess`](../packages/subprocess/subprocess) | | [`hook-protocol`](../packages/experimental/hook-protocol) | `experimental` | [`session`](../packages/core/session), [`shell`](../packages/shell/shell) | -| [`lsp-stdio`](../packages/lsp/lsp-stdio) | `lsp` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | +| [`lsp-stdio`](../packages/lsp/lsp-stdio) | `lsp` | [`brand`](../packages/util/brand), [`error`](../packages/util/error), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`bash-local`](../packages/shell/bash-local) | `shell` | [`shell`](../packages/shell/shell), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`pwsh-local`](../packages/shell/pwsh-local) | `shell` | [`shell`](../packages/shell/shell), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`llm-deepseek-api-key`](../packages/llm/llm-deepseek-api-key) | `llm` | [`credentials`](../packages/credentials/credentials), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek) | @@ -1782,7 +1792,7 @@ flowchart TD | [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`agent`](../packages/core/agent), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt) | | [`session-telemetry`](../packages/session/session-telemetry) | `session` | [`agent`](../packages/core/agent), [`session`](../packages/core/session) | | [`session-title`](../packages/session/session-title) | `session` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | -| [`working-directory`](../packages/session/working-directory) | `session` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt) | +| [`working-directory`](../packages/session/working-directory) | `session` | [`agent`](../packages/core/agent), [`error`](../packages/util/error), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt) | | [`terminal`](../packages/terminal/terminal) | `terminal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`error`](../packages/util/error) | | [`loader-smoke`](../packages/test-support/loader-smoke) | `test-support` | [`agent`](../packages/core/agent), [`http-proxy`](../packages/util/http-proxy), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | @@ -1806,7 +1816,7 @@ flowchart TD | [`tool-executor-native`](../packages/core/tool-executor-native) | `core` | [`tools`](../packages/core/tools) | | [`tool-executor-ptc`](../packages/core/tool-executor-ptc) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`working-directory`](../packages/session/working-directory) | | [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`attachment`](../packages/attachment/attachment), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`working-directory`](../packages/session/working-directory) | +| [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`attachment`](../packages/attachment/attachment), [`error`](../packages/util/error), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`working-directory`](../packages/session/working-directory) | | [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`output-retention`](../packages/util/output-retention), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) | | [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | @@ -1845,7 +1855,7 @@ flowchart TD | [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy) | `session` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`tools`](../packages/core/tools) | | [`session-title-first-prompt-llm`](../packages/session/session-title-first-prompt-llm) | `session` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | | [`tool-working-directory`](../packages/session/tool-working-directory) | `session` | [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | -| [`shell-env`](../packages/shell/shell-env) | `shell` | [`app-boot`](../packages/boot/app-boot), [`home-paths`](../packages/util/home-paths), [`shell`](../packages/shell/shell), [`tools`](../packages/core/tools) | +| [`shell-env`](../packages/shell/shell-env) | `shell` | [`app-boot`](../packages/boot/app-boot), [`error`](../packages/util/error), [`home-paths`](../packages/util/home-paths), [`shell`](../packages/shell/shell), [`tools`](../packages/core/tools) | | [`tool-bash-persistent`](../packages/shell/tool-bash-persistent) | `shell` | [`agent`](../packages/core/agent), [`output-retention`](../packages/util/output-retention), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`tool-pwsh-persistent`](../packages/shell/tool-pwsh-persistent) | `shell` | [`agent`](../packages/core/agent), [`output-retention`](../packages/util/output-retention), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`ssh`](../packages/ssh/ssh) | `ssh` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`fs-sandbox`](../packages/fs/fs-sandbox), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-local`](../packages/sandbox/sandbox-local), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session-projection`](../packages/session/session-projection), [`subprocess`](../packages/subprocess/subprocess), [`subprocess-local`](../packages/subprocess/subprocess-local), [`util-values`](../packages/util/values) | @@ -1865,8 +1875,8 @@ flowchart TD | [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`attachment`](../packages/attachment/attachment), [`credentials`](../packages/credentials/credentials), [`llm`](../packages/llm/llm), [`mcp-resources`](../packages/mcp/mcp-resources), [`scope`](../packages/core/scope), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`agent-preset`](../packages/preset/agent-preset) | `preset` | [`agent-preset-registry`](../packages/preset/agent-preset-registry) | | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`command-feedback`](../packages/feedback/command-feedback), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`otel`](../packages/telemetry/otel), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) | -| [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`working-directory`](../packages/session/working-directory) | -| [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`working-directory`](../packages/session/working-directory) | +| [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`working-directory`](../packages/session/working-directory) | +| [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`working-directory`](../packages/session/working-directory) | | [`fs-ssh`](../packages/ssh/fs-ssh) | `ssh` | [`fs`](../packages/fs/fs), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`ssh`](../packages/ssh/ssh) | | [`sandbox-ssh`](../packages/ssh/sandbox-ssh) | `ssh` | [`sandbox`](../packages/sandbox/sandbox), [`ssh`](../packages/ssh/ssh) | | [`subprocess-ssh`](../packages/ssh/subprocess-ssh) | `ssh` | [`ssh`](../packages/ssh/ssh), [`subprocess`](../packages/subprocess/subprocess), [`subprocess-local`](../packages/subprocess/subprocess-local) | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index baef62d6d9..f2294edff6 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -300,8 +300,8 @@ en: e8dd0cebbb930eb7 zh: e8dd0cebbb930eb7 /session-persistence-event-catalog/events/working-directory/working-directory-change-log-only: - en: fc893ed0d93765df - zh: 2df365d9d0ac6f6e + en: 27f0761cbdd6fe2f + zh: 33528f333498132d /session-persistence-event-catalog/events/workspace: en: 63ee1a85e4b59ae3 zh: 63ee1a85e4b59ae3 @@ -1203,8 +1203,8 @@ en: 1f691c223f54ab2c zh: f88fef3ee9745c91 /session-persistence-event-catalog/resolved-persistence-types/sandboxmode: - en: c9fa5343ea99733b - zh: d6de41351c57a73f + en: 5adc2bf6745ccc67 + zh: 566cb9aaa4a177c9 /session-persistence-event-catalog/resolved-persistence-types/schedulechange: en: b20a355fde48f699 zh: 5d0fd858e2dbff06 @@ -1494,8 +1494,8 @@ en: bef51e0442ebb903 zh: 240ebeb160533d8c /session-persistence-event-catalog/resolved-persistence-types/cwd: - en: 35feea0430c0f14b - zh: d5aaf1d2ca80114a + en: 2809c921c6996791 + zh: 80b15177561766d7 /session-persistence-event-catalog/resolved-persistence-types/decision-durationms-exitcode-handlerid: en: a99ac57bbec0370f zh: 9073329e324a66e0 @@ -1929,8 +1929,8 @@ en: 0b74295f7c5c8f11 zh: 5f1213cec87f6be0 /session-persistence-event-catalog/resolved-persistence-types/kind-working-directory: - en: 626ea3180b86e5b6 - zh: 4b6e0dc1312b3c5a + en: b28ca6b37f5a338d + zh: 4c4f2f05721b8b62 /session-persistence-event-catalog/resolved-persistence-types/max-tokens-messages-model-thinking: en: 3216b23c131d0070 zh: 73ca171c6014f16e diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 3f70236194..33d9aa4395 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -1329,7 +1329,7 @@ Source: [`packages/web/web-search-deepseek/src/provider.ts:89`](../packages/web/ } ``` -Source: [`packages/session/working-directory/src/index.ts:52`](../packages/session/working-directory/src/index.ts) +Source: [`packages/session/working-directory/src/index.ts:55`](../packages/session/working-directory/src/index.ts) ### `workspace/*` @@ -4619,7 +4619,7 @@ One of: SHA-256: `fab2bb09bc5e3b9db0685e8f5500b029af1a2b33e70bc703c1d5d0f596fbc1fd` -Sources: [`packages/sandbox/sandbox/src/index.ts:30`](../packages/sandbox/sandbox/src/index.ts) +Sources: [`packages/sandbox/sandbox/src/index.ts:38`](../packages/sandbox/sandbox/src/index.ts) One of: @@ -6209,7 +6209,7 @@ Sources: [`packages/compaction/compaction/src/types.ts:24`](../packages/compacti SHA-256: `da60ed58a8fa23d7aa4920ab5c1f4e3d28109cb0797ef9e5c8b125721b847582` -Sources: [`packages/session/working-directory/src/index.ts:52`](../packages/session/working-directory/src/index.ts) +Sources: [`packages/session/working-directory/src/index.ts:55`](../packages/session/working-directory/src/index.ts) | Property | Presence | Type | |---|---|---| @@ -7949,7 +7949,7 @@ Sources: [`packages/experimental/webhook/src/types.ts:74`](../packages/experimen SHA-256: `2235d957bf4b7c6cf959231d480f0ba1f3a6d72d82981493fb6c7c963021d3d5` -Sources: [`packages/session/working-directory/src/index.ts:45`](../packages/session/working-directory/src/index.ts) +Sources: [`packages/session/working-directory/src/index.ts:48`](../packages/session/working-directory/src/index.ts) | Property | Presence | Type | |---|---|---| diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index adfc4971e9..5047ebc659 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -1331,7 +1331,7 @@ export type SessionEvent = { } ``` -来源:[`packages/session/working-directory/src/index.ts:52`](../packages/session/working-directory/src/index.ts) +来源:[`packages/session/working-directory/src/index.ts:55`](../packages/session/working-directory/src/index.ts) ### `workspace/*` @@ -4621,7 +4621,7 @@ SHA-256: `530da6c0267813bb4511af72883cc518834e624e3b3220a7062955fd57a788a6` SHA-256: `fab2bb09bc5e3b9db0685e8f5500b029af1a2b33e70bc703c1d5d0f596fbc1fd` -来源:[`packages/sandbox/sandbox/src/index.ts:30`](../packages/sandbox/sandbox/src/index.ts) +来源:[`packages/sandbox/sandbox/src/index.ts:38`](../packages/sandbox/sandbox/src/index.ts) 以下类型之一: @@ -6211,7 +6211,7 @@ SHA-256: `3e4e7683b0192c9f0e6a75c59076108f017892cdb302a98032c2632fd79c356f` SHA-256: `da60ed58a8fa23d7aa4920ab5c1f4e3d28109cb0797ef9e5c8b125721b847582` -来源:[`packages/session/working-directory/src/index.ts:52`](../packages/session/working-directory/src/index.ts) +来源:[`packages/session/working-directory/src/index.ts:55`](../packages/session/working-directory/src/index.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -7951,7 +7951,7 @@ SHA-256: `9edc162949abf93bd5ff049191b345454ba104f583937ce5580fb50f067d2b94` SHA-256: `2235d957bf4b7c6cf959231d480f0ba1f3a6d72d82981493fb6c7c963021d3d5` -来源:[`packages/session/working-directory/src/index.ts:45`](../packages/session/working-directory/src/index.ts) +来源:[`packages/session/working-directory/src/index.ts:48`](../packages/session/working-directory/src/index.ts) | 属性 | 存在性 | 类型 | |---|---|---| diff --git a/docs/persistence-schema.json b/docs/persistence-schema.json index 3a1b058f36..a2832eb98c 100644 --- a/docs/persistence-schema.json +++ b/docs/persistence-schema.json @@ -159,7 +159,7 @@ {"digest":"2142e391b9ccb60d487afcf373ce74db98d952f69e8289eddb352a7e16e996c5","names":[],"sources":[]}, {"digest":"2152caf1a71e588e4df2358a806dea24936c1c47131134f01a12939d12cdc0c0","names":["packages/team/agent-team/src/types.ts#TeamMessageSource"],"sources":["packages/team/agent-team/src/types.ts:121"]}, {"digest":"21fb6a90d5068f6a0003b7ab316ed2f56342477146a65c00db0f13c4d8df667d","names":[],"sources":[]}, - {"digest":"2235d957bf4b7c6cf959231d480f0ba1f3a6d72d82981493fb6c7c963021d3d5","names":[],"sources":["packages/session/working-directory/src/index.ts:45"]}, + {"digest":"2235d957bf4b7c6cf959231d480f0ba1f3a6d72d82981493fb6c7c963021d3d5","names":[],"sources":["packages/session/working-directory/src/index.ts:48"]}, {"digest":"224aa11d4d5f88c450822d01b99e6ca5e3a9c7f26a5ede10bae33384a09ef4dc","names":[],"sources":[]}, {"digest":"234142ee4f996f38cc9fa3b8b3e3de29b03c9aefe5ebf6c224eddd564505aa85","names":[],"sources":[]}, {"digest":"237c004c73b6b288246816e0d7fde16e09c63a47aa3b1227383d7afe58666163","names":[],"sources":[]}, @@ -607,7 +607,7 @@ {"digest":"d9196086e41e4dd5c2ce87f5c25529c8112a1731d258cf0728747d41a9ee3241","names":[],"sources":[]}, {"digest":"d9c462b9f62fdd3efc76a44b9524dc8781f6b91cd9c7e584b17d971defafebab","names":["packages/llm/llm/src/message.ts#ToolMessageSource"],"sources":["packages/llm/llm/src/message.ts:29"]}, {"digest":"d9edf1862e4471d6ad8a106a3f4160e0643874368707d50ecd8e27e5ee7a2567","names":[],"sources":[]}, - {"digest":"da60ed58a8fa23d7aa4920ab5c1f4e3d28109cb0797ef9e5c8b125721b847582","names":[],"sources":["packages/session/working-directory/src/index.ts:52"]}, + {"digest":"da60ed58a8fa23d7aa4920ab5c1f4e3d28109cb0797ef9e5c8b125721b847582","names":[],"sources":["packages/session/working-directory/src/index.ts:55"]}, {"digest":"dacf3d5f3e4ff619c1320e64a48dec5e7ecd7870bece65fbd449a0e63adfa521","names":[],"sources":[]}, {"digest":"db06ef35b27a38425046b2228fa6bce9582abe140d781d363137eb2f4d233a1c","names":[],"sources":[]}, {"digest":"db6c5246911d47a7969f6b4cb47c7d0dbbde75c402710b22ace3672fa97bebe0","names":[],"sources":[]}, @@ -692,7 +692,7 @@ {"digest":"f97f47b0eb87905ed77cc27c5e4ed75ef805cb372c98905253709de1aab9b63c","names":[],"sources":[]}, {"digest":"f9cfc89e8fe085b4fcc9cd32b74b6f103608c54d7d8cd97b47c01de34f252ba3","names":[],"sources":[]}, {"digest":"fa87537f4a9c1e25602990a8bf686b383e9e00352ec8946e16ae24ba7c1625be","names":["packages/llm/llm/src/message.ts#AssistantMessage"],"sources":["packages/llm/llm/src/message.ts:167"]}, - {"digest":"fab2bb09bc5e3b9db0685e8f5500b029af1a2b33e70bc703c1d5d0f596fbc1fd","names":["packages/sandbox/sandbox/src/index.ts#SandboxMode"],"sources":["packages/sandbox/sandbox/src/index.ts:30"]}, + {"digest":"fab2bb09bc5e3b9db0685e8f5500b029af1a2b33e70bc703c1d5d0f596fbc1fd","names":["packages/sandbox/sandbox/src/index.ts#SandboxMode"],"sources":["packages/sandbox/sandbox/src/index.ts:38"]}, {"digest":"fab35c4520883c0d69e42d079c9f543fc5ac7a2d15b537b5572fcd99059c7018","names":[],"sources":[]}, {"digest":"fb5e0517050d2c6d40e5b8145db1f7c5ca7044d5d50fada6ff80e0b22c6ca973","names":[],"sources":[]}, {"digest":"fb8b6a2e53cb9cf44c551176de0fb77d1ddc60037b99a891ff8a860b6d5c29a0","names":[],"sources":[]}, diff --git a/docs/subsystems/filesystem.i18n.yaml b/docs/subsystems/filesystem.i18n.yaml index 03d12b8456..09a886a568 100644 --- a/docs/subsystems/filesystem.i18n.yaml +++ b/docs/subsystems/filesystem.i18n.yaml @@ -30,8 +30,8 @@ en: 7b462db42ab89b10 zh: 968219ded2b55d69 /filesystem/error-taxonomy-provider-contract: - en: ca206569d4e2528d - zh: e36d70ad39a73570 + en: dbab88fbb60fcb8f + zh: f90be1352b1e05f8 /filesystem/no-timeouts-on-file-io: en: ae18af906f3f7e50 zh: 24a1aa6f903fe08d diff --git a/docs/subsystems/filesystem.md b/docs/subsystems/filesystem.md index fcbbec29cd..f19f09ab43 100644 --- a/docs/subsystems/filesystem.md +++ b/docs/subsystems/filesystem.md @@ -375,7 +375,7 @@ import { FS_ERROR_CODES } from '@deepseek-ai/dsh-fs' type FsErrorCode = typeof FS_ERROR_CODES[number] ``` -`FS_NOT_DIRECTORY`, `FS_PERMISSION_DENIED`, and `FS_IO_ERROR` are used by directory listing to distinguish an existing non-directory target, a denied listing, and an unexpected backend I/O failure. `FS_SANDBOX_DENIED` is a POLICY refusal from a sandbox-enforcing backend (`dsh-fs-sandbox`) — the mode fence denied a mutation — distinct from `FS_PERMISSION_DENIED` (the host kernel refusing). `FS_NOT_OBSERVED` means the policy plugin has no prior-observation record for this owner (or a `createIfAbsent` hit an existing file). `FS_NOT_FOUND` also represents an edit rejected from confirmed absence. `FS_STALE_VERSION` means the backend version no longer matches the observed one (or the provider itself receives an edit for a missing target). Freshness authorization has no partial/full distinction, so there is no `FS_PARTIAL_OBSERVATION`. +`FS_NOT_DIRECTORY`, `FS_PERMISSION_DENIED`, and `FS_IO_ERROR` are used by directory listing to distinguish an existing non-directory target, a denied listing, and an unexpected backend I/O failure. `FS_SANDBOX_DENIED` is a POLICY refusal from a sandbox-enforcing backend (`dsh-fs-sandbox`) — the mode fence denied a mutation — distinct from `FS_PERMISSION_DENIED` (the host kernel refusing). `FS_NOT_OBSERVED` means the policy plugin has no prior-observation record for this owner (or a `createIfAbsent` hit an existing file). `FS_NOT_FOUND` also represents an edit rejected from confirmed absence. `FS_STALE_VERSION` means the backend version no longer matches the observed one (or the provider itself receives an edit for a missing target). Freshness authorization has no partial/full distinction, so there is no `FS_PARTIAL_OBSERVATION`. `FS_ALREADY_EXISTS` means a create-only tool command found an existing target; the caller chooses another path or edits the existing file. `dsh-fs-local` and the `dsh-fs-sandbox` containment check classify errno failures from resolution, metadata, reads, and listing with the `dsh-fs` function `fsErrorFromErrno`: `ENOENT` and `ENOTDIR` report `FS_NOT_FOUND`, `EACCES` and `EPERM` report `FS_PERMISSION_DENIED`, `EISDIR` reports `FS_NOT_REGULAR_FILE`, and other errno values report `FS_IO_ERROR`, each with the Node error as `cause`. ## No timeouts on file IO diff --git a/docs/subsystems/filesystem.zh.md b/docs/subsystems/filesystem.zh.md index 22f59a7884..8de900b9bd 100644 --- a/docs/subsystems/filesystem.zh.md +++ b/docs/subsystems/filesystem.zh.md @@ -375,7 +375,7 @@ import { FS_ERROR_CODES } from '@deepseek-ai/dsh-fs' type FsErrorCode = typeof FS_ERROR_CODES[number] ``` -目录列表使用 `FS_NOT_DIRECTORY`、`FS_PERMISSION_DENIED` 与 `FS_IO_ERROR` 区分已存在但并非目录的目标、被拒绝的列表操作和意外的后端 I/O 失败。`FS_SANDBOX_DENIED` 是强制执行沙箱的后端(`dsh-fs-sandbox`)所作的策略拒绝——模式边界拒绝了变更——与 `FS_PERMISSION_DENIED`(宿主内核拒绝)不同。`FS_NOT_OBSERVED` 表示策略插件没有此所有者的先前观测记录(或 `createIfAbsent` 遇到了现有文件)。`FS_NOT_FOUND` 也表示策略因确认缺失而拒绝 edit。`FS_STALE_VERSION` 表示后端版本不再与观测到的版本匹配(或提供方本身收到针对缺失目标的 edit)。新鲜度授权没有部分/完整之分,因此不存在 `FS_PARTIAL_OBSERVATION`。 +目录列表使用 `FS_NOT_DIRECTORY`、`FS_PERMISSION_DENIED` 与 `FS_IO_ERROR` 区分已存在但并非目录的目标、被拒绝的列表操作和意外的后端 I/O 失败。`FS_SANDBOX_DENIED` 是强制执行沙箱的后端(`dsh-fs-sandbox`)所作的策略拒绝——模式边界拒绝了变更——与 `FS_PERMISSION_DENIED`(宿主内核拒绝)不同。`FS_NOT_OBSERVED` 表示策略插件没有此所有者的先前观测记录(或 `createIfAbsent` 遇到了现有文件)。`FS_NOT_FOUND` 也表示策略因确认缺失而拒绝 edit。`FS_STALE_VERSION` 表示后端版本不再与观测到的版本匹配(或提供方本身收到针对缺失目标的 edit)。新鲜度授权没有部分/完整之分,因此不存在 `FS_PARTIAL_OBSERVATION`。`FS_ALREADY_EXISTS` 表示仅创建的工具命令遇到了已存在的目标;调用方应换一个路径或编辑现有文件。`dsh-fs-local` 与 `dsh-fs-sandbox` 的包含性检查用 `dsh-fs` 的函数 `fsErrorFromErrno` 分类解析、元数据、读取与目录列表中的 errno 失败:`ENOENT` 与 `ENOTDIR` 报告 `FS_NOT_FOUND`,`EACCES` 与 `EPERM` 报告 `FS_PERMISSION_DENIED`,`EISDIR` 报告 `FS_NOT_REGULAR_FILE`,其他 errno 报告 `FS_IO_ERROR`,均以 Node 错误为 `cause`。 ## 文件 IO 不设超时 diff --git a/docs/subsystems/forge.i18n.yaml b/docs/subsystems/forge.i18n.yaml index 97ebf7bece..408ac912e6 100644 --- a/docs/subsystems/forge.i18n.yaml +++ b/docs/subsystems/forge.i18n.yaml @@ -12,11 +12,14 @@ en: 26ccfa2e342e3e67 zh: fe6cc2a622757c48 /forges/change-request-actions: - en: 87d2c81d9514267f - zh: 1daf31c5b0127a1c + en: ae735fbdbdf2d870 + zh: 9903fcc571104ad8 +/forges/review-threads: + en: af729af6e59e915f + zh: 62ddae4cfcd4431f /forges/session-bindings: en: 13dfd36f2166bba0 zh: a369434f52f04d57 /forges/panel: - en: e52a5c57abc4d30e - zh: 509a40f421cc3d33 + en: 4020149c26661a2d + zh: 56842b057d4f662e diff --git a/docs/subsystems/forge.md b/docs/subsystems/forge.md index b5156c101f..3fc9cde00c 100644 --- a/docs/subsystems/forge.md +++ b/docs/subsystems/forge.md @@ -47,6 +47,18 @@ interface ForgeProvider { readonly forge: string /** Publish a comment or completed review. Omission declares that this provider cannot write reviews. */ submitReview?(ref: ChangeRequestRef, request: ForgeReviewRequest, signal: AbortSignal): Promise + /** + * Read the review threads of a change request at head commit `headSha`. The threads are returned only when the + * read names `headSha` as the head; once the head is another commit, the read resolves to a `stale` failure. + * Omission declares that this provider cannot read reviews. + */ + readReview?(ref: ChangeRequestRef, headSha: string, signal: AbortSignal): Promise + /** + * Write to the review threads or the pending review of a change request while its head is `write.headSha`; once the + * head is another commit, the write resolves to `stale` without writing. A write is never retried. Omission declares + * that this provider cannot write reviews, and its `readReview` then reports no viewer permission. + */ + writeReview?(ref: ChangeRequestRef, write: ForgeReviewWrite, signal: AbortSignal): Promise /** 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. */ @@ -71,9 +83,10 @@ interface ForgeProvider { /** * Read the changes of a change request at head commit `headSha` as the host computes them, as one unified * patch with `a/` and `b/` path prefixes. A patch is returned only when it describes `headSha`; once the - * head is another commit, the read resolves to a `stale` failure. + * head is another commit, the read resolves to a `stale` failure. `base`, when given, is the `baseSha` the + * latest status recorded with `headSha`; a provider may start reading from it before it confirms the head. */ - diff(ref: ChangeRequestRef, headSha: string, signal: AbortSignal): Promise + diff(ref: ChangeRequestRef, headSha: string, signal: AbortSignal, base?: string): 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. */ @@ -199,6 +212,11 @@ interface ChangeRequestSnapshot { readonly headSha: string /** Branch the change request merges into. */ readonly baseBranch: string + /** + * Base commit the host recorded with `headSha`, whose merge base with the head starts the change request's diff; + * omitted when the provider does not report it. + */ + readonly baseSha?: string /** Lines the change request adds. */ readonly additions: number /** Lines the change request deletes. */ @@ -238,9 +256,9 @@ 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; `stale`: the head is no longer the commit the read named, so reading again at the head of - * a later status can succeed; `unavailable`: any other failure. + * a later status can succeed; `unsupported`: the provider does not offer the read; `unavailable`: any other failure. */ - readonly code: 'not-found' | 'unauthenticated' | 'too-large' | 'stale' | 'unavailable' + readonly code: 'not-found' | 'unauthenticated' | 'too-large' | 'stale' | 'unsupported' | 'unavailable' /** Human-readable cause. */ readonly message: string } @@ -297,7 +315,7 @@ interface ForgeActionFailure { } ``` -`ctx.forge.submitReview` delegates an explicit comment or completed review to the provider. A provider that omits this capability returns unsupported. Consumers retain pending drafts and reconcile unknown write outcomes before resubmitting. +`ctx.forge.submitReview` delegates an explicit comment or completed review to the provider. A provider that omits this capability returns unsupported. Consumers reconcile unknown write outcomes before resubmitting; comments kept for a later review go to the code host's pending review through `ctx.forge.writeReview`. ```ts type-equiv /** One inline comment addressed to the old or new side of a pull request diff. */ @@ -317,17 +335,188 @@ type ForgeReviewRequest = { readonly headSha: string } & ( | { readonly kind: 'comment'; readonly comment: ForgeLineComment } - | { readonly kind: 'review'; readonly event: 'comment' | 'approve' | 'request-changes'; readonly body: string; readonly comments: readonly ForgeLineComment[] } + | { readonly kind: 'review'; readonly event: ForgeReviewEvent; readonly body: string; readonly comments: readonly ForgeLineComment[] } ) ``` ```ts type-equiv -/** Result of a review write; unknown outcomes must be checked on the host before another submission. */ +/** Result of a review submission; unknown outcomes must be checked on the host before another submission. */ type ForgeReviewResult = | { readonly ok: true; readonly url: string } | { readonly ok: false; readonly code: 'unsupported' | 'stale' | 'failed' | 'unknown'; readonly message: string } ``` +## Review threads + +`ctx.forge.readReview(ref, headSha, signal)` reads the review threads of a change request at one head commit. The provider returns `ForgeReviewData` only when the read names `headSha` as the head; otherwise it returns `stale`, which polls a watched change request again at once, as a stale diff read does. For a provider without `readReview` the service returns `unsupported`, so consumers show no threads instead of a failure. A line thread has two positions: `original` on the commit it was written on, and `current` on the head of the read, which is null once the host no longer places the thread on that commit. A consumer that shows the head's diff places a thread at `current` and shows the others with their `diffHunk`; reading the file at `original.commit` through `fileText` shows the whole file the thread was written on. The [GitHub provider README](../../packages/experimental/forge-github/README.md#use-this-package) owns how GitHub's fields map onto these positions. + +```ts type-equiv +/** Opaque host identity of one review thread. */ +type ForgeThreadId = Branded<'ForgeThreadId'> +``` + +```ts type-equiv +/** Opaque host identity of one review comment. */ +type ForgeCommentId = Branded<'ForgeCommentId'> +``` + +```ts type-equiv +/** Consecutive lines on one side of a diff whose new side is one commit. */ +interface ForgeAnchor { + /** + * Full object name of the commit the diff's new side shows: new-side (`R`) lines number this commit's file, and + * old-side (`L`) lines number the file at the diff's base. + */ + readonly commit: string + /** First line, inclusive. */ + readonly startLine: number + /** Last line, inclusive. */ + readonly endLine: number +} +``` + +```ts type-equiv +/** Account that wrote a review comment. */ +interface ForgeCommentAuthor { + /** Login as the host spells it. */ + readonly login: string + /** Avatar image URL, or null when the host reports none. */ + readonly avatarUrl: string | null + /** Whether the account is an app or bot rather than a person. */ + readonly bot: boolean +} +``` + +```ts type-equiv +/** One comment of a review thread. */ +interface ForgeReviewComment { + /** Host identity of the comment. */ + readonly id: ForgeCommentId + /** Author, or null when the account no longer exists. */ + readonly author: ForgeCommentAuthor | null + /** Markdown source as the author wrote it; it may contain raw HTML, which readers must not render as HTML. */ + readonly body: string + /** ISO 8601 time the comment was created. */ + readonly createdAt: string + /** Whether the comment belongs to the signed-in user's review that is not submitted yet, which only that user sees. */ + readonly pending: boolean + /** Web page of the comment, or null when the host reports none. */ + readonly url: string | null + /** Whether the signed-in user can replace the comment's text through `writeReview`; false for a provider without it. */ + readonly viewerCanEdit: boolean + /** Whether the signed-in user can delete the comment through `writeReview`; false for a provider without it. */ + readonly viewerCanDelete: boolean +} +``` + +```ts type-equiv +/** + * One review thread on a file of a change request. A line thread has two positions: `original` on the commit it was + * written on, and `current` on the head commit of the read. `current` is null once the host no longer places the + * thread on the head commit, as after later commits changed its lines. + */ +interface ForgeReviewThread { + /** Host identity of the thread. */ + readonly id: ForgeThreadId + /** Repository-relative path the thread is on. */ + readonly path: string + /** Old (`L`) or new (`R`) side of the diff the lines number. */ + readonly side: 'L' | 'R' + /** Whether the thread is on lines or on the whole file. */ + readonly subject: 'line' | 'file' + /** Lines in the diff that ends at the head commit of the read, whose `commit` is that head; null for an outdated or file thread. */ + readonly current: ForgeAnchor | null + /** Lines in the diff that ended at the commit the thread was written on; null for a file thread. */ + readonly original: ForgeAnchor | null + /** Diff hunk the host recorded when the thread was written, ending at its last line; null when it recorded none. */ + readonly diffHunk: string | null + /** Whether the thread is marked resolved. */ + readonly resolved: boolean + /** Whether the signed-in user can reply to the thread through `writeReview`; false for a provider without it. */ + readonly viewerCanReply: boolean + /** + * Whether the signed-in user can mark the thread resolved, or unresolved when `resolved`, through `writeReview`; false + * for a provider without it. + */ + readonly viewerCanResolve: boolean + /** Comments oldest first; the first starts the thread. */ + readonly comments: readonly ForgeReviewComment[] +} +``` + +```ts type-equiv +/** Review threads of a change request read at one head commit. */ +interface ForgeReviewData { + /** Head commit the threads were read at; `current` positions number its files. */ + readonly headSha: string + /** Login of the signed-in user the provider read as, or null when the host does not name one. */ + readonly viewer: string | null + /** Threads in host order, at most the provider's limit. */ + readonly threads: readonly ForgeReviewThread[] + /** Whether the change request has more threads than `threads` holds. */ + readonly truncated: boolean + /** The signed-in user's pending review, or null without one. */ + readonly pendingReview: ForgePendingReview | null + /** + * Whether the signed-in user can add comments to a pending review and submit or discard it through `writeReview`; + * false for a provider without it. + */ + readonly viewerCanReview: boolean +} +``` + +`ctx.forge.writeReview(ref, write, signal)` writes to the review threads and the signed-in user's pending review: a reply, at once or with `pending` inside the pending review; resolving or unresolving a thread; editing or deleting a comment; a line comment at the head added to the pending review; and submitting or discarding the pending review. A pending reply or comment starts a pending review when the user has none. Every write carries the head commit the user saw; the provider checks it first and returns `stale` without writing once the head moved, which polls a watched change request as a stale read does, and the service polls a watched change request at once after any other result. Writes are never retried, and an `unknown` result must be checked on the host before the write is tried again. For a provider without `writeReview` the service returns `unsupported`. `ForgeReviewData` carries the pending review and the user's permissions for each thread, comment, and the pending review, which a provider without `writeReview` reports as absent, so consumers offer only the writes that can succeed. The [GitHub provider README](../../packages/experimental/forge-github/README.md#use-this-package) owns the GraphQL mutations and how their outcomes map onto these results. + +```ts type-equiv +/** Opaque host identity of one review. */ +type ForgeReviewId = Branded<'ForgeReviewId'> +``` + +```ts type-equiv +/** The signed-in user's review of a change request that is not submitted yet, which only that user sees. */ +interface ForgePendingReview { + /** Host identity of the review. */ + readonly id: ForgeReviewId + /** Comments the review holds. */ + readonly comments: number +} +``` + +```ts type-equiv +/** Decision a submitted review records. */ +type ForgeReviewEvent = 'comment' | 'approve' | 'request-changes' +``` + +```ts type-equiv +/** + * One write to the review threads and the pending review of a change request. `pending` replies and `add-pending` + * comments join the signed-in user's pending review, which is started when the user has none. A host that adds every + * reply to an existing pending review, as GitHub does, refuses a reply with `pending: false` while one exists. + */ +type ForgeReviewWrite = { + /** Head commit the user saw; providers check it before writing and refuse with `stale` once the head is another commit. */ + readonly headSha: string +} & ( + | { readonly kind: 'reply'; readonly thread: ForgeThreadId; readonly body: string; readonly pending: boolean } + | { readonly kind: 'resolve'; readonly thread: ForgeThreadId; readonly resolved: boolean } + | { readonly kind: 'edit-comment'; readonly comment: ForgeCommentId; readonly body: string } + /** Deleting the only comment of a thread deletes the thread. */ + | { readonly kind: 'delete-comment'; readonly comment: ForgeCommentId } + /** A comment on lines of `headSha`. */ + | { readonly kind: 'add-pending'; readonly comment: ForgeLineComment } + | { readonly kind: 'submit-pending'; readonly review: ForgeReviewId; readonly event: ForgeReviewEvent; readonly body: string } + /** Delete the pending review with every comment it holds. */ + | { readonly kind: 'discard-pending'; readonly review: ForgeReviewId } +) +``` + +```ts type-equiv +/** Result of a review write; an `unknown` outcome must be checked on the host before the write is tried again. */ +type ForgeReviewWriteResult = + | { readonly ok: true } + | { readonly ok: false; readonly code: 'unsupported' | 'stale' | 'failed' | 'unknown'; 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. @@ -617,6 +806,10 @@ type ForgeChangeRequestStatesFrame = 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`, which returns a patch only for that head commit, or the `ForgeCommitRange` it names through the provider's `compare`; both return files that the Host parsed into `ForgeDiffFile` hunks, each with its review layer and change digest. `localCommits` and `changeRequestCommits` list the commits a range can name, the latter as null when the provider does not read commits. `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, always against the automatic fork point. A `branch` scope whose `base` names a ref compares with the merge base of HEAD and that branch instead; `diffBases` lists the branches the view offers, with the automatic fork point. When the head moved past the latest status, the provider returns `stale` and `ctx.forge.diff` polls the change request at once, once per head commit; `changeRequestDiff` rejects with `forgePanel/stale-head` and caches nothing, and the changes view keeps loading until a later status arrives, then reads again at that status's head. Review comments therefore always address the commit whose lines the view shows. +`changeRequestReview(session, url, headSha, fresh)` reads the threads of a bound change request at the head of the diff the view shows and refuses another head with `forgePanel/stale-head`, `forgePanel/review-unsupported` for a provider without review reads, and `forgePanel/review-failed` otherwise. It keeps one read per change request in memory, keyed by head commit and the status's update time and bounded by `maxCachedReviewBytes`, and reads again once the kept read is `reviewRefreshMs` old or when `fresh` asks for it, as the first read after the reader refreshes the view does: on GitHub a new comment or reply changes a pull request's update time, but resolving a thread does not. The changes view asks again at every status it receives while visible, and overlays the threads on the files the [panel README](../../packages/experimental/client-ui-forge/README.md#use-this-package) describes. + +`changeRequestReviewWrite(session, url, write)` passes one `ForgeReviewWrite` for a bound change request to `ctx.forge.writeReview` after checking its head commit, ids, text, and line range, and returns the `ForgeReviewWriteResult`. Once the provider answers, whatever the answer, it drops the kept threads of that change request and every read that started before, so the next `changeRequestReview` reads the host; `submitReview` drops them as well. The changes view sends one write at a time and reads the threads again after each write that may have changed them. + ```ts type-equiv /** * Consecutive commits a changes view shows instead of its whole comparison, named by full object names in the order @@ -907,7 +1100,8 @@ async fetchChecks(ref: ChangeRequestRef, signal: AbortSignal): Promise +/** + * Read the review threads of a change request at one head commit. A + * `stale` failure polls a watched change request again as {@link ForgeService.diff} does. + * @param ref - change request served by a registered provider. + * @param headSha - head commit the threads must be read at, usually the head of the displayed diff. + * @param signal - cancellation of the read. + * @returns the threads read at `headSha`, or why the host did not provide them; `unsupported` when the provider cannot read reviews. + * @throws when no provider serves the change request's forge, or with `signal.reason` when it aborts. + */ +async readReview(ref: ChangeRequestRef, headSha: string, signal: AbortSignal): Promise + +/** + * Write to the review threads or the pending review of a change request while its head is still `write.headSha`, then + * poll the change request at once when it is watched, unless the provider cannot write reviews. A `stale` result polls + * it as a stale read does. + * @param ref - change request served by a registered provider. + * @param write - the write and the head commit the user saw. + * @param signal - cancellation of the write. + * @returns whether the host took the write; `unsupported` when the provider cannot write reviews, and `unknown` when the + * outcome must be checked on the host before the write is tried again. + * @throws when no provider serves the change request's forge, or with `signal.reason` when it aborts. + */ +async writeReview(ref: ChangeRequestRef, write: ForgeReviewWrite, signal: AbortSignal): Promise + /** * List the commits of a change request at its current head. * @param ref - change request served by a registered provider. @@ -1187,7 +1405,28 @@ Serves the forge panel. Following a Session inspects the checkout of its current @Remote async changeRequestText(session: Session, url: string, commit: string, path: string, signal: AbortSignal): Promise /** - * Publish a line comment or completed review for a visible Session binding. + * Read the review threads of a change request of a Session at the head commit of its displayed diff. Threads read + * at the same head commit and update time are reused from memory, for any Session, until they are + * `reviewRefreshMs` old, `maxCachedReviewBytes` evicts them, or a review write through this panel drops them; a read + * again after that age, or a `fresh` read, shows threads resolved elsewhere since, which do not change a GitHub pull + * request's update time. + * @param session - Session the change request is bound to. + * @param url - binding URL of the change request. + * @param headSha - head commit of the displayed diff. + * @param fresh - whether to read from the host even when threads at this head and update time are kept, as after the + * reader refreshes the view; the result replaces the kept threads. + * @param signal - request cancellation. + * @returns the threads at `headSha`, with `current` positions numbering the files of that diff. + * @throws `forgePanel/not-bound` unless the URL is a visible binding of the Session that a provider serves; + * `forgePanel/stale-head` when the latest status or the host names another head, which a host answer polls again; + * `forgePanel/review-unsupported` when the provider cannot read reviews; `forgePanel/review-failed` before the + * status was read and when the host does not provide the threads otherwise. + */ +@Remote async changeRequestReview(session: Session, url: string, headSha: string, fresh: boolean, signal: AbortSignal): Promise + +/** + * Publish a line comment or completed review for a visible Session binding. The threads kept for the change request + * are dropped once the host answers, so the next read shows the comments. * @param session - Session retaining the change request. * @param url - visible binding URL. * @param request - reviewed head, ranges, plain text, and explicit review decision. @@ -1196,6 +1435,21 @@ Serves the forge panel. Following a Session inspects the checkout of its current */ @Remote async submitReview(session: Session, url: string, request: ForgeReviewRequest, signal: AbortSignal): Promise +/** + * Write to the review threads or the pending review of a change request of a Session while its head is still the + * head the user saw. The threads kept for the change request are dropped once the host answers, so the next read + * shows the write, which a resolve does not show through a GitHub pull request's update time. + * @param session - Session the change request is bound to. + * @param url - binding URL of the change request. + * @param write - the write and the head commit of the threads the user saw. + * @param signal - request cancellation. + * @returns whether the host took the write; `stale` once the head moved, which polls the change request again, and + * `unknown` when the outcome must be checked on the host before the write is tried again. + * @throws `forgePanel/not-bound` unless the URL is a visible binding of the Session that a provider serves; an error + * for a write without text, a head that is not a commit object name, or an invalid line range. + */ +@Remote async changeRequestReviewWrite( session: Session, url: string, write: ForgeReviewWrite, signal: AbortSignal, ): Promise + /** * Mark a draft change request of a Session ready for review; the change * request is polled again once the host answers. diff --git a/docs/subsystems/forge.zh.md b/docs/subsystems/forge.zh.md index 2ca42cde71..d127005c08 100644 --- a/docs/subsystems/forge.zh.md +++ b/docs/subsystems/forge.zh.md @@ -47,6 +47,18 @@ interface ForgeProvider { readonly forge: string /** Publish a comment or completed review. Omission declares that this provider cannot write reviews. */ submitReview?(ref: ChangeRequestRef, request: ForgeReviewRequest, signal: AbortSignal): Promise + /** + * Read the review threads of a change request at head commit `headSha`. The threads are returned only when the + * read names `headSha` as the head; once the head is another commit, the read resolves to a `stale` failure. + * Omission declares that this provider cannot read reviews. + */ + readReview?(ref: ChangeRequestRef, headSha: string, signal: AbortSignal): Promise + /** + * Write to the review threads or the pending review of a change request while its head is `write.headSha`; once the + * head is another commit, the write resolves to `stale` without writing. A write is never retried. Omission declares + * that this provider cannot write reviews, and its `readReview` then reports no viewer permission. + */ + writeReview?(ref: ChangeRequestRef, write: ForgeReviewWrite, signal: AbortSignal): Promise /** 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. */ @@ -71,9 +83,10 @@ interface ForgeProvider { /** * Read the changes of a change request at head commit `headSha` as the host computes them, as one unified * patch with `a/` and `b/` path prefixes. A patch is returned only when it describes `headSha`; once the - * head is another commit, the read resolves to a `stale` failure. + * head is another commit, the read resolves to a `stale` failure. `base`, when given, is the `baseSha` the + * latest status recorded with `headSha`; a provider may start reading from it before it confirms the head. */ - diff(ref: ChangeRequestRef, headSha: string, signal: AbortSignal): Promise + diff(ref: ChangeRequestRef, headSha: string, signal: AbortSignal, base?: string): 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. */ @@ -199,6 +212,11 @@ interface ChangeRequestSnapshot { readonly headSha: string /** Branch the change request merges into. */ readonly baseBranch: string + /** + * Base commit the host recorded with `headSha`, whose merge base with the head starts the change request's diff; + * omitted when the provider does not report it. + */ + readonly baseSha?: string /** Lines the change request adds. */ readonly additions: number /** Lines the change request deletes. */ @@ -238,9 +256,9 @@ 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; `stale`: the head is no longer the commit the read named, so reading again at the head of - * a later status can succeed; `unavailable`: any other failure. + * a later status can succeed; `unsupported`: the provider does not offer the read; `unavailable`: any other failure. */ - readonly code: 'not-found' | 'unauthenticated' | 'too-large' | 'stale' | 'unavailable' + readonly code: 'not-found' | 'unauthenticated' | 'too-large' | 'stale' | 'unsupported' | 'unavailable' /** Human-readable cause. */ readonly message: string } @@ -297,7 +315,7 @@ interface ForgeActionFailure { } ``` -`ctx.forge.submitReview` 将明确指定的评论或完整评审交给 provider;未实现此能力的 provider 返回不支持。消费者保存待提交草稿,并在再次提交前核对结果不明的写操作。 +`ctx.forge.submitReview` 将明确指定的评论或完整评审交给 provider;未实现此能力的 provider 返回不支持。消费者在再次提交前核对结果不明的写操作;留待之后一起提交的评论通过 `ctx.forge.writeReview` 写入代码托管平台的待提交评审。 ```ts type-equiv /** One inline comment addressed to the old or new side of a pull request diff. */ @@ -317,17 +335,188 @@ type ForgeReviewRequest = { readonly headSha: string } & ( | { readonly kind: 'comment'; readonly comment: ForgeLineComment } - | { readonly kind: 'review'; readonly event: 'comment' | 'approve' | 'request-changes'; readonly body: string; readonly comments: readonly ForgeLineComment[] } + | { readonly kind: 'review'; readonly event: ForgeReviewEvent; readonly body: string; readonly comments: readonly ForgeLineComment[] } ) ``` ```ts type-equiv -/** Result of a review write; unknown outcomes must be checked on the host before another submission. */ +/** Result of a review submission; unknown outcomes must be checked on the host before another submission. */ type ForgeReviewResult = | { readonly ok: true; readonly url: string } | { readonly ok: false; readonly code: 'unsupported' | 'stale' | 'failed' | 'unknown'; readonly message: string } ``` +## 评审讨论 + +`ctx.forge.readReview(ref, headSha, signal)` 读取变更请求在某个 head 提交处的评审讨论。只有当读取时的 head 就是 `headSha` 时,提供方才返回 `ForgeReviewData`;否则返回 `stale`,并像 diff 读取遇到 `stale` 时一样,立即重新轮询被关注的变更请求。提供方没有实现 `readReview` 时,服务返回 `unsupported`,使用方据此不显示讨论,也不报错。行级讨论有两套位置:`original` 是写下讨论时所在提交上的行,`current` 是本次读取的 head 上的行;托管平台不再把讨论放在该提交上时,`current` 为 null。显示 head diff 的使用方把讨论放在 `current` 位置,其余讨论用 `diffHunk` 展示;用 `fileText` 读取 `original.commit` 上的文件,可以看到写下讨论时的完整文件。GitHub 各字段如何映射到这两套位置,由 [GitHub 提供方 README](../../packages/experimental/forge-github/README.zh.md#use-this-package) 说明。 + +```ts type-equiv +/** Opaque host identity of one review thread. */ +type ForgeThreadId = Branded<'ForgeThreadId'> +``` + +```ts type-equiv +/** Opaque host identity of one review comment. */ +type ForgeCommentId = Branded<'ForgeCommentId'> +``` + +```ts type-equiv +/** Consecutive lines on one side of a diff whose new side is one commit. */ +interface ForgeAnchor { + /** + * Full object name of the commit the diff's new side shows: new-side (`R`) lines number this commit's file, and + * old-side (`L`) lines number the file at the diff's base. + */ + readonly commit: string + /** First line, inclusive. */ + readonly startLine: number + /** Last line, inclusive. */ + readonly endLine: number +} +``` + +```ts type-equiv +/** Account that wrote a review comment. */ +interface ForgeCommentAuthor { + /** Login as the host spells it. */ + readonly login: string + /** Avatar image URL, or null when the host reports none. */ + readonly avatarUrl: string | null + /** Whether the account is an app or bot rather than a person. */ + readonly bot: boolean +} +``` + +```ts type-equiv +/** One comment of a review thread. */ +interface ForgeReviewComment { + /** Host identity of the comment. */ + readonly id: ForgeCommentId + /** Author, or null when the account no longer exists. */ + readonly author: ForgeCommentAuthor | null + /** Markdown source as the author wrote it; it may contain raw HTML, which readers must not render as HTML. */ + readonly body: string + /** ISO 8601 time the comment was created. */ + readonly createdAt: string + /** Whether the comment belongs to the signed-in user's review that is not submitted yet, which only that user sees. */ + readonly pending: boolean + /** Web page of the comment, or null when the host reports none. */ + readonly url: string | null + /** Whether the signed-in user can replace the comment's text through `writeReview`; false for a provider without it. */ + readonly viewerCanEdit: boolean + /** Whether the signed-in user can delete the comment through `writeReview`; false for a provider without it. */ + readonly viewerCanDelete: boolean +} +``` + +```ts type-equiv +/** + * One review thread on a file of a change request. A line thread has two positions: `original` on the commit it was + * written on, and `current` on the head commit of the read. `current` is null once the host no longer places the + * thread on the head commit, as after later commits changed its lines. + */ +interface ForgeReviewThread { + /** Host identity of the thread. */ + readonly id: ForgeThreadId + /** Repository-relative path the thread is on. */ + readonly path: string + /** Old (`L`) or new (`R`) side of the diff the lines number. */ + readonly side: 'L' | 'R' + /** Whether the thread is on lines or on the whole file. */ + readonly subject: 'line' | 'file' + /** Lines in the diff that ends at the head commit of the read, whose `commit` is that head; null for an outdated or file thread. */ + readonly current: ForgeAnchor | null + /** Lines in the diff that ended at the commit the thread was written on; null for a file thread. */ + readonly original: ForgeAnchor | null + /** Diff hunk the host recorded when the thread was written, ending at its last line; null when it recorded none. */ + readonly diffHunk: string | null + /** Whether the thread is marked resolved. */ + readonly resolved: boolean + /** Whether the signed-in user can reply to the thread through `writeReview`; false for a provider without it. */ + readonly viewerCanReply: boolean + /** + * Whether the signed-in user can mark the thread resolved, or unresolved when `resolved`, through `writeReview`; false + * for a provider without it. + */ + readonly viewerCanResolve: boolean + /** Comments oldest first; the first starts the thread. */ + readonly comments: readonly ForgeReviewComment[] +} +``` + +```ts type-equiv +/** Review threads of a change request read at one head commit. */ +interface ForgeReviewData { + /** Head commit the threads were read at; `current` positions number its files. */ + readonly headSha: string + /** Login of the signed-in user the provider read as, or null when the host does not name one. */ + readonly viewer: string | null + /** Threads in host order, at most the provider's limit. */ + readonly threads: readonly ForgeReviewThread[] + /** Whether the change request has more threads than `threads` holds. */ + readonly truncated: boolean + /** The signed-in user's pending review, or null without one. */ + readonly pendingReview: ForgePendingReview | null + /** + * Whether the signed-in user can add comments to a pending review and submit or discard it through `writeReview`; + * false for a provider without it. + */ + readonly viewerCanReview: boolean +} +``` + +`ctx.forge.writeReview(ref, write, signal)` 写入评审讨论和当前登录用户的待提交评审:回复讨论,立即发布,或在 `pending` 时加入待提交评审;把讨论标记为已解决或取消解决;编辑或删除评论;在 head 上添加一条行内评论到待提交评审;以及提交或丢弃待提交评审。用户还没有待提交评审时,待提交的回复或评论会先开始一个评审。每个写操作都带有用户看到的 head 提交;提供方先检查它,head 已变化时不写入,直接返回 `stale`,并像读取遇到 `stale` 时那样重新轮询被关注的变更请求;其他结果之后,服务都会立即重新轮询被关注的变更请求。写操作从不重试;结果为 `unknown` 时,必须先在托管平台上核对,再重试。提供方没有实现 `writeReview` 时,服务返回 `unsupported`。`ForgeReviewData` 带有待提交评审,以及用户对每个讨论、每条评论和待提交评审的权限;未实现 `writeReview` 的提供方把这些权限报告为没有,使用方据此只提供能够成功的写操作。GraphQL mutation 以及它们的结果如何对应到这些返回值,由 [GitHub 提供方 README](../../packages/experimental/forge-github/README.zh.md#use-this-package) 说明。 + +```ts type-equiv +/** Opaque host identity of one review. */ +type ForgeReviewId = Branded<'ForgeReviewId'> +``` + +```ts type-equiv +/** The signed-in user's review of a change request that is not submitted yet, which only that user sees. */ +interface ForgePendingReview { + /** Host identity of the review. */ + readonly id: ForgeReviewId + /** Comments the review holds. */ + readonly comments: number +} +``` + +```ts type-equiv +/** Decision a submitted review records. */ +type ForgeReviewEvent = 'comment' | 'approve' | 'request-changes' +``` + +```ts type-equiv +/** + * One write to the review threads and the pending review of a change request. `pending` replies and `add-pending` + * comments join the signed-in user's pending review, which is started when the user has none. A host that adds every + * reply to an existing pending review, as GitHub does, refuses a reply with `pending: false` while one exists. + */ +type ForgeReviewWrite = { + /** Head commit the user saw; providers check it before writing and refuse with `stale` once the head is another commit. */ + readonly headSha: string +} & ( + | { readonly kind: 'reply'; readonly thread: ForgeThreadId; readonly body: string; readonly pending: boolean } + | { readonly kind: 'resolve'; readonly thread: ForgeThreadId; readonly resolved: boolean } + | { readonly kind: 'edit-comment'; readonly comment: ForgeCommentId; readonly body: string } + /** Deleting the only comment of a thread deletes the thread. */ + | { readonly kind: 'delete-comment'; readonly comment: ForgeCommentId } + /** A comment on lines of `headSha`. */ + | { readonly kind: 'add-pending'; readonly comment: ForgeLineComment } + | { readonly kind: 'submit-pending'; readonly review: ForgeReviewId; readonly event: ForgeReviewEvent; readonly body: string } + /** Delete the pending review with every comment it holds. */ + | { readonly kind: 'discard-pending'; readonly review: ForgeReviewId } +) +``` + +```ts type-equiv +/** Result of a review write; an `unknown` outcome must be checked on the host before the write is tried again. */ +type ForgeReviewWriteResult = + | { readonly ok: true } + | { readonly ok: false; readonly code: 'unsupported' | 'stale' | 'failed' | 'unknown'; 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)。 @@ -617,6 +806,10 @@ type ForgeChangeRequestStatesFrame = 变更视图经同一个 Remote 读取。`localDiff` 读取 Session 检出的某个 `ForgeLocalScope`;`changeRequestDiff` 经提供方的 `diff` 读取已绑定变更请求在其最新状态 head 处的变更(提供方只返回描述该 head 提交的补丁),或经提供方的 `compare` 读取它指定的 `ForgeCommitRange`;两者返回的文件都已在宿主上解析为 `ForgeDiffFile` 的 hunk,并带有分层与改动摘要。`localCommits` 与 `changeRequestCommits` 列出范围可以指定的提交,提供方不读取提交时后者返回 null。`localText` 与 `changeRequestText` 读取一个文件的新版本,用来补出 hunk 之间未变更的行。每次检查还会把 `branch` 范围的规模统计为 `ForgePanelState.local`,供面板的变更卡片使用,始终以自动分叉点为准。`base` 指定了引用的 `branch` 范围改为与 HEAD 和该分支的合并基准比较;`diffBases` 列出视图可选的分支以及自动分叉点。head 已越过最新状态时,提供方返回 `stale`,`ctx.forge.diff` 立即重新轮询该变更请求(每个 head 提交一次);`changeRequestDiff` 以 `forgePanel/stale-head` 拒绝且不缓存任何内容,变更视图保持加载,直到更新的状态到达后按该状态的 head 重新读取。因此评审评论始终指向视图所显示行的那个提交。 +`changeRequestReview(session, url, headSha, fresh)` 在视图所显示 diff 的 head 处读取已绑定变更请求的讨论;head 不同时以 `forgePanel/stale-head` 拒绝,提供方不支持读取讨论时返回 `forgePanel/review-unsupported`,其他失败返回 `forgePanel/review-failed`。每个变更请求在内存里保留一次读取结果,以 head 提交和状态的更新时间为键、受 `maxCachedReviewBytes` 约束,保留时间达到 `reviewRefreshMs` 后,或 `fresh` 要求时(读者刷新视图后的第一次读取就是这样)重新读取:在 GitHub 上,新评论和回复会改变 PR 的更新时间,解决讨论则不会。变更视图可见时,每收到一次状态就重新请求一次,并按[面板 README](../../packages/experimental/client-ui-forge/README.zh.md#use-this-package) 所述把讨论叠加到文件上。 + +`changeRequestReviewWrite(session, url, write)` 检查已绑定变更请求的一个 `ForgeReviewWrite` 的 head 提交、id、文字和行范围后,交给 `ctx.forge.writeReview`,并返回 `ForgeReviewWriteResult`。提供方给出答复后,无论结果如何,都会丢弃该变更请求保留的讨论,以及在此之前开始的所有读取,使下一次 `changeRequestReview` 从托管平台读取;`submitReview` 同样如此。变更视图一次只发送一个写操作,每个可能改变讨论的写操作之后都会重新读取讨论。 + ```ts type-equiv /** * Consecutive commits a changes view shows instead of its whole comparison, named by full object names in the order @@ -907,7 +1100,8 @@ async fetchChecks(ref: ChangeRequestRef, signal: AbortSignal): Promise +/** + * Read the review threads of a change request at one head commit. A + * `stale` failure polls a watched change request again as {@link ForgeService.diff} does. + * @param ref - change request served by a registered provider. + * @param headSha - head commit the threads must be read at, usually the head of the displayed diff. + * @param signal - cancellation of the read. + * @returns the threads read at `headSha`, or why the host did not provide them; `unsupported` when the provider cannot read reviews. + * @throws when no provider serves the change request's forge, or with `signal.reason` when it aborts. + */ +async readReview(ref: ChangeRequestRef, headSha: string, signal: AbortSignal): Promise + +/** + * Write to the review threads or the pending review of a change request while its head is still `write.headSha`, then + * poll the change request at once when it is watched, unless the provider cannot write reviews. A `stale` result polls + * it as a stale read does. + * @param ref - change request served by a registered provider. + * @param write - the write and the head commit the user saw. + * @param signal - cancellation of the write. + * @returns whether the host took the write; `unsupported` when the provider cannot write reviews, and `unknown` when the + * outcome must be checked on the host before the write is tried again. + * @throws when no provider serves the change request's forge, or with `signal.reason` when it aborts. + */ +async writeReview(ref: ChangeRequestRef, write: ForgeReviewWrite, signal: AbortSignal): Promise + /** * List the commits of a change request at its current head. * @param ref - change request served by a registered provider. @@ -1187,7 +1405,28 @@ Serves the forge panel. Following a Session inspects the checkout of its current @Remote async changeRequestText(session: Session, url: string, commit: string, path: string, signal: AbortSignal): Promise /** - * Publish a line comment or completed review for a visible Session binding. + * Read the review threads of a change request of a Session at the head commit of its displayed diff. Threads read + * at the same head commit and update time are reused from memory, for any Session, until they are + * `reviewRefreshMs` old, `maxCachedReviewBytes` evicts them, or a review write through this panel drops them; a read + * again after that age, or a `fresh` read, shows threads resolved elsewhere since, which do not change a GitHub pull + * request's update time. + * @param session - Session the change request is bound to. + * @param url - binding URL of the change request. + * @param headSha - head commit of the displayed diff. + * @param fresh - whether to read from the host even when threads at this head and update time are kept, as after the + * reader refreshes the view; the result replaces the kept threads. + * @param signal - request cancellation. + * @returns the threads at `headSha`, with `current` positions numbering the files of that diff. + * @throws `forgePanel/not-bound` unless the URL is a visible binding of the Session that a provider serves; + * `forgePanel/stale-head` when the latest status or the host names another head, which a host answer polls again; + * `forgePanel/review-unsupported` when the provider cannot read reviews; `forgePanel/review-failed` before the + * status was read and when the host does not provide the threads otherwise. + */ +@Remote async changeRequestReview(session: Session, url: string, headSha: string, fresh: boolean, signal: AbortSignal): Promise + +/** + * Publish a line comment or completed review for a visible Session binding. The threads kept for the change request + * are dropped once the host answers, so the next read shows the comments. * @param session - Session retaining the change request. * @param url - visible binding URL. * @param request - reviewed head, ranges, plain text, and explicit review decision. @@ -1196,6 +1435,21 @@ Serves the forge panel. Following a Session inspects the checkout of its current */ @Remote async submitReview(session: Session, url: string, request: ForgeReviewRequest, signal: AbortSignal): Promise +/** + * Write to the review threads or the pending review of a change request of a Session while its head is still the + * head the user saw. The threads kept for the change request are dropped once the host answers, so the next read + * shows the write, which a resolve does not show through a GitHub pull request's update time. + * @param session - Session the change request is bound to. + * @param url - binding URL of the change request. + * @param write - the write and the head commit of the threads the user saw. + * @param signal - request cancellation. + * @returns whether the host took the write; `stale` once the head moved, which polls the change request again, and + * `unknown` when the outcome must be checked on the host before the write is tried again. + * @throws `forgePanel/not-bound` unless the URL is a visible binding of the Session that a provider serves; an error + * for a write without text, a head that is not a commit object name, or an invalid line range. + */ +@Remote async changeRequestReviewWrite( session: Session, url: string, write: ForgeReviewWrite, signal: AbortSignal, ): Promise + /** * Mark a draft change request of a Session ready for review; the change * request is polled again once the host answers. diff --git a/docs/subsystems/lsp.i18n.yaml b/docs/subsystems/lsp.i18n.yaml index cae4151b07..a89df73725 100644 --- a/docs/subsystems/lsp.i18n.yaml +++ b/docs/subsystems/lsp.i18n.yaml @@ -15,5 +15,8 @@ en: d260facb7ad22440 zh: f4868aae5be01eb6 /lsp-navigation/provider-and-service: - en: 754b4f1a0c80e332 - zh: aaacbc912dd6337b + en: 9977196522f2d7e5 + zh: f971ba2c60beda68 +/lsp-navigation/failures: + en: 50959870fd65acce + zh: 9f9e0c2b04572235 diff --git a/docs/subsystems/lsp.md b/docs/subsystems/lsp.md index 5a8adb24c1..647fd2920b 100644 --- a/docs/subsystems/lsp.md +++ b/docs/subsystems/lsp.md @@ -162,7 +162,31 @@ interface LspService { } ``` -`LspProviderId` is the seam's branded id (`Branded<'LspProviderId'>` from [dsh-brand](../../packages/util/brand)); `LspError` extends `HarnessError` with stable codes such as `LSP_INVALID_PROVIDER`, `LSP_CONFLICT`, `LSP_UNAVAILABLE`, `LSP_DISPOSED`, `LSP_UNSUPPORTED_OPERATION`, and `LSP_MALFORMED_RESPONSE`, which callers route on instead of parsing `message`. +`LspProviderId` is the seam's branded id (`Branded<'LspProviderId'>` from [dsh-brand](../../packages/util/brand)); `LspError` extends `HarnessError` with the stable codes listed under [Failures](#failures), which callers route on instead of parsing `message`. + + +## Failures + +Every expected failure of an `lsp` tool call is a `HarnessError` whose `name` and `code` the tool result keeps in `error.info`. `LspError` sets `name` to `LspError`; its `code` stays open because providers and consumers add codes, so consumers tolerate codes they do not know. Invalid tool arguments fail as `ToolArgsError` `INVALID_ARGS`, and caller cancellation as `AbortError`. Workspace and source reads keep the filesystem provider's `FsError` code, such as `FS_NOT_FOUND`, `FS_NOT_REGULAR_FILE`, or `FS_NOT_TEXT`, and prefix the LSP operand to its message; the stdio provider adds `FS_NOT_DIRECTORY` for a non-directory workspace and `FS_TOO_LARGE` for a source over `maxDocumentBytes`. A coded query failure whose server teardown also fails keeps the query failure's `name` and `code` and appends the teardown failure to its message; a query failure without a code, such as a cancellation, surfaces with the teardown failure as an `AggregateError` without `error.info`. + +| Code | Raised by | Meaning | Consumer action | +|---|---|---|---| +| `LSP_INVALID_PROVIDER` | Seam | A registration has an empty id or language id, no extensions, or an invalid extension | Fix the registration | +| `LSP_CONFLICT` | Seam | The provider id or an extension is already registered | Remove the duplicate registration | +| `LSP_UNAVAILABLE` | Seam | No provider handles the file's extension | Query a supported file or add a provider | +| `LSP_WORKSPACE_REQUIRED` | `lsp` tool | The call has no Agent Session workspace | Call the tool from an Agent Session | +| `LSP_DISPOSED` | Provider | The provider or its server instance was disposed | Retry after the provider is mounted again | +| `LSP_UNSUPPORTED_OPERATION` | Provider | The server lacks the operation or transient open/close synchronization | Choose another operation or server | +| `LSP_MALFORMED_RESPONSE` | Provider | The server's `initialize` or query result violates the protocol | Report the server defect | +| `LSP_FILE_OUTSIDE_WORKSPACE` | Stdio provider | The source resolves outside the canonical workspace | Query a file inside the workspace | +| `LSP_UNSUPPORTED_ENCODING` | Stdio provider | The server negotiated a position encoding other than UTF-16 | Use a server that supports UTF-16 | +| `LSP_SPAWN_FAILED` | Stdio provider | The subprocess provider failed to launch or run the server; a provider `HarnessError` keeps its own `name` and `code` instead | Fix the server command or installation | +| `LSP_SERVER_EXITED` | Stdio provider | The server exited or closed its stdin; after an exit, the message carries the retained stderr tail | Retry later; inspect the stderr tail when it repeats | +| `LSP_PROTOCOL_ERROR` | Stdio provider | The server's output broke base-protocol framing or JSON | Report the server defect | +| `LSP_MESSAGE_TOO_LARGE` | Stdio provider | A server message exceeded `maxMessageBytes` | Narrow the query or raise `maxMessageBytes` | +| `LSP_SERVER_ERROR` | Stdio provider | The server answered with a JSON-RPC error; the message carries its numeric code and text | Act on the server's message | + +After a transport failure (`LSP_SPAWN_FAILED`, `LSP_SERVER_EXITED`, `LSP_PROTOCOL_ERROR`, or `LSP_MESSAGE_TOO_LARGE`), the stdio provider retries the read-only query once on a fresh process when the failed process tears down cleanly. diff --git a/docs/subsystems/lsp.zh.md b/docs/subsystems/lsp.zh.md index e041217866..7a6ef8d3c6 100644 --- a/docs/subsystems/lsp.zh.md +++ b/docs/subsystems/lsp.zh.md @@ -162,7 +162,31 @@ interface LspService { } ``` -`LspProviderId` 是该 seam 的品牌化 id(来自 [dsh-brand](../../packages/util/brand) 的 `Branded<'LspProviderId'>`);`LspError` 扩展 `HarnessError`,提供 `LSP_INVALID_PROVIDER`、`LSP_CONFLICT`、`LSP_UNAVAILABLE`、`LSP_DISPOSED`、`LSP_UNSUPPORTED_OPERATION` 和 `LSP_MALFORMED_RESPONSE` 等稳定错误码,调用方应按错误码路由,而不是解析 `message`。 +`LspProviderId` 是该 seam 的品牌化 id(来自 [dsh-brand](../../packages/util/brand) 的 `Branded<'LspProviderId'>`);`LspError` 扩展 `HarnessError`,提供[失败](#failures)一节列出的稳定错误码,调用方应按错误码路由,而不是解析 `message`。 + + +## 失败 + +`lsp` 工具调用的每个预期失败都是 `HarnessError`,工具结果会把它的 `name` 和 `code` 保留在 `error.info` 中。`LspError` 把 `name` 设为 `LspError`;由于提供方和消费方会添加错误码,它的 `code` 保持开放,消费方须容忍未知错误码。无效的工具参数以 `ToolArgsError` `INVALID_ARGS` 失败,调用方取消以 `AbortError` 失败。工作区与源文件读取保留文件系统提供方的 `FsError` 错误码,例如 `FS_NOT_FOUND`、`FS_NOT_REGULAR_FILE` 或 `FS_NOT_TEXT`,并在其消息前加上 LSP 操作对象;stdio 提供方对非目录工作区使用 `FS_NOT_DIRECTORY`,对超过 `maxDocumentBytes` 的源文件使用 `FS_TOO_LARGE`。如果带错误码的查询失败后服务器拆除也失败,错误保留查询失败的 `name` 和 `code`,并把拆除失败追加到消息中;不带错误码的查询失败(例如取消)与拆除失败一起以 `AggregateError` 呈现,不带 `error.info`。 + +| 错误码 | 抛出方 | 含义 | 消费方操作 | +|---|---|---|---| +| `LSP_INVALID_PROVIDER` | Seam | 注册的 id 或语言 id 为空、没有扩展名,或扩展名无效 | 修正注册 | +| `LSP_CONFLICT` | Seam | 提供方 id 或某个扩展名已被注册 | 移除重复注册 | +| `LSP_UNAVAILABLE` | Seam | 没有提供方处理该文件扩展名 | 查询受支持的文件,或添加提供方 | +| `LSP_WORKSPACE_REQUIRED` | `lsp` 工具 | 调用没有 Agent Session 工作区 | 从 Agent Session 调用该工具 | +| `LSP_DISPOSED` | 提供方 | 提供方或其服务器实例已被释放 | 在提供方重新挂载后重试 | +| `LSP_UNSUPPORTED_OPERATION` | 提供方 | 服务器不支持该操作或临时打开/关闭同步 | 选择其他操作或服务器 | +| `LSP_MALFORMED_RESPONSE` | 提供方 | 服务器的 `initialize` 或查询结果违反协议 | 报告服务器缺陷 | +| `LSP_FILE_OUTSIDE_WORKSPACE` | stdio 提供方 | 源文件解析到规范工作区之外 | 查询工作区内的文件 | +| `LSP_UNSUPPORTED_ENCODING` | stdio 提供方 | 服务器协商了 UTF-16 以外的位置编码 | 使用支持 UTF-16 的服务器 | +| `LSP_SPAWN_FAILED` | stdio 提供方 | 子进程提供方未能启动或运行服务器;提供方的 `HarnessError` 则保留其自身的 `name` 和 `code` | 修正服务器命令或安装 | +| `LSP_SERVER_EXITED` | stdio 提供方 | 服务器已退出或关闭了 stdin;进程退出时消息携带保留的 stderr 尾部 | 稍后重试;反复出现时检查 stderr 尾部 | +| `LSP_PROTOCOL_ERROR` | stdio 提供方 | 服务器输出破坏了基础协议分帧或 JSON | 报告服务器缺陷 | +| `LSP_MESSAGE_TOO_LARGE` | stdio 提供方 | 某条服务器消息超过 `maxMessageBytes` | 缩小查询范围或调大 `maxMessageBytes` | +| `LSP_SERVER_ERROR` | stdio 提供方 | 服务器以 JSON-RPC 错误应答;消息携带其数字错误码与文本 | 按服务器消息处理 | + +传输失败(`LSP_SPAWN_FAILED`、`LSP_SERVER_EXITED`、`LSP_PROTOCOL_ERROR` 或 `LSP_MESSAGE_TOO_LARGE`)后,如果失败的进程干净地完成拆除,stdio 提供方会在新进程上把只读查询重试一次。 diff --git a/docs/subsystems/sandbox.i18n.yaml b/docs/subsystems/sandbox.i18n.yaml index 3d2954b1c4..6f92d1861f 100644 --- a/docs/subsystems/sandbox.i18n.yaml +++ b/docs/subsystems/sandbox.i18n.yaml @@ -15,5 +15,5 @@ en: 2ba8930762e92e40 zh: 220f64da1a9e5d57 /process-sandbox/provider-and-fail-closed-errors: - en: a90a38fe4b11737c - zh: 2397768e48712617 + en: a1f9649b3cde0aca + zh: 6708348a70eb2451 diff --git a/docs/subsystems/sandbox.md b/docs/subsystems/sandbox.md index c530276e90..71f4eedea6 100644 --- a/docs/subsystems/sandbox.md +++ b/docs/subsystems/sandbox.md @@ -161,7 +161,7 @@ The [local provider](../../packages/sandbox/sandbox-local/README.md) owns operat ## Provider and fail-closed errors -`await ctx.sandbox.confine(argv, policy, signal)` resolves policy paths and returns a `ConfinedArgv` from the execution world, or rejects with `SandboxUnavailableError` and code `SANDBOX_UNAVAILABLE` when no usable backend exists. The optional signal cancels resolution before launch. Consumers may also classify a failure while spawning or observing the returned argv; that attribution belongs to the consumer contract. Silent unconfined passthrough is never legal for a confined policy. +`await ctx.sandbox.confine(argv, policy, signal)` resolves policy paths and returns a `ConfinedArgv` from the execution world, or rejects with `SandboxUnavailableError` and code `SANDBOX_UNAVAILABLE` when no usable backend exists, or with `SandboxPreparationError` (`SANDBOX_PREPARATION_FAILED`, `SANDBOX_INVALID_TEMP_ROOT`) when the provider cannot prepare host state for the mode. The optional signal cancels resolution before launch. Consumers may also classify a failure while spawning or observing the returned argv; that attribution belongs to the consumer contract. Silent unconfined passthrough is never legal for a confined policy. Provider selection, probing, caching, and backend-specific enforcement reports belong to the [local provider](../../packages/sandbox/sandbox-local/README.md). @@ -213,6 +213,8 @@ The sandbox-policy service (`ctx.sandboxPolicy`). Owns the deployment default mo * snapshotted together; existing operations and processes keep their policy. * @param request - optional session and approved mode override. * @returns the fully resolved per-call mode and absolute workspace root. + * @throws `SandboxInvalidRootError` (`SANDBOX_INVALID_ROOT`) when the session's recorded cwd is + * relative or contains a NUL byte. */ resolve(request: SandboxPolicyRequest = {}): SandboxExecutionPolicy diff --git a/docs/subsystems/sandbox.zh.md b/docs/subsystems/sandbox.zh.md index c3cfd0720e..2b79f4c17c 100644 --- a/docs/subsystems/sandbox.zh.md +++ b/docs/subsystems/sandbox.zh.md @@ -161,7 +161,7 @@ interface ConfinedArgv { ## 提供方与 fail-closed 错误 -`await ctx.sandbox.confine(argv, policy, signal)` 在执行环境中解析策略路径并返回 `ConfinedArgv`,没有可用后端时以 `SandboxUnavailableError`(错误码 `SANDBOX_UNAVAILABLE`)拒绝。可选信号可在启动前取消解析。消费方也可以在 spawn 或观察所返回的 argv 时对失败进行分类;该归因属于消费方约定。对于受限策略,静默的无隔离透传永远不合法。 +`await ctx.sandbox.confine(argv, policy, signal)` 在执行环境中解析策略路径并返回 `ConfinedArgv`,没有可用后端时以 `SandboxUnavailableError`(错误码 `SANDBOX_UNAVAILABLE`)拒绝,提供方无法为该模式准备主机状态时以 `SandboxPreparationError`(`SANDBOX_PREPARATION_FAILED`、`SANDBOX_INVALID_TEMP_ROOT`)拒绝。可选信号可在启动前取消解析。消费方也可以在 spawn 或观察所返回的 argv 时对失败进行分类;该归因属于消费方约定。对于受限策略,静默的无隔离透传永远不合法。 提供方选择、探测、缓存和后端特定的强制执行报告归[本地提供方](../../packages/sandbox/sandbox-local/README.zh.md)所有。 @@ -213,6 +213,8 @@ The sandbox-policy service (`ctx.sandboxPolicy`). Owns the deployment default mo * snapshotted together; existing operations and processes keep their policy. * @param request - optional session and approved mode override. * @returns the fully resolved per-call mode and absolute workspace root. + * @throws `SandboxInvalidRootError` (`SANDBOX_INVALID_ROOT`) when the session's recorded cwd is + * relative or contains a NUL byte. */ resolve(request: SandboxPolicyRequest = {}): SandboxExecutionPolicy diff --git a/docs/subsystems/terminal.md b/docs/subsystems/terminal.md index c1c78963e4..0d917219d5 100644 --- a/docs/subsystems/terminal.md +++ b/docs/subsystems/terminal.md @@ -31,7 +31,7 @@ A backend owns how one registered type starts and detects readiness. `TerminalSe interface TerminalBackend { /** Stable type selected by {@link TerminalSpawnRequest.type}. */ readonly type: string - /** Create an unpublished session or reject after cleaning partial resources; cleanup failure uses {@link TerminalBackendCleanupError}. */ + /** Create an unpublished session or reject after cleaning partial resources; cleanup failure uses `TerminalBackendCleanupError`. */ spawn(spec: TerminalBackendSpawnSpec): Promise } ``` diff --git a/docs/subsystems/terminal.zh.md b/docs/subsystems/terminal.zh.md index e97e5771f5..a9d2b36d27 100644 --- a/docs/subsystems/terminal.zh.md +++ b/docs/subsystems/terminal.zh.md @@ -31,7 +31,7 @@ type TerminalSessionStatus = interface TerminalBackend { /** Stable type selected by {@link TerminalSpawnRequest.type}. */ readonly type: string - /** Create an unpublished session or reject after cleaning partial resources; cleanup failure uses {@link TerminalBackendCleanupError}. */ + /** Create an unpublished session or reject after cleaning partial resources; cleanup failure uses `TerminalBackendCleanupError`. */ spawn(spec: TerminalBackendSpawnSpec): Promise } ``` diff --git a/docs/subsystems/working-directory.md b/docs/subsystems/working-directory.md index c6f4545328..cac8b9afa4 100644 --- a/docs/subsystems/working-directory.md +++ b/docs/subsystems/working-directory.md @@ -42,7 +42,8 @@ get(session: Session): string * @param signal - cancellation for filesystem inspection. * @returns the existing directory; recovery is committed before fulfillment. * A notice failure is warned without reverting the committed state. - * @throws when the original project is also unavailable, or when a `working-directory/access` listener rejects. + * @throws {@link WorkingDirectoryError} `WORKING_DIRECTORY_UNAVAILABLE` when the original project is also + * unavailable or the service is disposed, or the rejection of a `working-directory/access` listener. */ ensure(agent: Agent, signal?: AbortSignal): Promise @@ -53,7 +54,9 @@ ensure(agent: Agent, signal?: AbortSignal): Promise * @param signal - cancellation before the durable change. * @returns the canonical absolute directory, committed before fulfillment. * A notice failure is warned; the next request still receives the committed directory. - * @throws when the requested path is not an existing directory. + * @throws {@link WorkingDirectoryError} `WORKING_DIRECTORY_INVALID` for an empty path and + * `WORKING_DIRECTORY_NOT_FOUND` when the path is not an existing directory, or the rejection of a + * `working-directory/access` listener. */ set(agent: Agent, path: string, signal?: AbortSignal): Promise ``` diff --git a/docs/subsystems/working-directory.zh.md b/docs/subsystems/working-directory.zh.md index 98020a3dfc..aa6270328a 100644 --- a/docs/subsystems/working-directory.zh.md +++ b/docs/subsystems/working-directory.zh.md @@ -42,7 +42,8 @@ get(session: Session): string * @param signal - cancellation for filesystem inspection. * @returns the existing directory; recovery is committed before fulfillment. * A notice failure is warned without reverting the committed state. - * @throws when the original project is also unavailable, or when a `working-directory/access` listener rejects. + * @throws {@link WorkingDirectoryError} `WORKING_DIRECTORY_UNAVAILABLE` when the original project is also + * unavailable or the service is disposed, or the rejection of a `working-directory/access` listener. */ ensure(agent: Agent, signal?: AbortSignal): Promise @@ -53,7 +54,9 @@ ensure(agent: Agent, signal?: AbortSignal): Promise * @param signal - cancellation before the durable change. * @returns the canonical absolute directory, committed before fulfillment. * A notice failure is warned; the next request still receives the committed directory. - * @throws when the requested path is not an existing directory. + * @throws {@link WorkingDirectoryError} `WORKING_DIRECTORY_INVALID` for an empty path and + * `WORKING_DIRECTORY_NOT_FOUND` when the path is not an existing directory, or the rejection of a + * `working-directory/access` listener. */ set(agent: Agent, path: string, signal?: AbortSignal): Promise ``` diff --git a/docs/upgrade-guide/v0.2.2-rc.1/archive-worktrees/guide.i18n.yaml b/docs/upgrade-guide/v0.2.2-rc.1/archive-worktrees/guide.i18n.yaml index b563eb7323..b748855bae 100644 --- a/docs/upgrade-guide/v0.2.2-rc.1/archive-worktrees/guide.i18n.yaml +++ b/docs/upgrade-guide/v0.2.2-rc.1/archive-worktrees/guide.i18n.yaml @@ -9,8 +9,8 @@ en: bc309764ab7b8b98 zh: 20fdfbce76706025 /worktree-cleanup-on-archive/change: - en: 05f27e166f2dbb40 - zh: a320ac6252fd2e38 + en: 990690039f918e72 + zh: 8e23fbabf88a777f /worktree-cleanup-on-archive/migration: - en: c9b2fca77d02e07f - zh: a4228d7f22fb7e35 + en: 05f29b760cb99ae9 + zh: 33f181ac2a582bdf diff --git a/docs/upgrade-guide/v0.2.2-rc.1/archive-worktrees/guide.md b/docs/upgrade-guide/v0.2.2-rc.1/archive-worktrees/guide.md index 77912a2e71..74f3986277 100644 --- a/docs/upgrade-guide/v0.2.2-rc.1/archive-worktrees/guide.md +++ b/docs/upgrade-guide/v0.2.2-rc.1/archive-worktrees/guide.md @@ -9,13 +9,13 @@ English | [中文](guide.zh.md) ## Change -Archiving a Session previously retained its checkout. The worktree service now records each checkout it creates as a `plugin:worktree/created` record and, when the Session that created one is archived, removes it with a non-force `git worktree remove` when no unarchived Session refers to it, its creator's policy is not read-only, and Git finds no tracked changes, untracked files, hidden index flags, submodules, locks, or detached HEAD. Ignored files such as `node_modules` do not retain it. Branches and commits remain. The service's `retainOnArchive` setting, off by default, keeps every checkout instead. Checkouts created before this release have no such event and are never swept, nor are Sessions archived earlier. See the [worktree service](../../../../packages/experimental/worktree/README.md). +Archiving a Session previously retained its checkout. The worktree service now records each checkout it creates as a `plugin:worktree/created` record and, when the Session that created one is archived, removes it with a non-force `git worktree remove` when no unarchived Session refers to it, its creator's policy is not read-only, and Git finds no tracked changes, untracked files, initialized submodules, locks, or detached HEAD. Ignored files such as `node_modules` and edits to skip-worktree or assume-unchanged files, which `git status` hides, do not retain it; Git discards them. Git refuses to remove a checkout holding a committed embedded repository, so that checkout stays and the archive notice reports a failed cleanup. Branches and commits remain. The service's `retainOnArchive` setting, off by default, keeps every checkout instead. Checkouts created before this release have no such event and are never swept, nor are Sessions archived earlier. See the [worktree service](../../../../packages/experimental/worktree/README.md). The Forge bundle mounts one `worktree` service for both the panel and the `create_worktree` tool. The `forge-worktree` row and the `optional-forge-panel` group that isolated a second service are gone; the panel is the top-level `forge-panel` row. ## Migration -1. If a checkout must remain available outside DSH, keep its Session unarchived, lock it with `git worktree lock ` before archiving, or turn on **Keep worktrees when archiving** on the Forge bundle's worktree row of the Plugins page (`retainOnArchive`), which keeps every checkout. Ignored files are not kept. +1. If a checkout must remain available outside DSH, keep its Session unarchived, lock it with `git worktree lock ` before archiving, or turn on **Keep worktrees when archiving** on the Forge bundle's worktree row of the Plugins page (`retainOnArchive`), which keeps every checkout. Ignored files are not kept, nor are edits to skip-worktree or assume-unchanged files: commit those edits, or clear the flags with `git update-index --no-skip-worktree` or `--no-assume-unchanged`, before archiving. 2. To resume an archived Session whose checkout was removed, unarchive it and use **Restore worktree**. The retained branch is checked out at the recorded path. 3. In a profile's `cordis.patch.yml`, move any `forge-worktree` override to the `worktree` row and any `optional-forge-panel` override to the `forge-panel` row; a `disabled: true` on `forge-panel` turns off the panel alone. 4. Confirm the branch and commits are present with `git branch --list` and that **Restore worktree** makes the recorded checkout available. Session logs require no migration. diff --git a/docs/upgrade-guide/v0.2.2-rc.1/archive-worktrees/guide.zh.md b/docs/upgrade-guide/v0.2.2-rc.1/archive-worktrees/guide.zh.md index 5edf966298..d5295cfb18 100644 --- a/docs/upgrade-guide/v0.2.2-rc.1/archive-worktrees/guide.zh.md +++ b/docs/upgrade-guide/v0.2.2-rc.1/archive-worktrees/guide.zh.md @@ -9,13 +9,13 @@ description: "归档 Session 可以删除其干净的工作树并保留分支, ## 变更 -归档 Session 以前会保留检出目录。工作树服务现在把它创建的每个检出目录记录为 `plugin:worktree/created` Session 事件;当创建它的 Session 被归档时,若没有未归档的 Session 引用该目录、创建者的策略不是只读,且 Git 未发现跟踪文件改动、未跟踪文件、隐藏的索引标记、子模块、锁或分离 HEAD,就用不带 force 的 `git worktree remove` 删除它。`node_modules` 之类被忽略的文件不会保留目录。分支和提交仍会保留。服务的 `retainOnArchive` 设置默认关闭,开启后改为保留所有检出目录。本版本之前创建的检出目录没有该事件,不会被扫描;更早归档的 Session 也不会。参见[工作树服务](../../../../packages/experimental/worktree/README.zh.md)。 +归档 Session 以前会保留检出目录。工作树服务现在把它创建的每个检出目录记录为 `plugin:worktree/created` Session 事件;当创建它的 Session 被归档时,若没有未归档的 Session 引用该目录、创建者的策略不是只读,且 Git 未发现跟踪文件改动、未跟踪文件、已初始化的子模块、锁或分离 HEAD,就用不带 force 的 `git worktree remove` 删除它。`node_modules` 之类被忽略的文件,以及 `git status` 不报告的 skip-worktree 或 assume-unchanged 文件改动,都不会保留目录,Git 会丢弃它们。含已提交内嵌仓库的检出目录会被 Git 拒绝删除,因此保留,归档提示显示清理失败。分支和提交仍会保留。服务的 `retainOnArchive` 设置默认关闭,开启后改为保留所有检出目录。本版本之前创建的检出目录没有该事件,不会被扫描;更早归档的 Session 也不会。参见[工作树服务](../../../../packages/experimental/worktree/README.zh.md)。 Forge bundle 为面板和 `create_worktree` 工具挂载同一个 `worktree` 服务。隔离第二个服务的 `forge-worktree` 行与 `optional-forge-panel` 组已移除;面板是顶层的 `forge-panel` 行。 ## 迁移 -1. 如果检出目录必须供 DSH 之外继续使用,请保持其 Session 未归档,在归档前用 `git worktree lock ` 锁定它,或在插件页 Forge 套件的 worktree 行上开启**归档时不删除工作树**(`retainOnArchive`),它会保留所有检出目录。被忽略的文件不会保留。 +1. 如果检出目录必须供 DSH 之外继续使用,请保持其 Session 未归档,在归档前用 `git worktree lock ` 锁定它,或在插件页 Forge 套件的 worktree 行上开启**归档时不删除工作树**(`retainOnArchive`),它会保留所有检出目录。被忽略的文件不会保留,skip-worktree 或 assume-unchanged 文件的改动也不会:归档前请提交这些改动,或用 `git update-index --no-skip-worktree` 或 `--no-assume-unchanged` 清除标记。 2. 要继续使用已归档且检出目录已删除的 Session,请取消归档并使用**恢复工作树**。保留的分支会在记录的路径重新检出。 3. 在 profile 的 `cordis.patch.yml` 中,把针对 `forge-worktree` 的覆盖迁到 `worktree` 行,把针对 `optional-forge-panel` 的覆盖迁到 `forge-panel` 行;对 `forge-panel` 设置 `disabled: true` 可以只关闭面板。 4. 用 `git branch --list` 确认分支与提交仍然存在,并确认**恢复工作树**使记录的检出目录重新可用。Session 日志无需迁移。 diff --git a/docs/upgrade-guide/v0.2.2-rc.1/attachment-error-codes/guide.i18n.yaml b/docs/upgrade-guide/v0.2.2-rc.1/attachment-error-codes/guide.i18n.yaml new file mode 100644 index 0000000000..66e712178e --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-rc.1/attachment-error-codes/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.2-rc.1/attachment-error-codes/guide.md +/: + en: 8a0a1ff4ca3ad446 + zh: 4bd6cd51cbd791c2 +/attachment-failures-keep-their-codes: + en: 4f2b2f0db0646d3b + zh: 9467f41336a65c48 +/attachment-failures-keep-their-codes/change: + en: 0f8c05c401f15a7d + zh: adf5976a05802bf1 +/attachment-failures-keep-their-codes/migration: + en: 8599ead7a17bc7eb + zh: 1120fd02065567f5 diff --git a/docs/upgrade-guide/v0.2.2-rc.1/attachment-error-codes/guide.md b/docs/upgrade-guide/v0.2.2-rc.1/attachment-error-codes/guide.md new file mode 100644 index 0000000000..70acc07793 --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-rc.1/attachment-error-codes/guide.md @@ -0,0 +1,23 @@ +--- +kind: upgrade-guide +description: "AttachmentError now extends HarnessError, so tool and model-request failures record attachment codes instead of losing them or reporting UNKNOWN." +--- + +# Attachment failures keep their codes + +English | [中文](guide.zh.md) + +## Change + +`AttachmentError` from `@deepseek-ai/dsh-attachment` now extends `HarnessError` from `@deepseek-ai/dsh-error`. Its `name` (`AttachmentError`) and every `ATTACHMENT_*` and image admission code are unchanged. Two persisted results change: + +- When a model request fails because an adapter could not read an image attachment, the failed turn's `turn/end` error previously recorded `code: 'UNKNOWN'`. It now records the `AttachmentError` code, such as `ATTACHMENT_NOT_FOUND`, `ATTACHMENT_CORRUPT`, `ATTACHMENT_READ_FAILED`, or an image admission code such as `INVALID_IMAGE` or `IMAGE_TOO_LARGE`. +- A tool whose body lets an `AttachmentError` escape now records `error: { name: 'AttachmentError', code }` on its `tool/result`; previously the result had no `error`. `read_image` also records the `AttachmentError` code and names the file and the next step in the message. + +`instanceof HarnessError` now matches `AttachmentError`. `isAttachmentError` is unchanged. + +## Migration + +1. If analytics or retry rules treat `UNKNOWN` model failures as a catch-all, add the codes of the exported `AttachmentErrorCode` type; in code, identify these failures with `isAttachmentError`. The codes do not share one prefix. +2. If code catches `HarnessError` before `AttachmentError`, move the `AttachmentError` check first wherever the two need different handling. +3. Earlier Session logs keep `UNKNOWN`; readers that compare old and new logs should treat both as the same failure class for attachment reads. diff --git a/docs/upgrade-guide/v0.2.2-rc.1/attachment-error-codes/guide.zh.md b/docs/upgrade-guide/v0.2.2-rc.1/attachment-error-codes/guide.zh.md new file mode 100644 index 0000000000..7c00e63528 --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-rc.1/attachment-error-codes/guide.zh.md @@ -0,0 +1,23 @@ +--- +kind: upgrade-guide +description: "AttachmentError 现在继承 HarnessError,工具失败与模型请求失败会记录附件错误码,不再丢失或报告为 UNKNOWN。" +--- + +# 附件失败保留其错误码 + +[English](guide.md) | 中文 + +## 变更 + +`@deepseek-ai/dsh-attachment` 的 `AttachmentError` 现在继承 `@deepseek-ai/dsh-error` 中的 `HarnessError`。其 `name`(`AttachmentError`)以及所有 `ATTACHMENT_*` 与图像准入错误码均保持不变。两类持久化结果会变化: + +- 适配器无法读取图像附件而导致模型请求失败时,失败 turn 的 `turn/end` 错误过去记录 `code: 'UNKNOWN'`。现在记录 `AttachmentError` 的错误码,例如 `ATTACHMENT_NOT_FOUND`、`ATTACHMENT_CORRUPT`、`ATTACHMENT_READ_FAILED`,或 `INVALID_IMAGE`、`IMAGE_TOO_LARGE` 等图像准入错误码。 +- 工具主体让 `AttachmentError` 逸出时,其 `tool/result` 现在记录 `error: { name: 'AttachmentError', code }`;过去结果中没有 `error`。`read_image` 同样记录 `AttachmentError` 的错误码,并在消息中写明文件与下一步。 + +`instanceof HarnessError` 现在会匹配 `AttachmentError`。`isAttachmentError` 不变。 + +## 迁移 + +1. 如果分析或重试规则把 `UNKNOWN` 模型失败当作兜底类别,请加入导出类型 `AttachmentErrorCode` 中的错误码;代码中用 `isAttachmentError` 识别这些失败。这些错误码没有统一前缀。 +2. 如果代码先捕获 `HarnessError` 再捕获 `AttachmentError`,在两者需要不同处理的地方把 `AttachmentError` 检查移到前面。 +3. 较早的 Session 日志仍保留 `UNKNOWN`;对比新旧日志的读取方应把附件读取的这两种记录视为同一类失败。 diff --git a/docs/upgrade-guide/v0.2.2-rc.1/cua-hover-ax-only/guide.i18n.yaml b/docs/upgrade-guide/v0.2.2-rc.1/cua-hover-ax-only/guide.i18n.yaml index 9a48f7428c..44d138539c 100644 --- a/docs/upgrade-guide/v0.2.2-rc.1/cua-hover-ax-only/guide.i18n.yaml +++ b/docs/upgrade-guide/v0.2.2-rc.1/cua-hover-ax-only/guide.i18n.yaml @@ -9,8 +9,8 @@ en: 6833a7851486f10d zh: 22983f326fb725ce /macos-hover-keeps-the-system-pointer-in-place-by-default/change: - en: 55c3070b24b4ad39 - zh: 2555d127b0f4e4a0 + en: e6d2e56ccb50e46a + zh: a788fc19e0771a46 /macos-hover-keeps-the-system-pointer-in-place-by-default/migration: - en: 1f0ab7e8741e821f - zh: f59adc3a23ebcb43 + en: 5db40025fe40c374 + zh: a5556c0e0b000d1c diff --git a/docs/upgrade-guide/v0.2.2-rc.1/cua-hover-ax-only/guide.md b/docs/upgrade-guide/v0.2.2-rc.1/cua-hover-ax-only/guide.md index f7191cc254..e2e074cac4 100644 --- a/docs/upgrade-guide/v0.2.2-rc.1/cua-hover-ax-only/guide.md +++ b/docs/upgrade-guide/v0.2.2-rc.1/cua-hover-ax-only/guide.md @@ -9,10 +9,10 @@ English | [中文](guide.zh.md) ## Change -On macOS, `win.mouseMove(target)` uses an enabled element or ancestor that advertises both `AXShowAlternateUI` and `AXShowDefaultUI`. Without that route, previous versions moved the visible system cursor. The default now returns `HOVER_UNAVAILABLE` before sending input. +On macOS, `win.mouseMove(target)` uses an enabled element or ancestor that advertises both `AXShowAlternateUI` and `AXShowDefaultUI`. Without that route, previous versions moved the visible system cursor. The default now returns `HOVER_UNAVAILABLE` before sending input. By default, explicitly requesting the system pointer asks the user for this session. ## Migration 1. For an available control, use its advertised action or click it directly. Use browser tools for web content that requires page hover. -2. To restore visible pointer movement, set `systemPointerHover: true` in the `config` of the macOS provider entry (`id: cua`) in your profile's `cordis.patch.yml`. Preserve the entry's other settings. If you set `helperPath`, rebuild the custom helper so it accepts `systemPointerHover` in the connection handshake. Restart the profile. -3. Call `win.mouseMove` on a foreground, unobscured control without AX alternate UI. The opt-in result reports `hoverRoute: "system-pointer"` and `systemPointerMoved: true`. Keep the default to avoid disturbing the user's cursor. +2. If your profile sets `helperPath`, rebuild the custom helper to support the per-request `useSystemPointer` field and restart the profile. +3. When the task requires physical movement, call `win.mouseMove(target, { useSystemPointer: true })` on an unobscured foreground target. After approval this move reports `systemPointerMoved: true`. Pass `useSystemPointer: true` again for each subsequent physical move. Closing the session or restarting DSH clears approval. diff --git a/docs/upgrade-guide/v0.2.2-rc.1/cua-hover-ax-only/guide.zh.md b/docs/upgrade-guide/v0.2.2-rc.1/cua-hover-ax-only/guide.zh.md index 713ae43fb3..a26861e5d3 100644 --- a/docs/upgrade-guide/v0.2.2-rc.1/cua-hover-ax-only/guide.zh.md +++ b/docs/upgrade-guide/v0.2.2-rc.1/cua-hover-ax-only/guide.zh.md @@ -9,10 +9,10 @@ description: "macOS 的 mouseMove 默认要求控件支持 AX 备用界面,否 ## 变更 -在 macOS 上,`win.mouseMove(target)` 会查找已启用且同时声明 `AXShowAlternateUI` 和 `AXShowDefaultUI` 的控件或祖先。找不到时,旧版会移动系统光标,新版默认在发送输入前返回 `HOVER_UNAVAILABLE`。 +在 macOS 上,`win.mouseMove(target)` 会查找已启用且同时声明 `AXShowAlternateUI` 和 `AXShowDefaultUI` 的控件或祖先。找不到时,旧版会移动系统光标,新版默认在发送输入前返回 `HOVER_UNAVAILABLE`。默认配置下,显式请求移动系统光标时会询问用户是否授权当前会话。 ## 迁移 1. 对已经可用的控件,可执行它声明的动作或直接点击。需要网页悬停时可用浏览器工具。 -2. 要恢复可见光标移动,在 profile 的 `cordis.patch.yml` 中,给 macOS 提供方条目(`id: cua`)的 `config` 添加 `systemPointerHover: true`。保留该条目的其他设置。如果指定了 `helperPath`,先重新构建自定义 helper,使其在建立连接时能读取 `systemPointerHover`,然后重启 profile。 -3. 对前台未受遮挡且不提供 AX 备用界面的控件调用 `win.mouseMove`。启用配置时,结果为 `hoverRoute: "system-pointer"` 和 `systemPointerMoved: true`。要保留用户的光标位置,使用默认配置。 +2. 如果 profile 设置了 `helperPath`,请重新构建该 helper,使其支持按请求读取 `useSystemPointer` 字段,然后重启 profile。 +3. 任务确实需要移动鼠标时,在前台未受遮挡的目标上调用 `win.mouseMove(target, { useSystemPointer: true })`。用户同意后,本次移动返回 `systemPointerMoved: true`。之后每次移动系统鼠标仍需显式传 `useSystemPointer: true`。关闭会话或重启 DSH 后需要重新授权。 diff --git a/docs/upgrade-guide/v0.2.2-rc.1/forge-review-reads/guide.i18n.yaml b/docs/upgrade-guide/v0.2.2-rc.1/forge-review-reads/guide.i18n.yaml new file mode 100644 index 0000000000..8377a14f8a --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-rc.1/forge-review-reads/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.2-rc.1/forge-review-reads/guide.md +/: + en: 53dbdc67362a5851 + zh: 9c7b2c4dd31cf666 +/forge-failures-report-unsupported-reads: + en: 3e5488f3008886b7 + zh: 8123e954c41d559c +/forge-failures-report-unsupported-reads/change: + en: 77712ff12908e838 + zh: 00714f77f3b7ae80 +/forge-failures-report-unsupported-reads/migration: + en: 698a34a7e09be2be + zh: a32a64dc130f496f diff --git a/docs/upgrade-guide/v0.2.2-rc.1/forge-review-reads/guide.md b/docs/upgrade-guide/v0.2.2-rc.1/forge-review-reads/guide.md new file mode 100644 index 0000000000..d6c6aa2962 --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-rc.1/forge-review-reads/guide.md @@ -0,0 +1,19 @@ +--- +kind: upgrade-guide +description: "ForgeFailure gains the unsupported code, which ctx.forge.readReview returns for a provider without review reads." +--- + +# Forge failures report unsupported reads + +English | [中文](guide.zh.md) + +## Change + +`ForgeFailure['code']` gains `unsupported`. `ctx.forge.readReview(ref, headSha, signal)`, new beside the optional `ForgeProvider.readReview`, returns it when the provider of the change request does not read review threads. Other reads keep their codes: a provider without `fetchChecks` still makes `ctx.forge.fetchChecks` return `unavailable`. + +This affects code that switches exhaustively over `ForgeFailure['code']`, such as a `Record` lookup or a `switch` that ends in `assertNever`. All types come from `@deepseek-ai/dsh-experimental-forge`. Providers need no change: `readReview` is optional. + +## Migration + +1. Handle `unsupported` wherever code switches exhaustively over `ForgeFailure['code']`; treat it as a capability the provider lacks, not as a failed read to retry. +2. Run your type check; it succeeds when no `ForgeFailure` code is unhandled. diff --git a/docs/upgrade-guide/v0.2.2-rc.1/forge-review-reads/guide.zh.md b/docs/upgrade-guide/v0.2.2-rc.1/forge-review-reads/guide.zh.md new file mode 100644 index 0000000000..75f8a1057e --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-rc.1/forge-review-reads/guide.zh.md @@ -0,0 +1,19 @@ +--- +kind: upgrade-guide +description: "ForgeFailure 新增 unsupported 代码;提供方不支持读取评审讨论时,ctx.forge.readReview 返回它。" +--- + +# Forge 失败代码报告不支持的读取 + +[English](guide.md) | 中文 + +## 变更 + +`ForgeFailure['code']` 新增 `unsupported`。新增的 `ctx.forge.readReview(ref, headSha, signal)` 对应可选的 `ForgeProvider.readReview`;变更请求的提供方不支持读取评审讨论时,它返回 `unsupported`。其他读取的代码不变:提供方未实现 `fetchChecks` 时,`ctx.forge.fetchChecks` 仍返回 `unavailable`。 + +受影响的是对 `ForgeFailure['code']` 做穷举的代码,例如 `Record` 查表,或以 `assertNever` 结尾的 `switch`。所有类型都来自 `@deepseek-ai/dsh-experimental-forge`。提供方无需修改:`readReview` 是可选方法。 + +## 迁移 + +1. 在所有对 `ForgeFailure['code']` 做穷举的地方处理 `unsupported`,把它当作提供方缺少的能力,而不是需要重试的读取失败。 +2. 运行类型检查;没有未处理的 `ForgeFailure` 代码时即通过。 diff --git a/docs/upgrade-guide/v0.2.2-rc.1/terminal-backend-cleanup-error/guide.i18n.yaml b/docs/upgrade-guide/v0.2.2-rc.1/terminal-backend-cleanup-error/guide.i18n.yaml new file mode 100644 index 0000000000..fc72ae7b2e --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-rc.1/terminal-backend-cleanup-error/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.2-rc.1/terminal-backend-cleanup-error/guide.md +/: + en: 91597636f5a71894 + zh: 02d2517952d86cfd +/terminalbackendcleanuperror-extends-terminalerror: + en: cb11df977825904b + zh: 080f157e78942a9d +/terminalbackendcleanuperror-extends-terminalerror/change: + en: bf3bd46faf910ff0 + zh: fd47d6e58f467a1e +/terminalbackendcleanuperror-extends-terminalerror/migration: + en: 85fe7dd354cf8657 + zh: e39dc4ce137aeb13 diff --git a/docs/upgrade-guide/v0.2.2-rc.1/terminal-backend-cleanup-error/guide.md b/docs/upgrade-guide/v0.2.2-rc.1/terminal-backend-cleanup-error/guide.md new file mode 100644 index 0000000000..7ab32fec51 --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-rc.1/terminal-backend-cleanup-error/guide.md @@ -0,0 +1,20 @@ +--- +kind: upgrade-guide +description: "TerminalBackendCleanupError extends TerminalError instead of AggregateError and carries the CLEANUP_FAILED code." +--- + +# `TerminalBackendCleanupError` extends `TerminalError` + +English | [中文](guide.zh.md) + +## Change + +`@deepseek-ai/dsh-terminal` exports `TerminalBackendCleanupError`, which a PTY backend throws when startup failed and cleaning its partial resources also failed. It previously extended `AggregateError`. It now extends `TerminalError`, so it is a `HarnessError` with code `CLEANUP_FAILED`, and a tool result that carries it records `error.name` `TerminalBackendCleanupError` and `error.code` `CLEANUP_FAILED`. The constructor, the `name`, `spawnError`, `cleanupError`, and the `errors` list in that order are unchanged; `errors` is typed `readonly unknown[]` instead of `any[]`. `cause` is a new `AggregateError` of the same two failures. `error instanceof AggregateError` is now `false`. + +Backends and consumers that only construct the error, read `spawnError` or `cleanupError`, or test `instanceof TerminalBackendCleanupError` are unaffected. + +## Migration + +1. Replace `error instanceof AggregateError` checks meant for this error with `error instanceof TerminalBackendCleanupError`, or with `error.code === 'CLEANUP_FAILED'` on a `TerminalError`. +2. Where TypeScript code reads members of `error.errors` without narrowing, narrow each `unknown` element before use, or read `spawnError` and `cleanupError`. +3. Confirm with `pnpm exec tsc --noEmit` in the consuming project, and check that a startup cleanup failure still reaches the handling path. diff --git a/docs/upgrade-guide/v0.2.2-rc.1/terminal-backend-cleanup-error/guide.zh.md b/docs/upgrade-guide/v0.2.2-rc.1/terminal-backend-cleanup-error/guide.zh.md new file mode 100644 index 0000000000..7f69c4558d --- /dev/null +++ b/docs/upgrade-guide/v0.2.2-rc.1/terminal-backend-cleanup-error/guide.zh.md @@ -0,0 +1,20 @@ +--- +kind: upgrade-guide +description: "TerminalBackendCleanupError 改为继承 TerminalError 而非 AggregateError,并带 CLEANUP_FAILED 错误码。" +--- + +# `TerminalBackendCleanupError` 改为继承 `TerminalError` + +[English](guide.md) | 中文 + +## 变更 + +`@deepseek-ai/dsh-terminal` 导出的 `TerminalBackendCleanupError` 由 PTY 后端在启动失败且清理部分资源也失败时抛出。它原先继承 `AggregateError`,现在继承 `TerminalError`,因此是带 code `CLEANUP_FAILED` 的 `HarnessError`,携带它的工具结果会记录 `error.name` `TerminalBackendCleanupError` 和 `error.code` `CLEANUP_FAILED`。构造函数、`name`、`spawnError`、`cleanupError` 以及按原顺序排列的 `errors` 列表不变;`errors` 的类型由 `any[]` 改为 `readonly unknown[]`。`cause` 新增为同样两个失败组成的 `AggregateError`。`error instanceof AggregateError` 现在为 `false`。 + +只构造该错误、读取 `spawnError` 或 `cleanupError`、或检查 `instanceof TerminalBackendCleanupError` 的后端与消费方不受影响。 + +## 迁移 + +1. 把针对该错误的 `error instanceof AggregateError` 检查改为 `error instanceof TerminalBackendCleanupError`,或对 `TerminalError` 检查 `error.code === 'CLEANUP_FAILED'`。 +2. TypeScript 代码未经收窄就读取 `error.errors` 元素成员时,先收窄每个 `unknown` 元素,或改为读取 `spawnError` 与 `cleanupError`。 +3. 在消费项目中运行 `pnpm exec tsc --noEmit` 确认通过,并检查启动清理失败仍会进入原有的处理路径。 diff --git a/packages/attachment/attachment/README.i18n.yaml b/packages/attachment/attachment/README.i18n.yaml index 125d96125d..44c9125f4f 100644 --- a/packages/attachment/attachment/README.i18n.yaml +++ b/packages/attachment/attachment/README.i18n.yaml @@ -36,8 +36,8 @@ en: 25d9c7d6c0899d23 zh: ad8380d14810ff1c /deepseek-ai-dsh-attachment/understand-the-implementation/design-decisions: - en: 6315b2d1fb12f0ca - zh: c3f99f4c0850fb44 + en: bc4586bb4ce74464 + zh: 5ffd0a0e7261bc24 /deepseek-ai-dsh-attachment/understand-the-implementation/service-operations: en: b94d13fe133d8ce6 zh: dd4064d20d510afc diff --git a/packages/attachment/attachment/README.md b/packages/attachment/attachment/README.md index 0e0c2bc05f..075094dcc8 100644 --- a/packages/attachment/attachment/README.md +++ b/packages/attachment/attachment/README.md @@ -67,7 +67,7 @@ This section explains the design decisions behind the seam and the service opera - **Immutable and retention-neutral.** Objects are immutable once published; resumed and forked sessions may share them, so reference-aware garbage collection is deferred rather than tied to any one session's deletion. - **Verify on read.** Reads verify the immutable object against the supplied reference without consulting a Session. Optional display names and pre-normalization dimensions pass through as caller-supplied display information; the stored bytes do not verify these fields. - **Role-neutral image blocks.** The `ImageBlock` content block in `dsh-llm` carries an `ImageAttachmentRef`; provider adapters resolve it into deterministic request versions at an explicit route-chosen target size and byte target, while execution filesystems may map the immutable host object to a model-readable process path. -- **Error routing by code.** `AttachmentError` re-implements the `HarnessError` shape instead of extending it because the base lives in `dsh-llm`, which depends on this package; consumers use `isAttachmentError` and route on `code`, never on the prototype chain. +- **Error routing by code.** `AttachmentError` extends `HarnessError` from the dependency-free `dsh-error`, so tool results keep its `name` and `code`; consumers use `isAttachmentError`, which matches the code across duplicate installations, and route on `code`. - **Files are verbatim, images are normalized.** `saveFile` commits an existing byte array, `saveFileStream` commits bounded chunks with backpressure and cancellation, `readFileStream` verifies and returns bounded chunks, and `fileHostPath` locates the stored object for read-on-demand projection; neither file write path applies admission limits. The image path keeps its separate normalization, limits, and request-version pipeline. The `FileBlock` content block in `dsh-llm` carries a `FileAttachmentRef`, and request assembly projects it to deterministic handle text for every route. ### Service operations diff --git a/packages/attachment/attachment/README.zh.md b/packages/attachment/attachment/README.zh.md index 9c9d2c7fb5..400cc79c1e 100644 --- a/packages/attachment/attachment/README.zh.md +++ b/packages/attachment/attachment/README.zh.md @@ -67,7 +67,7 @@ kind: "package-reference" - **不可变且保留策略中立。** 对象一经发布即不可变;恢复和 fork 后的会话可能共享它们,因此引用感知的垃圾回收被推迟,而不是与任何单个会话的删除绑定。 - **读取时校验。** 读取会把不可变对象与传入引用比对,无需查询 Session。可选的显示名称和规范化前尺寸作为调用方提交的显示信息原样返回;存储字节不校验这两个字段。 - **角色无关的图片块。** `dsh-llm` 中的 `ImageBlock` 内容块携带 `ImageAttachmentRef`;提供方适配器按路由显式选定的目标尺寸与字节目标把引用解析为确定性请求版本,执行文件系统则可以把不可变宿主对象映射为模型可读的进程路径。 -- **按错误码路由。** `AttachmentError` 重新实现 `HarnessError` 的结构而不是继承它,因为基类位于 `dsh-llm`,而后者依赖本包;消费方用 `isAttachmentError` 识别错误并按 `code` 路由,绝不依赖原型链。 +- **按错误码路由。** `AttachmentError` 继承无依赖包 `dsh-error` 中的 `HarnessError`,因此工具结果保留其 `name` 与 `code`;消费方用 `isAttachmentError` 识别错误(它按错误码匹配,跨重复安装也有效),并按 `code` 路由。 - **文件原样,图片规范化。**`saveFile` 提交已有字节数组,`saveFileStream` 以背压和取消语义提交有界分块,`readFileStream` 校验并返回有界分块,`fileHostPath` 定位存储对象供按需读取投影;两种文件写入路径都不设准入限制。图片路径保留其独立的规范化、限额与请求版本流水线。`dsh-llm` 中的 `FileBlock` 内容块承载 `FileAttachmentRef`,请求组装会为每条路由将其投影为确定性的句柄文本。 ### 服务操作 diff --git a/packages/attachment/attachment/package.json b/packages/attachment/attachment/package.json index 79c35fd196..dc831253b1 100644 --- a/packages/attachment/attachment/package.json +++ b/packages/attachment/attachment/package.json @@ -23,10 +23,12 @@ "license": "MIT", "peerDependencies": { "@deepseek-ai/dsh-brand": "workspace:*", + "@deepseek-ai/dsh-error": "workspace:*", "@deepseek-ai/cordis": "workspace:~" }, "devDependencies": { "@deepseek-ai/dsh-brand": "workspace:*", + "@deepseek-ai/dsh-error": "workspace:*", "@deepseek-ai/cordis": "workspace:~" } } diff --git a/packages/attachment/attachment/src/error.ts b/packages/attachment/attachment/src/error.ts index 0aed793f35..2a933daa08 100644 --- a/packages/attachment/attachment/src/error.ts +++ b/packages/attachment/attachment/src/error.ts @@ -1,5 +1,7 @@ /** Attachment failure class. @module @deepseek-ai/dsh-attachment/error */ +import { HarnessError } from '@deepseek-ai/dsh-error' + const IMAGE_ADMISSION_ERROR_CODES = [ 'TOO_MANY_IMAGES', 'IMAGES_TOO_LARGE', @@ -35,17 +37,14 @@ const IMAGE_ADMISSION_ERROR_CODE_SET: ReadonlySet = new Set(IMAGE_ADMISS const ATTACHMENT_ERROR_CODE_SET: ReadonlySet = new Set(ATTACHMENT_ERROR_CODES) /** - * Stable failures suitable for host RPC error mapping. - * - * Deliberately re-implements the `HarnessError` shape instead of extending it: - * the base lives in `@deepseek-ai/dsh-llm`, which itself depends on this - * package (`ImageBlock` references `ImageAttachmentRef`), so sharing the base - * would create a dependency cycle. Consumers route on `code`, never on the - * prototype chain, so the shapes stay interchangeable at the wire boundary. + * Stable failures suitable for host RPC error mapping. Extends {@link HarnessError}, so a tool + * result that fails with it records `name` `AttachmentError` and its `code`. Consumers that may + * receive an error from a duplicate package installation identify it with {@link isAttachmentError}, + * which matches the code instead of the prototype chain. */ -export class AttachmentError extends Error { +export class AttachmentError extends HarnessError { /** Stable machine-routing failure code. */ - readonly code: AttachmentErrorCode + override readonly code: AttachmentErrorCode /** * @param message - human-readable failure description without raw bytes or host paths. @@ -53,7 +52,7 @@ export class AttachmentError extends Error { * @param options - optional chained cause. */ constructor(message: string, code: AttachmentErrorCode, options?: ErrorOptions) { - super(message, options) + super(message, code, options) this.name = 'AttachmentError' this.code = code } diff --git a/packages/attachment/attachment/tests/index.spec.ts b/packages/attachment/attachment/tests/index.spec.ts index 9ce59d2d48..159dafd61f 100644 --- a/packages/attachment/attachment/tests/index.spec.ts +++ b/packages/attachment/attachment/tests/index.spec.ts @@ -1,5 +1,6 @@ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' +import { HarnessError } from '@deepseek-ai/dsh-error' import AttachmentStore, { AttachmentError, AttachmentId, @@ -218,6 +219,15 @@ describe('isImageAdmissionError', () => { }) }) +describe('AttachmentError', () => { + it('is a HarnessError that keeps its literal name and attachment code', () => { + const cause = new Error('disk full') + const error = new AttachmentError('disk failed', 'ATTACHMENT_WRITE_FAILED', { cause }) + expect(error).toBeInstanceOf(HarnessError) + expect(error).toMatchObject({ name: 'AttachmentError', code: 'ATTACHMENT_WRITE_FAILED', message: 'disk failed', cause }) + }) +}) + describe('isAttachmentError', () => { it('recognizes attachment failures from another package installation by code', () => { expect(isAttachmentError(new AttachmentError('bad base64', 'INVALID_FILE_BASE64'))).toBe(true) diff --git a/packages/attachment/attachment/tsconfig.json b/packages/attachment/attachment/tsconfig.json index 33e1dd58de..3efee044ab 100644 --- a/packages/attachment/attachment/tsconfig.json +++ b/packages/attachment/attachment/tsconfig.json @@ -5,6 +5,7 @@ "references": [ { "path": "../../../vendor/cosmokit" }, { "path": "../../../vendor/cordis" }, - { "path": "../../util/brand" } + { "path": "../../util/brand" }, + { "path": "../../util/error" } ] } diff --git a/packages/boot/app-boot/README.i18n.yaml b/packages/boot/app-boot/README.i18n.yaml index ba1cfc5bda..f0fecadd3b 100644 --- a/packages/boot/app-boot/README.i18n.yaml +++ b/packages/boot/app-boot/README.i18n.yaml @@ -24,8 +24,8 @@ en: 65bf2834b81f87da zh: c864ac60a3005b99 /deepseek-ai-dsh-app-boot/use-this-package/profiles: - en: 18c32e4ba60713e6 - zh: 4f247fce2f4056bf + en: 781fe487a973ee6f + zh: e95188274f75f9ad /deepseek-ai-dsh-app-boot/use-this-package/previewing-the-effective-configuration: en: 646a0ccdd7881ad1 zh: e3696acb1573dd7b diff --git a/packages/boot/app-boot/README.md b/packages/boot/app-boot/README.md index 9cc3427cc2..f480a5886d 100644 --- a/packages/boot/app-boot/README.md +++ b/packages/boot/app-boot/README.md @@ -61,7 +61,7 @@ A bundle may declare `dsh.bundle.platforms` as a nonempty list of Node Host plat Your machine-local preferences also live in the Harness home: -- **`.env`** — your ordinary environment layers: the invoking directory's file outranks the Harness-home file, and both sit below the inherited environment. Variables that decide how the process starts (`PATH`, `DSH_*`, `XDG_*` and similar) are rejected from files: export them instead. The four proxy names (`HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, `NO_PROXY`) are accepted from the Harness-home file only, never from the invoking directory's, which arrives with a clone. For a non-product bin that just wants one directory's `.env`, a missing file is fine and an unloadable one prints one labelled warning line. +- **`.env`** — your ordinary environment layers: the invoking directory's file outranks the Harness-home file, and both sit below the inherited environment. Variables that decide how the process starts (`PATH`, `DSH_*`, `XDG_*` and similar) are rejected from files: export them instead. The four proxy names (`HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, `NO_PROXY`) in any letter case and the DeepSeek endpoint names (`DEEPSEEK_BASE_URL`, `DEEPSEEK_SEARCH_BASE_URL`) in exactly that spelling are accepted from the Harness-home file only, never from the invoking directory's, which arrives with a clone. For a non-product bin that just wants one directory's `.env`, a missing file is fine and an unloadable one prints one labelled warning line. - **`cordis.patch.yml`** — your tweak layer, applied after every bundle layer (per-profile first, then the home-level file, which therefore outranks it): replace one entry's whole config (restating the fields you keep), insert new entries, or interpolate `!!js` expressions at boot. A patch naming an entry that does not exist prints a stderr warning; an empty or comments-only file fails boot — disable the layer with `[]` instead. The enabled `dsh-hmr` plugin watches the profile manifest and both user patch files, re-reads the ordered bundle layers, and applies the [reload failure policy](#startup-and-reload-failures). [DSH HMR](../hmr/README.md) serializes these reloads with [Plugin Manager](../plugin-manager/README.md) configuration writes; package operations run outside its queue. The launcher does not install HMR or watchers; disabled or absent HMR means changes require restart. diff --git a/packages/boot/app-boot/README.zh.md b/packages/boot/app-boot/README.zh.md index 81215bb8f7..2a9c53a38e 100644 --- a/packages/boot/app-boot/README.zh.md +++ b/packages/boot/app-boot/README.zh.md @@ -61,7 +61,7 @@ profile 导入插件前,DSH 会将其声明的 DSH 版本要求与 `getDshRunt 你的机器本地偏好同样位于 harness home 中: -- **`.env`**——你的普通环境层:调用目录的文件优先于 harness home 的文件,两者都低于继承环境。在文件中设置的进程启动变量(如 `PATH`、`DSH_*`、`XDG_*`)会被拒绝:请改为导出这些变量。四个代理名(`HTTP_PROXY`、`HTTPS_PROXY`、`ALL_PROXY`、`NO_PROXY`)只从 harness home 的文件接受,绝不从调用目录的文件接受——后者随 clone 一起到来。对于只想加载某个目录 `.env` 的非产品 bin,文件缺失不影响启动,文件无法加载时输出一行带标签的警告。 +- **`.env`**——你的普通环境层:调用目录的文件优先于 harness home 的文件,两者都低于继承环境。在文件中设置的进程启动变量(如 `PATH`、`DSH_*`、`XDG_*`)会被拒绝:请改为导出这些变量。四个代理名(`HTTP_PROXY`、`HTTPS_PROXY`、`ALL_PROXY`、`NO_PROXY`,大小写不限)和 DeepSeek 端点变量名(`DEEPSEEK_BASE_URL`、`DEEPSEEK_SEARCH_BASE_URL`,须按此拼写)只从 harness home 的文件接受,绝不从调用目录的文件接受——后者随 clone 一起到来。对于只想加载某个目录 `.env` 的非产品 bin,文件缺失不影响启动,文件无法加载时输出一行带标签的警告。 - **`cordis.patch.yml`**——你的 tweak 层,应用在所有组合包层之后(先应用逐 profile 的文件,再应用 home 级文件,因此后者优先级更高):替换某个条目的整个配置(重述你要保留的字段)、插入新条目,或在启动时插值 `!!js` 表达式。patch 指定的条目不存在时输出 stderr 警告;空文件或仅含注释的文件会导致启动失败——如需禁用该层,请改用 `[]`。 启用的 `dsh-hmr` 插件会监视 profile manifest 与两份用户 patch 文件,重新读取按顺序排列的组合包层,并应用[重载失败策略](#startup-and-reload-failures)。[DSH HMR](../hmr/README.zh.md) 将这些重载与[插件管理器](../plugin-manager/README.zh.md)的配置写入串行化;包操作在其队列之外执行。启动器不安装 HMR 或监视器;HMR 被禁用或不存在时,更改需要重启。 diff --git a/packages/boot/app-boot/src/index.ts b/packages/boot/app-boot/src/index.ts index daf1406a43..40ff3068c7 100644 --- a/packages/boot/app-boot/src/index.ts +++ b/packages/boot/app-boot/src/index.ts @@ -157,19 +157,34 @@ const BOOTSTRAP_NAMES = new Set([ /** Name prefixes no discovered file may set. */ const BOOTSTRAP_PREFIXES = ['DSH_', 'XDG_', 'DYLD_', 'BASH_FUNC_'] -/** - * The bootstrap names the Harness-home `.env` alone may set. A proxy chooses the route every - * request takes, so the invoking directory's file — which arrives with a clone — keeps refusing - * them; the home file is the user's own, and `DSH_HOME` is itself bootstrap-only, so no `.env` can - * relocate this exemption. The CA and TLS names in the same group stay refused everywhere: they - * change what is trusted, not where traffic goes. - */ +/** Proxy names the Harness-home `.env` may set in any letter case: proxy clients read both spellings. */ const HOME_LAYER_PROXY_NAMES = new Set(['HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'NO_PROXY']) +/** + * DeepSeek endpoint names the Harness-home `.env` may set only as spelled here: their consumers look them up by + * exact name on POSIX, so another spelling would load without effect. + */ +const HOME_LAYER_ENDPOINT_NAMES = new Set(['DEEPSEEK_BASE_URL', 'DEEPSEEK_SEARCH_BASE_URL']) + +/** + * Whether the Harness-home `.env` alone may set a bootstrap-only name. Each such name chooses where requests go: a + * proxy for every request, a DeepSeek endpoint for the model or search requests that carry the user's credential. + * The invoking directory's file arrives with a clone and keeps refusing them. The home file is the user's own, and + * the profile patches beside it can already set these endpoints as plugin `baseURL` fields; `DSH_HOME` is itself + * bootstrap-only, so no `.env` can relocate this exemption. The CA and TLS names in the same group stay refused + * everywhere: they change what is trusted, not where traffic goes. + * @param name - the variable name as the file spells it. + * @returns true for a {@link HOME_LAYER_PROXY_NAMES} entry in any letter case or a + * {@link HOME_LAYER_ENDPOINT_NAMES} entry in its exact spelling. + */ +function isHomeLayerRouteName(name: string): boolean { + return HOME_LAYER_PROXY_NAMES.has(name.toUpperCase()) || HOME_LAYER_ENDPOINT_NAMES.has(name) +} + /** * Whether a variable may come only from the inherited process environment * because it changes process, runtime, VCS, or network bootstrap. The Harness-home - * file is additionally allowed {@link HOME_LAYER_PROXY_NAMES}. + * file is additionally allowed the names {@link isHomeLayerRouteName} accepts. * @param name - the variable name. * @returns true when only the inherited environment may supply it. */ @@ -184,7 +199,7 @@ function isBootstrapOnly(name: string): boolean { * @param binName - the diagnostic prefix on the thrown error. * @param dir - the directory whose `.env` to read. * @param warn - sink for the one-line unreadable-file diagnostic. - * @param home - the resolved Harness home; when `dir` is it, {@link HOME_LAYER_PROXY_NAMES} are accepted. + * @param home - the resolved Harness home; when `dir` is it, names {@link isHomeLayerRouteName} accepts are allowed. * @returns the parsed entries, or `undefined` when the file is absent or unreadable. * @throws when the file declares a name {@link isBootstrapOnly} rejects and this layer may not set. */ @@ -207,12 +222,17 @@ function readEnvLayer( const values = parseEnv(content) as Record for (const name of Object.keys(values)) { if (!isBootstrapOnly(name)) continue - const proxyName = HOME_LAYER_PROXY_NAMES.has(name.toUpperCase()) - if (isHome && proxyName) continue - // A proxy name has a second way out that the other bootstrap names do not, so its message says so. - const remedy = proxyName - ? `export ${name}, or put it in ${resolve(home, '.env')}, which does not travel with a repository` - : `export ${name} instead of putting it in a .env file` + const homeLayerName = isHomeLayerRouteName(name) + if (isHome && homeLayerName) continue + // A home-layer name, or an endpoint name spelled another way, has a second way out that the other bootstrap names + // do not, so its message says so. + const homeEnv = resolve(home, '.env') + const upper = name.toUpperCase() + const remedy = homeLayerName + ? `export ${name}, or put it in ${homeEnv}, which does not travel with a repository` + : HOME_LAYER_ENDPOINT_NAMES.has(upper) + ? `spell it ${upper}, then export it or put it in ${homeEnv}, which does not travel with a repository` + : `export ${name} instead of putting it in a .env file` throw new Error( `${binName}: ${path} sets "${name}", which only the launching environment may set` + ' (it decides how this process starts, where its code and instructions load from, or how it' @@ -231,7 +251,7 @@ function readEnvLayer( * @param cwd - the invoking directory whose `.env` is the project layer. * @param warn - sink for the one-line misconfiguration diagnostics. * @returns this run's frozen environment snapshot. - * @throws when either file declares a bootstrap-only variable, except {@link HOME_LAYER_PROXY_NAMES} in the Harness-home file. + * @throws when either file declares a bootstrap-only variable, except names {@link isHomeLayerRouteName} accepts in the Harness-home file. */ export function loadLayeredEnv( binName: string, cwd: string = process.cwd(), diff --git a/packages/boot/app-boot/tests/app-boot.spec.ts b/packages/boot/app-boot/tests/app-boot.spec.ts index 48f2e4456c..d454332317 100644 --- a/packages/boot/app-boot/tests/app-boot.spec.ts +++ b/packages/boot/app-boot/tests/app-boot.spec.ts @@ -144,6 +144,9 @@ describe('loadLayeredEnv', () => { ['a network proxy', 'HTTPS_PROXY=http://attacker.example\n'], ['a lowercase network proxy', 'https_proxy=http://attacker.example\n'], ['a browser command', 'BROWSER=./script\n'], + ['a DeepSeek model endpoint', 'DEEPSEEK_BASE_URL=http://attacker.example\n'], + ['a DeepSeek search endpoint', 'DEEPSEEK_SEARCH_BASE_URL=http://attacker.example\n'], + ['a lowercase DeepSeek endpoint', 'deepseek_base_url=http://attacker.example\n'], ])('refuses to launch when a .env sets %s, before applying anything', (_case, content) => { const home = tmp() const project = tmp() @@ -189,12 +192,59 @@ describe('loadLayeredEnv', () => { } }) - it('still refuses every other bootstrap name in the Harness-home .env', () => { + it('accepts the DeepSeek endpoints from the Harness-home .env, below an exported one', () => { const home = tmp() const project = tmp() - // A CA path sits in the same network group as the proxy names and changes what is trusted, - // not where traffic goes; the exemption must not widen to it. - writeFileSync(join(home, '.env'), 'SSL_CERT_FILE=/tmp/ca.pem\n') + writeFileSync(join(home, '.env'), [ + 'DEEPSEEK_BASE_URL=https://from-home.example/anthropic', + 'DEEPSEEK_SEARCH_BASE_URL=https://from-home.example/v1', + '', + ].join('\n')) + clear() + vi.stubEnv('DSH_HOME', home) + vi.stubEnv('DEEPSEEK_BASE_URL', 'https://exported.example/anthropic') + vi.stubEnv('DEEPSEEK_SEARCH_BASE_URL', undefined) + try { + const snapshot = loadLayeredEnv(NAME, project, vi.fn()) + expect(snapshot.get('DEEPSEEK_SEARCH_BASE_URL')) + .toEqual({ value: 'https://from-home.example/v1', source: 'user-env', path: join(home, '.env') }) + expect(snapshot.get('DEEPSEEK_BASE_URL')).toEqual({ value: 'https://exported.example/anthropic', source: 'process' }) + expect(process.env.DEEPSEEK_SEARCH_BASE_URL).toBe('https://from-home.example/v1') + expect(process.env.DEEPSEEK_BASE_URL).toBe('https://exported.example/anthropic') + } finally { + clear() + vi.unstubAllEnvs() + } + }) + + it.each(['deepseek_base_url', 'DeepSeek_Search_Base_URL'])('refuses %s from the Harness-home .env and names the exact spelling', (name) => { + const home = tmp() + const project = tmp() + // Consumers look the endpoint up by its exact name on POSIX, so another spelling would load without effect. + writeFileSync(join(home, '.env'), `${name}=https://from-home.example/v1\n`) + clear() + vi.stubEnv('DSH_HOME', home) + vi.stubEnv(name, undefined) + try { + expect(() => loadLayeredEnv(NAME, project, vi.fn())) + .toThrow(`spell it ${name.toUpperCase()}, then export it or put it in ${join(home, '.env')}`) + expect(process.env[name]).toBeUndefined() + } finally { + clear() + vi.unstubAllEnvs() + } + }) + + it.each([ + 'SSL_CERT_FILE=/tmp/ca.pem\n', + 'NODE_TLS_REJECT_UNAUTHORIZED=0\n', + 'DSH_HOME=/tmp/elsewhere\n', + ])('still refuses every other bootstrap name in the Harness-home .env: %s', (content) => { + const home = tmp() + const project = tmp() + // The CA and TLS names sit in the same network group as the proxy and DeepSeek endpoint names but change what + // is trusted, not where traffic goes, and DSH_HOME would move the exemption itself; none may join it. + writeFileSync(join(home, '.env'), content) clear() vi.stubEnv('DSH_HOME', home) try { @@ -205,16 +255,17 @@ describe('loadLayeredEnv', () => { } }) - it('names the Harness-home file as the way out when a project .env sets a proxy', () => { + it.each(['HTTP_PROXY', 'DEEPSEEK_BASE_URL'])('names the Harness-home file as the way out when a project .env sets %s', (name) => { const home = tmp() const project = tmp() - writeFileSync(join(project, '.env'), 'HTTP_PROXY=http://attacker.example\n') + writeFileSync(join(project, '.env'), `${name}=http://attacker.example\n`) clear(); clearProxy() vi.stubEnv('DSH_HOME', home) + vi.stubEnv(name, undefined) try { expect(() => loadLayeredEnv(NAME, project, vi.fn())) - .toThrow(`export HTTP_PROXY, or put it in ${join(home, '.env')}, which does not travel with a repository`) - expect(process.env.HTTP_PROXY).toBeUndefined() + .toThrow(`export ${name}, or put it in ${join(home, '.env')}, which does not travel with a repository`) + expect(process.env[name]).toBeUndefined() } finally { clear(); clearProxy() vi.unstubAllEnvs() diff --git a/packages/client/ui-sidebar-documentpreview/README.i18n.yaml b/packages/client/ui-sidebar-documentpreview/README.i18n.yaml index 99d60b54c9..91373281f1 100644 --- a/packages/client/ui-sidebar-documentpreview/README.i18n.yaml +++ b/packages/client/ui-sidebar-documentpreview/README.i18n.yaml @@ -15,8 +15,8 @@ en: 0dd8c3a22e28aacf zh: c27c11bf4b5cceba /deepseek-ai-dsh-client-ui-sidebar-documentpreview/what-it-registers: - en: 5686f49d98437f49 - zh: 40c154f8cc7b8d14 + en: 691249b09395eae1 + zh: e2ae49df2ebe130e /deepseek-ai-dsh-client-ui-sidebar-documentpreview/addresses: en: 57942270e23d3a8e zh: 6617124e652b3f67 diff --git a/packages/client/ui-sidebar-documentpreview/README.md b/packages/client/ui-sidebar-documentpreview/README.md index 4e87ae3aed..6453a874e8 100644 --- a/packages/client/ui-sidebar-documentpreview/README.md +++ b/packages/client/ui-sidebar-documentpreview/README.md @@ -31,7 +31,7 @@ Preview files in the right Sidebar and choose among registered renderers. Markdo Image drag actions arrive through the image entry’s inject face from [ui-file-drag](../ui-file-drag/README.md). - **The type** — `ctx.sidebarRightTabs.register(...)` with id `@deepseek-ai/dsh-client-ui-sidebar-documentpreview` (this implementation's identity in the tab system, and the key its body registers under), kind `text`, a whole-address file-resource pattern, band `fallback`. `canOpen` accepts only Session addresses, whose paths may be relative or absolute; bare `absolute` addresses are not claimed. A type registered at the `extension` or `builtin` band for a narrower pattern (say `*.png`) takes those addresses; other supported files land here. The whole address is the content identity, so two files with one name in different directories, or one path under two sessions, are two tabs; the decoded basename is the tab title, and the keyed `sidebar.right.pane.tab.title` seat places its extension-specific `FileTypeIcon` before that title. -- **The body** — the keyed `sidebar.right.pane.tab` seat under the type's id. Its fixed header shows the Host's absolute path when available, otherwise the requested path; the shared [`PathLabel`](../ui-primitives/README.md#component-catalog) subdues directories, preserves a clipped path’s trailing characters with a left-edge fade, and exposes the full value on hover. A dropdown appears when multiple supported renderers are available. Plain text is offered only for text-compatible sources; a single renderer shows no viewer control. A known binary container suffix without a registered renderer shows the file-type icon and an unsupported-preview message under the path header, without issuing a read. Two Session-scoped list child slots hand the previewed file to other plugins once its metadata reports a Host path, both with `absolutePath` as the owner prop: `sidebar.right.tab.document.actions` renders after the header's own controls and before the navigator toggle, at the same position in every state that shows the header so it is not remounted when the state changes, and `sidebar.right.tab.document.unpreviewable` renders in the unsupported empty state and in the empty state of a readable file the preview cannot render (`not-text`, `too-large`), where Retry would otherwise stand; a carrier or unclassified read failure keeps Retry, and a missing or non-regular path offers only its explanation. The shipped occupant of both is [`ui-open-in-app`](../ui-open-in-app/README.md). The preview directly renders [`ui-sidebar-files`](../ui-sidebar-files/README.md#the-navigator)'s Session-scoped Factories: `sidebar.files.navigator` after the body in its content row with `{ useTabInfo, page: false, onOpen }`, where `onOpen(address)` calls `tab.actions.openResource(address, { replaceTab: true })`, and `sidebar.files.navigator.toggle` at the header's end with `{ useTabInfo }`. Both stay mounted from loading to loaded in docked and floating panes; unregistered Factories render nothing. The navigator owns its width within the row's positioning box and stacking context, narrowing or overlaying the body without covering the floating pane's resize handle. A renderer layer handling Escape calls `preventDefault()` to keep the navigator from also closing. A wrap toggle appears only when the selected renderer declares `wrap: true`; its glyph describes the mode the click selects, and the per-tab preference starts on. Reload stays in this header, not the Sidebar's tab strip. Automatic refresh defaults to enabled; its separate toggle is hidden, with state, labels, styles, and toggle logic retained. The body reaches every pane edge; each renderer owns its content inset and may own an inner scrollport. This intentionally differs from the file tree's 2px right-side scrollbar offset: previews keep the full pane width so edge-to-edge HTML and code scrollports end at the pane edge. +- **The body** — the keyed `sidebar.right.pane.tab` seat under the type's id. Its fixed header shows the Host's absolute path when available, otherwise the requested path; the shared [`PathLabel`](../ui-primitives/README.md#component-catalog) subdues directories, preserves a clipped path’s trailing characters with a left-edge fade, and exposes the full value on hover. A dropdown appears when multiple supported renderers are available. Plain text is offered only for text-compatible sources; a single renderer shows no viewer control. A known binary container suffix without a registered renderer shows the file-type icon and an unsupported-preview message under the path header, without issuing a read. Two Session-scoped list child slots hand the previewed file to other plugins once its metadata reports a Host path, both with `absolutePath` as the owner prop: `sidebar.right.tab.document.actions` renders after the header's own controls and before the navigator toggle, at the same position in every state that shows the header so it is not remounted when the state changes, and `sidebar.right.tab.document.unpreviewable` renders in the unsupported empty state and in the empty state of a readable file the preview cannot render (`not-text`, `too-large`), where Retry would otherwise stand; a carrier or unclassified read failure keeps Retry, and a missing or non-regular path offers only its explanation. The shipped occupant of both is [`ui-open-in-app`](../ui-open-in-app/README.md). The preview directly renders [`ui-sidebar-files`](../ui-sidebar-files/README.md#the-navigator)'s Session-scoped Factories: `sidebar.files.navigator` after the body in its content row, which carries `data-files-navigator-row`, with `{ useTabInfo, page: false, onOpen }`, where `onOpen(address)` calls `tab.actions.openResource(address, { replaceTab: true })`, and `sidebar.files.navigator.toggle` at the header's end with `{ useTabInfo }`. Both stay mounted from loading to loaded in docked and floating panes; unregistered Factories render nothing. The navigator owns its width within the row's positioning box and stacking context, narrowing or overlaying the body without covering the floating pane's resize handle. A renderer layer handling Escape calls `preventDefault()` to keep the navigator from also closing. A wrap toggle appears only when the selected renderer declares `wrap: true`; its glyph describes the mode the click selects, and the per-tab preference starts on. Reload stays in this header, not the Sidebar's tab strip. Automatic refresh defaults to enabled; its separate toggle is hidden, with state, labels, styles, and toggle logic retained. The body reaches every pane edge; each renderer owns its content inset and may own an inner scrollport. This intentionally differs from the file tree's 2px right-side scrollbar offset: previews keep the full pane width so edge-to-edge HTML and code scrollports end at the pane edge. - **Shared loading and view state**, session-scoped and bucketed by tab id. The store holds accumulated pages or complete bytes, read and observed versions, pending Resource changes, automatic-refresh state, loading/failure state, renderer choice, scroll offset, wrap, and the answered navigation revision. The ordinary inject face calls Remote readers and writes through declared store actions. Reloads and loading-mode changes retire older requests; the tab's abort signal forgets its state. Document implementations register metadata with `ctx.documentPreviews.register({ id, extensions, binaryExtensions?, priority, title, loading, wrap?, sourceLines? })` and a body under the same `id` in the keyed, Session-scoped `sidebar.right.tab.document` child slot. `binaryExtensions` lists suffixes in `extensions` that cannot be read as text and omit the plain-text option. `sourceLines` declares that a `text-pages` body exposes source-line anchors for [navigation](#navigation). Own both registrations with effects and wait for the child slot through `ctx.slots.inject`. Bodies receive `resourceAddress`, `content`, `wrap`, `scrollportRef`, `addResource`, `setResources`, and the standard `useTabInfo`/`useResource` hooks. Dependency membership belongs to a tab-owned `ResourceGroup`; replacing dependencies always retains the root Resource. An inner scrolling element attaches `scrollportRef`; unmounting restores the shared body as scroll owner. The registry retains all matching alternatives: `extension` (the default) ranks above `builtin`, then longer suffixes rank first, then registration order. The dropdown preserves a selected implementation while it remains available. HTML, SVG, and unmatched extensions retain the plain-text fallback independently of loading mode. diff --git a/packages/client/ui-sidebar-documentpreview/README.zh.md b/packages/client/ui-sidebar-documentpreview/README.zh.md index f2996fd813..3e846d1f63 100644 --- a/packages/client/ui-sidebar-documentpreview/README.zh.md +++ b/packages/client/ui-sidebar-documentpreview/README.zh.md @@ -31,7 +31,7 @@ kind: "package-reference" 图片拖放回调通过图片入口的注入接口从 [ui-file-drag](../ui-file-drag/README.zh.md) 获取。 - **类型** —— `ctx.sidebarRightTabs.register(...)`,id 为 `@deepseek-ai/dsh-client-ui-sidebar-documentpreview`(这个实现在 tab 系统中的身份,也是其正文注册所用的键),kind `text`,匹配完整文件资源地址的 pattern,档位 `fallback`。`canOpen` 只接受 Session 地址,其中路径可为相对或绝对路径;不认领裸 `absolute` 地址。在 `extension` 或 `builtin` 档以更窄 pattern(比如 `*.png`)注册的类型接走那些地址;其他受支持文件落到这里。整个地址就是内容身份,所以不同目录下同名的两个文件、或同一路径在两个会话之下,是两个 tab;解码后的 basename 是 tab 标题,keyed slot `sidebar.right.pane.tab.title` 会在标题前放置按扩展名选择的 `FileTypeIcon`。 -- **正文** —— keyed slot `sidebar.right.pane.tab`,键为类型的 id。固定头部在可用时显示 Host 的绝对路径,否则显示请求路径;共享的 [`PathLabel`](../ui-primitives/README.zh.md#component-catalog) 弱化目录颜色,以左侧渐隐保留被裁剪路径的尾部字符,并在悬停时展示完整值。有多个受支持的渲染器时才显示下拉菜单。仅文本兼容的源文件提供纯文本选项;只有一个渲染器时不显示查看器控件。已知的二进制容器后缀没有注册渲染器时,在路径头部下方显示文件类型图标和不支持预览的说明,并且不会发起读取。两个 Session 作用域的 list 子 slot 在文件元数据报出 Host 路径后把当前文件交给其他插件,owner prop 为 `absolutePath`:`sidebar.right.tab.document.actions` 在每个显示头部的状态里渲染在头部自有控件之后、导航栏开关之前,位置在各状态间相同,因此状态切换时不会重新挂载;`sidebar.right.tab.document.unpreviewable` 渲染在不支持预览的空态里,以及可读但本预览无法渲染的文件(`not-text`、`too-large`)的空态里,占据原本放重试的位置;载体或未分类的读取失败仍保留重试,不存在或非普通文件的路径只给说明。两者已发布的占位者都是 [`ui-open-in-app`](../ui-open-in-app/README.zh.md)。预览直接渲染 [`ui-sidebar-files`](../ui-sidebar-files/README.zh.md#the-navigator) 的 Session 作用域 Factory:`sidebar.files.navigator` 位于内容行的正文之后,传入 `{ useTabInfo, page: false, onOpen }`,其中 `onOpen(address)` 调用 `tab.actions.openResource(address, { replaceTab: true })`;`sidebar.files.navigator.toggle` 位于头部末端,传入 `{ useTabInfo }`。两者在停靠与浮动分栏中从加载到完成保持挂载;未注册的 Factory 不绘制内容。导航栏在内容行的定位框与层叠上下文内决定宽度,让正文变窄或覆盖正文,不遮挡浮窗缩放手柄。自行处理 Escape 的渲染器浮层需调用 `preventDefault()`,避免同时关闭导航栏。仅当所选渲染器声明 `wrap: true` 时显示换行开关;图标表示点击后切换到的模式,该偏好按 tab 保存,初始开启。重新载入仍在此头部,不放入 Sidebar 的 tab 条。自动刷新默认开启;独立开关暂时隐藏,状态、文案、样式与切换逻辑保留。正文贴合格的每条边,各渲染器自行提供内容留白,并可拥有内部滚动区。这与文件树右侧预留 2px 滚动条间距的布局有意不同:Preview 使用格的完整宽度,使贴边 HTML 与代码滚动区终止于格的边缘。 +- **正文** —— keyed slot `sidebar.right.pane.tab`,键为类型的 id。固定头部在可用时显示 Host 的绝对路径,否则显示请求路径;共享的 [`PathLabel`](../ui-primitives/README.zh.md#component-catalog) 弱化目录颜色,以左侧渐隐保留被裁剪路径的尾部字符,并在悬停时展示完整值。有多个受支持的渲染器时才显示下拉菜单。仅文本兼容的源文件提供纯文本选项;只有一个渲染器时不显示查看器控件。已知的二进制容器后缀没有注册渲染器时,在路径头部下方显示文件类型图标和不支持预览的说明,并且不会发起读取。两个 Session 作用域的 list 子 slot 在文件元数据报出 Host 路径后把当前文件交给其他插件,owner prop 为 `absolutePath`:`sidebar.right.tab.document.actions` 在每个显示头部的状态里渲染在头部自有控件之后、导航栏开关之前,位置在各状态间相同,因此状态切换时不会重新挂载;`sidebar.right.tab.document.unpreviewable` 渲染在不支持预览的空态里,以及可读但本预览无法渲染的文件(`not-text`、`too-large`)的空态里,占据原本放重试的位置;载体或未分类的读取失败仍保留重试,不存在或非普通文件的路径只给说明。两者已发布的占位者都是 [`ui-open-in-app`](../ui-open-in-app/README.zh.md)。预览直接渲染 [`ui-sidebar-files`](../ui-sidebar-files/README.zh.md#the-navigator) 的 Session 作用域 Factory:`sidebar.files.navigator` 位于内容行的正文之后(该行带有 `data-files-navigator-row`),传入 `{ useTabInfo, page: false, onOpen }`,其中 `onOpen(address)` 调用 `tab.actions.openResource(address, { replaceTab: true })`;`sidebar.files.navigator.toggle` 位于头部末端,传入 `{ useTabInfo }`。两者在停靠与浮动分栏中从加载到完成保持挂载;未注册的 Factory 不绘制内容。导航栏在内容行的定位框与层叠上下文内决定宽度,让正文变窄或覆盖正文,不遮挡浮窗缩放手柄。自行处理 Escape 的渲染器浮层需调用 `preventDefault()`,避免同时关闭导航栏。仅当所选渲染器声明 `wrap: true` 时显示换行开关;图标表示点击后切换到的模式,该偏好按 tab 保存,初始开启。重新载入仍在此头部,不放入 Sidebar 的 tab 条。自动刷新默认开启;独立开关暂时隐藏,状态、文案、样式与切换逻辑保留。正文贴合格的每条边,各渲染器自行提供内容留白,并可拥有内部滚动区。这与文件树右侧预留 2px 滚动条间距的布局有意不同:Preview 使用格的完整宽度,使贴边 HTML 与代码滚动区终止于格的边缘。 - **共享加载与视图状态**,会话作用域、按 tab id 分桶。store 持有累计页或完整字节、读取与观察版本、待处理 Resource 变化、自动刷新状态、加载/失败状态、渲染器选择、滚动位置、换行和已响应的导航 revision。普通 inject face 调用 Remote 读取,并经声明的 store action 写入。重新载入和加载模式变化会淘汰旧请求;tab 的中止信号清理其状态。 文档实现在 `ctx.documentPreviews.register({ id, extensions, binaryExtensions?, priority, title, loading, wrap?, sourceLines? })` 注册元数据,并以相同 `id` 向 keyed、Session 作用域的子 slot `sidebar.right.tab.document` 注册正文。`binaryExtensions` 列出 `extensions` 中不可按文本阅读的后缀,这些后缀不提供纯文本选项。`sourceLines` 声明 `text-pages` 正文为[导航](#navigation)提供源码行锚点。两处注册都由 effect 持有,通过 `ctx.slots.inject` 等待子 slot。正文接收 `resourceAddress`、`content`、`wrap`、`scrollportRef`、`addResource`、`setResources` 和标准 `useTabInfo`/`useResource` 钩子。依赖成员由 tab 持有的 `ResourceGroup` 管理;替换依赖始终保留根 Resource。内部滚动元素挂载 `scrollportRef`;卸载时恢复共享正文的滚动职责。注册表保留所有匹配备选:`extension`(默认)优先于 `builtin`,随后按更长的后缀、再按注册顺序排列。所选实现仍可用时,下拉选择保持不变。HTML、SVG 和未匹配的扩展名保留纯文本回退,与加载方式无关。 diff --git a/packages/client/ui-sidebar-documentpreview/src/client/PreviewFrame.tsx b/packages/client/ui-sidebar-documentpreview/src/client/PreviewFrame.tsx index 6ee191a3fe..5b529fffee 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/PreviewFrame.tsx +++ b/packages/client/ui-sidebar-documentpreview/src/client/PreviewFrame.tsx @@ -73,7 +73,7 @@ export function PreviewFrame({ {handoff} -
+
{children}
diff --git a/packages/client/ui-sidebar-documentpreview/tests/text-preview.client.spec.tsx b/packages/client/ui-sidebar-documentpreview/tests/text-preview.client.spec.tsx index fb24a887b4..8e0c2b089c 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/text-preview.client.spec.tsx +++ b/packages/client/ui-sidebar-documentpreview/tests/text-preview.client.spec.tsx @@ -861,6 +861,8 @@ describe('TextPreview — workspace navigator Factories', () => { return { toggle: header?.lastElementChild?.getAttribute('data-navigator-factory'), navigator: row?.lastElementChild?.getAttribute('data-navigator-factory'), + // The navigator's filled layout hides the body of the row this attribute names. + navigatorRow: row?.hasAttribute('data-files-navigator-row'), } } @@ -876,7 +878,7 @@ describe('TextPreview — workspace navigator Factories', () => { ]) expect(view.container.querySelector('[data-textpreview-state="loading"]')).not.toBeNull() expect(seats(view.container)).toEqual({ - toggle: 'sidebar.files.navigator.toggle', navigator: 'sidebar.files.navigator', + toggle: 'sidebar.files.navigator.toggle', navigator: 'sidebar.files.navigator', navigatorRow: true, }) const navigator = view.container.querySelector('[data-textpreview-content] > [data-navigator-factory]')! const toggle = view.container.querySelector('[data-textpreview-path]')?.parentElement?.lastElementChild @@ -890,7 +892,7 @@ describe('TextPreview — workspace navigator Factories', () => { await settle() expect(view.container.querySelector('[data-textpreview-state="text"]')).not.toBeNull() expect(seats(view.container)).toEqual({ - toggle: 'sidebar.files.navigator.toggle', navigator: 'sidebar.files.navigator', + toggle: 'sidebar.files.navigator.toggle', navigator: 'sidebar.files.navigator', navigatorRow: true, }) expect(view.container.querySelector('[data-textpreview-content] > [data-navigator-factory]')).toBe(navigator) expect(view.container.querySelector('[data-textpreview-path]')?.parentElement?.lastElementChild).toBe(toggle) diff --git a/packages/client/ui-sidebar-files/README.i18n.yaml b/packages/client/ui-sidebar-files/README.i18n.yaml index 0dfbf36f96..dd3d34d879 100644 --- a/packages/client/ui-sidebar-files/README.i18n.yaml +++ b/packages/client/ui-sidebar-files/README.i18n.yaml @@ -18,8 +18,8 @@ en: c4b8f3a52f68bd51 zh: 30b580320637572c /deepseek-ai-dsh-client-ui-sidebar-files/the-navigator: - en: 7e3aebc6079b023b - zh: 11911cc59fcec9b1 + en: 3424600b15bde2d2 + zh: 2cf1c99ffef2ad50 /deepseek-ai-dsh-client-ui-sidebar-files/the-tree: en: 3d71e50801761213 zh: e4e9ab19dd22288d diff --git a/packages/client/ui-sidebar-files/README.md b/packages/client/ui-sidebar-files/README.md index 2aea08292d..3991ccec18 100644 --- a/packages/client/ui-sidebar-files/README.md +++ b/packages/client/ui-sidebar-files/README.md @@ -41,7 +41,7 @@ The [registration](src/client/index.ts) assembles the page, navigator, toggle, a ## The navigator -Render `sidebar.files.navigator` with `FileNavigatorInput`: `{ useTabInfo, page, onOpen }`. The required `onOpen: (address: string) => void` callback belongs to the containing plugin; the navigator builds the file address without choosing placement. `page` controls presentation only: `true` always fills the files page, while `false` follows the preview's pane preference and available width. +Render `sidebar.files.navigator` with `FileNavigatorInput`: `{ useTabInfo, page, onOpen }`. The required `onOpen: (address: string) => void` callback belongs to the containing plugin; the navigator builds the file address without choosing placement. `page` controls presentation only: `true` always fills the files page, while `false` follows the preview's pane preference and available width. Render the Factory as a direct child of the row it shares with the body, and mark that row with `data-files-navigator-row`: the layout that fills the row hides the row's other children only under that attribute, so a host without it leaves the covered body reachable by keyboard. The navigator is drawn below the preview's header, which spans the pane, at the right of the row it shares with the body. Beside a preview, its pane's record decides whether it shows: a pane starts with it hidden, and the choice holds for every file tab of the pane until the toggle, Escape, the workspace command, or navigation to a covered preview changes it. A pane without a record of its own, such as a floating panel a tab was dragged out into, starts hidden at the default width. A click outside the tree does not hide it, and opening content in another pane does not hide it either. On the files page the tree fills the page below the header, docked or floating, without a separator, and always shows: the page draws no navigator toggle and Escape leaves it. A file the page opens starts with the destination pane's own choice, hidden for a pane that has made none. diff --git a/packages/client/ui-sidebar-files/README.zh.md b/packages/client/ui-sidebar-files/README.zh.md index 18e9caef84..5c5e776ad2 100644 --- a/packages/client/ui-sidebar-files/README.zh.md +++ b/packages/client/ui-sidebar-files/README.zh.md @@ -41,7 +41,7 @@ kind: "package-reference" ## 导航栏 -用 `FileNavigatorInput` 渲染 `sidebar.files.navigator`:`{ useTabInfo, page, onOpen }`。必填的 `onOpen: (address: string) => void` 回调由容纳它的插件提供;导航栏构造文件地址,不决定落位。`page` 只控制呈现:`true` 时始终铺满文件页,`false` 时遵循预览所在分栏的偏好与可用宽度。 +用 `FileNavigatorInput` 渲染 `sidebar.files.navigator`:`{ useTabInfo, page, onOpen }`。必填的 `onOpen: (address: string) => void` 回调由容纳它的插件提供;导航栏构造文件地址,不决定落位。`page` 只控制呈现:`true` 时始终铺满文件页,`false` 时遵循预览所在分栏的偏好与可用宽度。Factory 要作为它与正文共用的那一行的直接子元素渲染,并给该行加上 `data-files-navigator-row`:铺满整行的布局只在带这个属性的行里隐藏其他子元素,宿主不加时,被盖住的正文仍可用键盘访问。 导航栏画在横跨分栏的预览标题行下方,位于它与正文共用的那一行的右侧。在预览旁,是否显示由其分栏的记录决定:分栏默认隐藏,这一选择对分栏内的每个文件 tab 都有效,直到开关、Escape、工作区命令,或导航到被目录树遮住的预览改变它。自身没有记录的分栏(例如 tab 拖出后所在的浮窗)从隐藏、默认宽度开始。点击目录树以外的地方不会隐藏它;在另一分栏打开内容也不会隐藏它。在文件页上,目录树铺满标题行下方的整页,停靠与浮动时都一样,不显示分隔条,且始终显示:文件页不画导航栏开关,Escape 也不关闭它。文件页打开的文件沿用落定分栏自己的选择,尚未做过选择的分栏为隐藏。 diff --git a/packages/client/ui-sidebar-files/src/client/FileNavigator.tsx b/packages/client/ui-sidebar-files/src/client/FileNavigator.tsx index dfe53b637f..dac8bb7f88 100644 --- a/packages/client/ui-sidebar-files/src/client/FileNavigator.tsx +++ b/packages/client/ui-sidebar-files/src/client/FileNavigator.tsx @@ -41,7 +41,11 @@ export interface FileNavigatorInput { declare module '@deepseek-ai/dsh-client-ui-slots' { interface SlotFactoryMap { - /** One pane's workspace tree with its width and show/hide state. */ + /** + * One pane's workspace tree with its width and show/hide state. The host renders it as a direct child of + * the content row it shares with the body, and that row carries `data-files-navigator-row`, which the + * filled layout needs to hide the covered body. + */ 'sidebar.files.navigator': { scope: 'session' props: FileNavigatorInput diff --git a/packages/client/ui-sidebar-files/src/client/FilesBody.module.css b/packages/client/ui-sidebar-files/src/client/FilesBody.module.css index 4bddc11bb1..315d5afae8 100644 --- a/packages/client/ui-sidebar-files/src/client/FilesBody.module.css +++ b/packages/client/ui-sidebar-files/src/client/FilesBody.module.css @@ -74,14 +74,16 @@ border-left: 0; } -/* Covered content stays laid out but cannot receive keyboard focus. */ -:has(> .navigator.fill) > :not(.navigator) { +/* Covered content stays laid out but cannot receive keyboard focus. The rule + names its row through `data-files-navigator-row`: a `:has()` without a + subject makes the browser restyle the whole document after any DOM change. */ +[data-files-navigator-row]:has(> .navigator.fill) > :not(.navigator) { visibility: hidden; } /* navigator-layout `FULLSCREEN_QUERY`: every layout fills the row. */ @media (width < 768px) { - :has(> .navigator:not(.wholePage)) > :not(.navigator) { + [data-files-navigator-row]:has(> .navigator:not(.wholePage)) > :not(.navigator) { visibility: hidden; } diff --git a/packages/client/ui-sidebar-files/src/client/FilesBody.tsx b/packages/client/ui-sidebar-files/src/client/FilesBody.tsx index f7f5ae385f..0f6b26d7f6 100644 --- a/packages/client/ui-sidebar-files/src/client/FilesBody.tsx +++ b/packages/client/ui-sidebar-files/src/client/FilesBody.tsx @@ -113,7 +113,7 @@ export function FilesBody(props: FilesBodyProps): ReactNode { {renderSlot('sidebar.right.tab.files.actions', { absolutePath: cwd })}
-
{navigator}
+
{navigator}
) } diff --git a/packages/client/ui-sidebar-files/tests/files-body.client.spec.tsx b/packages/client/ui-sidebar-files/tests/files-body.client.spec.tsx index a8ef76a271..2cd0aa3d5a 100644 --- a/packages/client/ui-sidebar-files/tests/files-body.client.spec.tsx +++ b/packages/client/ui-sidebar-files/tests/files-body.client.spec.tsx @@ -56,6 +56,8 @@ describe('FilesBody', () => { expect([...header.querySelectorAll('button:not([data-files-auto-refresh])')].map(button => button.getAttribute('aria-label'))) .toEqual([zh.reload, zh['search.placeholder']]) const content = page.querySelector('[data-files-content]')! + // The filled layout's rule finds the row the navigator covers by this attribute. + expect(content.hasAttribute('data-files-navigator-row')).toBe(true) expect(content.children).toHaveLength(1) const navigator = content.firstElementChild! expect(navigator.getAttribute('data-files-navigator')).toBe(PANE) diff --git a/packages/client/ui-theme/tests/stylesheet-scan.client.spec.ts b/packages/client/ui-theme/tests/stylesheet-scan.client.spec.ts new file mode 100644 index 0000000000..e65cb1a16a --- /dev/null +++ b/packages/client/ui-theme/tests/stylesheet-scan.client.spec.ts @@ -0,0 +1,32 @@ +/** Tests for the package-tree scan helpers that the stylesheet contracts range over. */ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it } from 'vitest' +import { packageFiles } from './stylesheet-scan.ts' + +const roots: string[] = [] + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) +}) + +it('skips dot directories holding transient roots', () => { + const root = mkdtempSync(join(tmpdir(), 'dsh-stylesheet-scan-')) + roots.push(root) + mkdirSync(join(root, 'pkg', 'nested'), { recursive: true }) + writeFileSync(join(root, 'pkg', 'package.json'), '{}') + writeFileSync(join(root, 'pkg', 'nested', 'package.json'), '{}') + mkdirSync(join(root, '.typert-worker-failure-probe')) + writeFileSync(join(root, '.typert-worker-failure-probe', 'package.json'), '{}') + + // The helper reports `/`-separated paths on every platform, so the Windows + // temporary root's separators must not reach the expectation. Traversal + // reports the nested manifest before its parent, and directory order is not + // guaranteed, so both sides are compared sorted. + const normalized = root.replaceAll('\\', '/') + expect(packageFiles(name => name === 'package.json', root).sort()).toEqual([ + `${normalized}/pkg/nested/package.json`, + `${normalized}/pkg/package.json`, + ]) +}) diff --git a/packages/client/ui-theme/tests/stylesheet-scan.ts b/packages/client/ui-theme/tests/stylesheet-scan.ts index 91311945bf..ce796bc62c 100644 --- a/packages/client/ui-theme/tests/stylesheet-scan.ts +++ b/packages/client/ui-theme/tests/stylesheet-scan.ts @@ -73,22 +73,28 @@ export function varReferences(value: string): string[] { /** * Every file shipped as package source whose name the predicate accepts, - * excluding build output and installed dependencies. + * excluding build output, installed dependencies, and dot directories. + * Dot directories hold VCS metadata and per-test temporary roots rather than + * shipped source, and their owner can remove one while a scan reads it. * @param accepts - filename predicate. - * @returns absolute paths under packages/, `/`-separated on every platform so + * @param root - tree to walk, the packages directory by default. + * @returns absolute paths under `root`, `/`-separated on every platform so * specs can match repo-relative suffixes verbatim. */ -export function packageFiles(accepts: (name: string) => boolean): string[] { +export function packageFiles(accepts: (name: string) => boolean, root: string = PACKAGES_DIR): string[] { const found: string[] = [] const walk = (dir: string): void => { for (const entry of readdirSync(dir, { withFileTypes: true })) { const path = join(dir, entry.name) if (entry.isDirectory()) { - if (entry.name !== 'node_modules' && entry.name !== 'lib' && entry.name !== 'dist') walk(path) + // Dot directories hold VCS metadata and the temporary roots each spec + // creates inside the package tree, never shipped package source; a + // concurrent owner can remove one while a scan reads it. + if (!entry.name.startsWith('.') && entry.name !== 'node_modules' && entry.name !== 'lib' && entry.name !== 'dist') walk(path) } else if (accepts(entry.name)) found.push(path.replaceAll('\\', '/')) } } - walk(PACKAGES_DIR) + walk(root) return found } diff --git a/packages/deliverables/tool-present/README.i18n.yaml b/packages/deliverables/tool-present/README.i18n.yaml index 7de5647086..d5b2ce4e60 100644 --- a/packages/deliverables/tool-present/README.i18n.yaml +++ b/packages/deliverables/tool-present/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 09388d293f9be9cb /deepseek-ai-dsh-tool-present/use-this-package: - en: d1a1e22686f9041d - zh: 8053a913199195fa + en: aecb160dd6ee85ba + zh: 148288a2e92236fa /deepseek-ai-dsh-tool-present/understand-the-implementation: en: 6e73ca41dddc096e zh: b59fdfa3f3cc47e1 diff --git a/packages/deliverables/tool-present/README.md b/packages/deliverables/tool-present/README.md index 7439bda570..031cf546d4 100644 --- a/packages/deliverables/tool-present/README.md +++ b/packages/deliverables/tool-present/README.md @@ -29,7 +29,7 @@ Shared output and file-link guidance comes from [`dsh-deliverable-file-reference `present` validates paths against the Session's current working directory and records absolute paths, so later directory changes do not redirect delivered files. -The `standard`, `ptc`, and `cordis` agent presets mount this plugin. Call `present` with `files: [{ path, description? }]` after creating the files. Files must be regular files accessible through the Session filesystem. Relative paths resolve against the Session working directory; absolute paths may name files outside it, including `/tmp` or Downloads. Missing files, directories, final symbolic links, and provider-denied paths fail the call. Files in a shell sandbox’s private `/tmp` must first be written somewhere the Session filesystem can access. +The `standard`, `ptc`, and `cordis` agent presets mount this plugin. Call `present` with `files: [{ path, description? }]` after creating the files. Files must be regular files accessible through the Session filesystem. Relative paths resolve against the Session working directory; absolute paths may name files outside it, including `/tmp` or Downloads. Missing files, directories, final symbolic links, and provider-denied paths fail the call with `FsError` codes such as `FS_NOT_FOUND`, `FS_NOT_REGULAR_FILE`, and `FS_PERMISSION_DENIED`; an empty path or a file count outside 1 to `maxFiles` reports `ToolArgsError` (`INVALID_ARGS`), and a call without an agent Session or open turn reports `ToolContextUnavailableError` (`TOOL_CONTEXT_UNAVAILABLE`). Files in a shell sandbox’s private `/tmp` must first be written somewhere the Session filesystem can access. Mount it in an agent's Cordis composition with `tools`, `fs`, and the `turnBoundary` Session projection available: diff --git a/packages/deliverables/tool-present/README.zh.md b/packages/deliverables/tool-present/README.zh.md index 40b81571aa..08410efe05 100644 --- a/packages/deliverables/tool-present/README.zh.md +++ b/packages/deliverables/tool-present/README.zh.md @@ -29,7 +29,7 @@ kind: "package-reference" `present` 按 Session 的当前工作目录校验路径并记录绝对路径,因此后续目录切换不会让已交付文件指向其他位置。 -`standard`、`ptc` 与 `cordis` Agent preset 挂载本插件。创建文件后,以 `files: [{ path, description? }]` 调用 `present`。文件必须是 Session 文件系统可访问的普通文件。相对路径按 Session 工作目录解析;绝对路径可以指向工作区外的文件,包括 `/tmp` 或 Downloads。文件缺失、为目录、最终路径为符号链接或提供方拒绝访问时,调用失败。Shell 沙箱私有 `/tmp` 中的文件需要先写入 Session 文件系统可访问的位置。 +`standard`、`ptc` 与 `cordis` Agent preset 挂载本插件。创建文件后,以 `files: [{ path, description? }]` 调用 `present`。文件必须是 Session 文件系统可访问的普通文件。相对路径按 Session 工作目录解析;绝对路径可以指向工作区外的文件,包括 `/tmp` 或 Downloads。文件缺失、为目录、最终路径为符号链接或提供方拒绝访问时,调用以 `FsError` 失败,错误码如 `FS_NOT_FOUND`、`FS_NOT_REGULAR_FILE` 与 `FS_PERMISSION_DENIED`;路径为空或文件数量不在 1 到 `maxFiles` 之间时报告 `ToolArgsError`(`INVALID_ARGS`),缺少 agent Session 或打开的 turn 时报告 `ToolContextUnavailableError`(`TOOL_CONTEXT_UNAVAILABLE`)。Shell 沙箱私有 `/tmp` 中的文件需要先写入 Session 文件系统可访问的位置。 在 Agent 的 Cordis 组合中挂载,并提供 `tools`、`fs` 和 `turnBoundary` Session 投影: diff --git a/packages/deliverables/tool-present/src/index.ts b/packages/deliverables/tool-present/src/index.ts index 393a95d8c8..fac6ab0378 100644 --- a/packages/deliverables/tool-present/src/index.ts +++ b/packages/deliverables/tool-present/src/index.ts @@ -3,7 +3,7 @@ import type {} from '@deepseek-ai/dsh-working-directory' import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { FsError } from '@deepseek-ai/dsh-fs' -import { defineTool, type ToolExecution } from '@deepseek-ai/dsh-tools' +import { ToolArgsError, ToolContextUnavailableError, defineTool, type ToolExecution } from '@deepseek-ai/dsh-tools' import type {} from '@deepseek-ai/dsh-agent' import type { Session } from '@deepseek-ai/dsh-session' import type { PresentedFile } from './types.ts' @@ -76,21 +76,21 @@ export function apply(ctx: Context, config: Config): void { render: (_args, value) => [{ type: 'text', text: value.files.map(file => `Presented ${file.path}`).join('\n') }], }, async execute(args, exec) { - if (exec.agent === undefined) throw new Error('present requires an agent Session') + if (exec.agent === undefined) throw new ToolContextUnavailableError('present requires an agent Session') const boundary = ctx.agents.turnBoundaryOf(exec.agent.session) - if (boundary.openTurnStartSeq === null) throw new Error('present requires an open turn') - if (args.files.length === 0 || args.files.length > config.maxFiles) throw new Error(`present accepts 1 to ${config.maxFiles} files`) + if (boundary.openTurnStartSeq === null) throw new ToolContextUnavailableError('present requires an open turn') + if (args.files.length === 0 || args.files.length > config.maxFiles) throw new ToolArgsError([`present accepts 1 to ${config.maxFiles} files`]) const cwd = await ctx.workingDirectory.ensure(exec.agent, exec.signal) const options = { cwd, signal: exec.signal } const files: PresentedFile[] = [] for (const file of args.files) { - if (file.path.trim().length === 0) throw new Error('present requires a non-empty file path') + if (file.path.trim().length === 0) throw new ToolArgsError(['present requires a non-empty file path']) const entry = await ctx.fs.lstat(file.path, { cwd }, exec.signal) - if (entry !== undefined && entry.type !== 'file') throw new Error(`Cannot present ${file.path}: not a regular file`) + if (entry !== undefined && entry.type !== 'file') throw new FsError(`Cannot present ${file.path}: not a regular file`, 'FS_NOT_REGULAR_FILE') const target = await ctx.fs.resolve(file.path, options) const info = await ctx.fs.stat(target, exec.signal) if (info === undefined) throw new FsError(`Cannot present ${file.path}: file not found. Check the path, create the file if needed, and retry.`, 'FS_NOT_FOUND') - if (info.type !== 'file') throw new Error(`Cannot present ${file.path}: not a regular file`) + if (info.type !== 'file') throw new FsError(`Cannot present ${file.path}: not a regular file`, 'FS_NOT_REGULAR_FILE') files.push({ ...file, path: ctx.fs.processPath(target) }) } exec.signal.throwIfAborted() diff --git a/packages/deliverables/tool-present/tests/present.spec.ts b/packages/deliverables/tool-present/tests/present.spec.ts index 18da272027..6492740c99 100644 --- a/packages/deliverables/tool-present/tests/present.spec.ts +++ b/packages/deliverables/tool-present/tests/present.spec.ts @@ -140,9 +140,19 @@ describe('present', () => { const { root, owner, execute } = await setup() await writeFile(join(root, 'large'), 'four') await symlink(tmpdir(), join(root, 'outside')) - for (const files of [[], [{ path: '' }], [{ path: 'missing' }], [{ path: '.' }], [{ path: 'outside' }], [{ path: 'large' }, { path: 'large' }, { path: 'large' }]]) { + const invalidArgs = { name: 'ToolArgsError', code: 'INVALID_ARGS' } + const notRegular = { name: 'FsError', code: 'FS_NOT_REGULAR_FILE' } + for (const [files, info] of [ + [[], invalidArgs], + [[{ path: '' }], invalidArgs], + [[{ path: 'missing' }], { name: 'FsError', code: 'FS_NOT_FOUND' }], + [[{ path: '.' }], notRegular], + [[{ path: 'outside' }], notRegular], + [[{ path: 'large' }, { path: 'large' }, { path: 'large' }], invalidArgs], + ] as const) { const result = await execute(files) expect(result.isError, JSON.stringify(files)).toBe(true) + expect(result.error?.info, JSON.stringify(files)).toEqual(info) } expect(owner.session.snapshotEvents().some(event => event.type === 'deliverables/presented')).toBe(false) }) @@ -163,9 +173,13 @@ it('validates deployment limits before registering the tool', async () => { it('requires an agent, an open turn, and a workspace', async () => { const { ctx, owner, execute } = await setup() const detached = await executeToolFixture(ctx, { signal: new AbortController().signal, callId: ToolCallId('detached'), name: 'present', arguments: { files: [{ path: 'a' }] } }) + const contextUnavailable = { name: 'ToolContextUnavailableError', code: 'TOOL_CONTEXT_UNAVAILABLE' } expect(detached.isError).toBe(true) + expect(detached.error?.info).toEqual(contextUnavailable) owner.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - expect((await execute([{ path: 'a' }])).isError).toBe(true) + const closed = await execute([{ path: 'a' }]) + expect(closed.isError).toBe(true) + expect(closed.error?.info).toEqual(contextUnavailable) const noWorkspace = await agent(ctx, undefined) noWorkspace.session.append('turn/start', { turn: 1 }) const absent = await executeToolFixture(ctx, { signal: new AbortController().signal, callId: ToolCallId('no-workspace'), name: 'present', arguments: { files: [{ path: 'a' }] }, agent: noWorkspace }) @@ -188,7 +202,9 @@ it('refuses a final symlink to an ordinary file', async () => { const { root, execute } = await setup() await writeFile(join(root, 'source'), 'source') await symlink(join(root, 'source'), join(root, 'link')) - expect((await execute([{ path: 'link' }])).isError).toBe(true) + const result = await execute([{ path: 'link' }]) + expect(result.isError).toBe(true) + expect(result.error?.info).toEqual({ name: 'FsError', code: 'FS_NOT_REGULAR_FILE' }) }) @@ -197,5 +213,7 @@ it('refuses a file replaced by a directory after inspecting its final component' await writeFile(join(root, 'source'), 'source') const directory = await ctx.fs.stat(await ctx.fs.resolve(root)) vi.spyOn(ctx.fs, 'stat').mockResolvedValueOnce(directory) - expect((await execute([{ path: 'source' }])).isError).toBe(true) + const result = await execute([{ path: 'source' }]) + expect(result.isError).toBe(true) + expect(result.error?.info).toEqual({ name: 'FsError', code: 'FS_NOT_REGULAR_FILE' }) }) diff --git a/packages/experimental/client-ui-forge/README.i18n.yaml b/packages/experimental/client-ui-forge/README.i18n.yaml index c85baa23fe..3affdf7c58 100644 --- a/packages/experimental/client-ui-forge/README.i18n.yaml +++ b/packages/experimental/client-ui-forge/README.i18n.yaml @@ -15,23 +15,23 @@ en: d152484eb41ac6b4 zh: 2f8a621b991260a1 /deepseek-ai-dsh-experimental-client-ui-forge/use-this-package: - en: c7e1b7d87ccccd2e - zh: 42d864b918a6175c + en: 78e5f4f2b3faeb7a + zh: 6d0e381fc385d6c7 /deepseek-ai-dsh-experimental-client-ui-forge/understand-the-implementation: - en: 7495fe1e0d0ee643 - zh: f820a5790ae6e2a7 + en: 0feae5f8c5ef559f + zh: 5334adff35e3bebd /deepseek-ai-dsh-experimental-client-ui-forge/further-exploration: en: fa4d8c579ffc49ff zh: 5856ed7469e4585b /deepseek-ai-dsh-experimental-client-ui-forge/model-experience: - en: 10101427d1e0589b - zh: c981cec12f64eb75 + en: 795766e162ebf4df + zh: 893de3302798330a /deepseek-ai-dsh-experimental-client-ui-forge/model-experience/kv-cache-effect: en: 2bc25a99e17789c4 zh: 4bec723fbf249d28 /deepseek-ai-dsh-experimental-client-ui-forge/known-limitations-and-deferred-work: - en: 5e29ac36aad15d02 - zh: e46339bb637a69ef + en: da0e90e368524e68 + zh: 1763d9c607926013 /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 index 3986c9bffc..8c24bf2687 100644 --- a/packages/experimental/client-ui-forge/README.md +++ b/packages/experimental/client-ui-forge/README.md @@ -41,14 +41,15 @@ Panel text follows the application language: Chinese, English, Japanese, Spanish - **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. The local tab is named Local changes and shows Branch by default: the working tree and untracked files against the fork point. Its header names the checked-out branch, or the abbreviated commit on a detached HEAD, and the base branch, which opens a searchable menu: first Default, the base commit of the worktree the Session created, else the merge base of HEAD with the default branch of the code host remote, then remote-tracking and then local branches, each most recently committed first, without the checked-out branch and, outside a created worktree, without the remote-tracking branch Default already names. Typing filters the listed rows at once and lists the branches whose names contain the text, ignoring case, after a short pause; Enter chooses the first row. Choosing another branch compares with the merge base of HEAD and that branch. The tab keeps the choice while other scopes show; closing the tab or reloading the application returns it to Default. When the chosen branch is gone, its name is struck through, and when it shares no history with HEAD, the view says so as well; both offer Use the default base branch instead of Retry. A narrow view hides the note that uncommitted changes count, then the checked-out branch. Uncommitted compares them with HEAD, Unstaged with the index, and Staged compares the index with HEAD. A change request view shows the host's diff at its head and says how many commits the checked-out branch has beyond that head when it is the change request's head branch. The toolbar jumps to a file, totals the changed lines, refreshes, and switches between two columns and one and between wrapped and cut long lines. Visited changes tabs stay mounted across tab and Session switches, hiding the sidebar, and docking changes, preserving file folds, revealed large files, expanded context, layout, and scroll position while the diff is unchanged. Hidden changes views release their panel subscription and defer diff reads; returning reads again only when the latest head or local summary differs. A refresh that replaces the diff discards expanded context, and changed content may move the reading position. Temporary menus close when the view is hidden. Closing the tab or reloading the application discards this progress. Each file is a separate card, with a tinted header that stays at the top while its lines scroll. 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. When the header is narrow, the path, the badges, and Changed since viewed shorten, so the line counts, Viewed, and the menu stay whole. 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. When the head moved past the latest status, the view keeps loading and reads again at the head of a status later than the one the read started with, at once when that status arrived during the read. 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 whose output exceeds the provider's `maxOutputBytes`, 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. - **Review layers, test files, viewed files, and commits** — Above the file tree, the lens keeps All files or the files of one layer, Interface, Implementation, or Tests, each with its count; where no tree shows, the toolbar offers the lens as a menu. The repository decides first: a file whose root `.gitattributes` sets `dsh-review-layer=test`, `interface`, or `implementation` is in that layer, and one it marks `linguist-generated` is in Tests. Next come the optional `testPaths` and `interfacePaths` globs, which add to the built-in rules and are empty by default. The built-in rules then follow common conventions: test directories and files of Jest, Vitest, `go test`, pytest, RSpec, XCTest, and .NET, snapshots, locale catalogs, and lockfiles are Tests; package manifests, type declarations, headers, stubs, and interface definitions such as Protocol Buffers, GraphQL, and OpenAPI are Interface. Every other file is in Implementation. For example, `*_test.go dsh-review-layer=test` or `api/**/*.yaml dsh-review-layer=interface` in `.gitattributes` records a repository's own convention. Hide test files leaves Tests out of the file tree and the diff without listing them, and disables the Tests lens. Viewed on a file header marks that change of the file viewed and collapses it; the toolbar shows how many of the shown files are viewed, and `V` marks or clears the file being read: the file holding the focus or the text cursor, else the file at the top of the view. A file whose change differs from the change it was marked at shows Changed since viewed and expands again. Marks are kept in this browser per Session, source, path, and change digest, so they survive closing the tab and reloading, and the tabs of one Session share them: a mark in one tab appears in the others and keeps their marks. A change of the file mode alone, such as `chmod +x`, also counts as a new change. All commits beside Submit review, and beside the base branch in the Branch scope, lists the commits oldest first, read once until the head, the local branch or base, or a refresh changes them; a click shows one commit and a Shift click the commits from the last one clicked, which must follow each other as first parents: a run that leaves that line, such as one across a merged branch, keeps the menu open and says why; the view then shows those commits' changes, reads unchanged lines at the last of them, and offers Add to input but no GitHub comment or review. Branch commits exclude uncommitted and untracked files. Another base or scope returns to all commits, and chosen commits that are gone or no longer one run offer Show all commits. +- **Review threads** — A change request view overlays the review threads its code host read at the shown head. A thread on lines follows the row of its last line, on the side it was written on, or the collapsed unchanged lines that hold it; it shows the place it was written, each comment's avatar, author, time, and Markdown with raw HTML left out, and tags for the change request's author, bots, and comments still pending in the signed-in user's review. A resolved thread folds into one row that unfolds on request. Threads on a whole file open the file, and threads later commits made outdated gather after its lines in a section that shows the end of the hunk they were written on and offers View file at that commit, which reads the file at the commit they were written on with their lines marked. Chosen commits show only new-side threads whose lines number the last chosen commit. The file tree and each file header count unresolved threads. Each click on the count in a header goes to the file's next unresolved thread in the order the file shows them, threads the chosen commits do not hold last, and from the last back to the first; the Threads toolbar button opens the thread list, which groups the threads by file in file tree order and orders each file's threads the same way, shows the Unresolved, Resolved, or All threads, Unresolved first, and opens each thread at its place, choosing all commits for one the chosen commits do not hold. The list docks beside the files in a view at least 1,040px wide, floats over them in a narrower one and closes before it opens a thread there, and takes their whole width below 480px. The list names a truncated read. A thread offers Add to input, which quotes the whole thread, View on GitHub, and the writes the signed-in user may make. Without a pending review, its reply field replies at once, and its menu starts a pending review with the reply; with one, the field adds the reply to it, as GitHub adds every reply of the user to the pending review. Resolve thread and Unresolve thread change its state, and the menu of the user's own comment edits or deletes it after asking, saying when deleting the only comment deletes the thread. A resolved thread stays open while the user writes a reply or an edit in it. A write the code host refuses keeps the text and says why; a write whose outcome is unknown reads the threads again and asks the user to check them before trying again. Every write reads the threads again, since resolving a thread does not change a GitHub pull request's update time. Threads are read again at each status while the view shows, and from the code host after Refresh even while the panel keeps an earlier read; a failed read keeps the last threads and offers Retry, and a provider that does not read reviews shows none. The checks row initially shows the polling preview. While it is incomplete, the card uses the host’s aggregate outcome without a failure count and labels the loaded check count as partial. Opening the list loads the remaining pages for that PR through `changeRequestChecks`; its deadline is independent of polling. A failed read keeps the preview and offers Retry. Closing the list, changing the head, or unmounting cancels the read; late results are ignored. Reopening reads current details. The Remote requires a visible Session binding and rejects a response for a different head commit. The diff follows the file tree’s depth-first order: directories before files, with each group sorted by displayed name in case-sensitive UTF-16 code-unit order. Compacted directory chains are compared as one complete name. Filtering or folding the tree does not reorder the diff. Click the diff content to focus it. Left and Right move to the previous and next file in diff order and expand it; navigation stops at the first and last file. Up and Down keep their normal scrolling behavior. After manual scrolling, file navigation starts from the file at the top of the viewport. Text editing, non-collapsed text selections, modified keys, menus, and file-tree controls keep their own keyboard behavior. -`gitCommand`, `gitTimeoutMs`, `graceMs`, `maxOutputBytes`, `readinessTimeoutMs`, `maxBranches`, `worktreePrefix`, `maxDiffBytes`, `maxUntrackedFiles`, `maxCachedDiffBytes`, `maxCommits`, `testPaths`, and `interfacePaths` are listed in the [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-experimental-client-ui-forge). +`gitCommand`, `gitTimeoutMs`, `graceMs`, `maxOutputBytes`, `readinessTimeoutMs`, `maxBranches`, `worktreePrefix`, `maxDiffBytes`, `maxUntrackedFiles`, `maxCachedDiffBytes`, `maxCachedReviewBytes`, `reviewRefreshMs`, `maxCommits`, `testPaths`, and `interfacePaths` are listed in the [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-experimental-client-ui-forge). -All files opens a searchable directory tree with the selected file scrolled to its middle. Single-child directories share a row, and renamed files also match their old path. A wide changes pane shows the same tree beside the diff; its File tree toolbar button cycles through left, right, and hidden, starting on the left. Its accessible name describes the next action, and focus stays on the button when it moves or hides the tree. The search, folds, selection, and width survive moving or hiding the tree and switching tabs or Sessions. Drag the divider or use its arrow keys to resize the tree. Files and folders reserve the same disclosure column so each child is indented from its parent. Local changes and each PR retain separate tabs. Select code text or use line numbers to select a range within one displayed hunk and side; text selection and copying stay on the starting side. The plus button opens the plain-text comment editor. With a mouse, hovering a line also shows a plus beside its line number, which stays clickable, on the hovered side in two columns and on the line's own side in one column; pressing it selects that line, dragging extends the range on that side, and releasing opens the editor. It accepts newlines and IME composition; only its action buttons submit the comment. Drag its lower edge to adjust the height. Add to input inserts a removable quote into the main composer that shows the file name, side and lines, and comment, and on hover the file path; multiple quotes are sent together only when the user sends that composer. The GitHub split button starts or adds to a pending review; its menu publishes a single comment immediately. Comments remain beside their code after quoting, queueing, or publishing. Pending comments are edited or removed there. Request changes requires a review summary; the dialog explains this beside the summary field and announces the requirement when the decision changes. Submit review in the PR title bar opens a centered dialog showing only the pending count, summary, and Comment, Approve, or Request changes. Added and deleted files show only their populated line-number column. Pending reviews and comment records are stored in this browser per Session and tab, including the original PR head commit. Switching Sessions or hiding the pane retains them; closing the tab clears its comments, summary, and decision, and reopening starts an empty review. Published GitHub comments and references already added to the main composer are unaffected. Records whose code changed remain visible below their file, or after the diff when the file is absent. A changed head requires fresh line selections. While the tab remains open, an uncertain write stays blocked across reloads until the user checks GitHub and explicitly permits another submission. +All files opens a searchable directory tree with the selected file scrolled to its middle. Single-child directories share a row, and renamed files also match their old path. A wide changes pane shows the same tree beside the diff; its File tree toolbar button cycles through left, right, and hidden, starting on the left. Its accessible name describes the next action, and focus stays on the button when it moves or hides the tree. The search, folds, selection, and width survive moving or hiding the tree and switching tabs or Sessions. Drag the divider or use its arrow keys to resize the tree. Files and folders reserve the same disclosure column so each child is indented from its parent. Local changes and each PR retain separate tabs. Select code text or use line numbers to select a range within one displayed hunk and side; text selection and copying stay on the starting side. The plus button opens the plain-text comment editor. With a mouse, hovering a line also shows a plus beside its line number, which stays clickable, on the hovered side in two columns and on the line's own side in one column; pressing it selects that line, dragging extends the range on that side, and releasing opens the editor. It accepts newlines and IME composition; only its action buttons submit the comment. Drag its lower edge to adjust the height. Add to input inserts a removable quote into the main composer that shows the file name, side and lines, and comment, and on hover the file path; multiple quotes are sent together only when the user sends that composer. Once the review threads are read and the user may review, the GitHub split button starts the user's pending review on GitHub with the comment, or adds the comment to it; the comment then shows as a thread tagged Pending, which only the user sees until the review is submitted. Its menu publishes a single comment immediately, which GitHub refuses while the user has a pending review, so the menu is then absent. Agent references and published comments remain beside their code. Request changes requires a review summary; the dialog explains this beside the summary field and announces the requirement when the decision changes. Submit review in the PR title bar opens a centered dialog showing only the pending count, summary, and Comment, Approve, or Request changes. With a pending review, it submits that review, keeping a submission whose outcome is unknown blocked until the user checks GitHub, as a direct submission is, and Discard review deletes the review and its comments after a confirmation; without one, it publishes the summary as one review. On the user's own pull request, which GitHub lets its author only comment on, Approve and Request changes are unavailable. Added and deleted files show only their populated line-number column. The summary, decision, and records of Agent references and published comments are stored in this browser per Session and tab, including the original PR head commit. Switching Sessions or hiding the pane retains them; closing the tab clears them, and reopening starts an empty summary. The pending review, published GitHub comments, and references already added to the main composer are unaffected. Comment drafts an earlier version kept in this browser stay beside their code tagged Browser draft and can be edited, deleted, or added to the pending review; they keep Submit review disabled while a pending review exists, and without one Submit review publishes them with the summary. Records whose code changed remain visible below their file, or after the diff when the file is absent. A changed head requires fresh line selections. While the tab remains open, an uncertain write stays blocked across reloads until the user checks GitHub and explicitly permits another submission. ----- @@ -58,7 +59,7 @@ All files opens a searchable directory tree with the selected file scrolled to i
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. +`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` only when the state differs from the published one, so a reinspection that finds the same checkout reaches no follower. 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=`; `diffBases` finds the default fork point as `localDiff` does below, runs `for-each-ref --sort=-committerdate` over `refs/heads/` and `refs/remotes/` within `maxDiffBytes`, leaves out symbolic refs such as `origin/HEAD`, and keeps at most `maxBranches` branches of each kind whose names contain the query. Inspection then sizes the `branch` scope for the local changes card: it finds the fork point, runs `diff --numstat -z -M` against it with the same helper and submodule restrictions as `localDiff` below, and lists untracked files. Every command uses the Session's resolved sandbox mode: `read-only` and `workspace-write` require confinement, while `danger-full-access` runs without a sandbox backend. Commands disable optional index writes, hooks, the filesystem monitor, prompts, and lazy object fetches, remove inherited Git selectors and secret-bearing variables, and use their 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. @@ -69,11 +70,11 @@ Restoration records `restoring` before mutation and `restored` only after succes 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`. A `branch` scope that names a base ref under `refs/heads/` or `refs/remotes/` instead takes `merge-base HEAD` with it once `show-ref --verify` finds the ref, refusing with `forgePanel/base-missing` when the ref does not exist and with `forgePanel/no-fork-point` when HEAD shares no history with it; another ref name is refused before Git runs. It runs `diff --no-ext-diff --no-textconv --submodule=short --ignore-submodules=dirty --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. Before patches and statistics, Git configuration is read to apply command-local clean/process filter overrides. A failed or truncated configuration read stops the comparison: `localDiff` reports `forgePanel/filter-config-failed`, with a retry after correcting Git configuration or the output limit; failed statistics leave the local changes card hidden. Filter enumeration uses `maxDiffBytes` for patches and `maxOutputBytes` for statistics. 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. It gives each file a digest of its paths, status, hunks, and `old mode` and `new mode` lines, plus the blob names of a binary change or the content of an untracked binary file, and a layer from the `dsh-review-layer` and `linguist-generated` lines of the root `.gitattributes`, later lines overriding earlier ones and a value other than a layer name leaving the layer unspecified, with patterns matched by Git's wildmatch rules in [`src/wildmatch.ts`](src/wildmatch.ts) and negated patterns skipped as Git skips them, then from picomatch matches, with dot files included, of `testPaths`, `interfacePaths`, and the built-in rules in [`src/layers.ts`](src/layers.ts), with `.gitattributes` read from the index for Staged, the working tree for the other local scopes, the last chosen commit for chosen commits, and the head or last chosen commit through `fileText` for a change request, where a missing file reads as none. `localCommits` lists the commits between the fork point and HEAD with `log --no-show-signature --topo-order --reverse`, the newest `maxCommits` of them, so the list ends with HEAD's commit. A Branch scope with chosen commits compares the first one's first parent, or the empty tree for a root commit, with the last, refusing commits that are not full object names, not listed, or out of order with `forgePanel/commits-missing`, and a chosen commit whose first parent is not the commit listed before it with `forgePanel/commits-not-linear`; `localText` reads that last commit through `show`. `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`, a `stale` failure, meaning the head moved past that status, with `forgePanel/stale-head`, and refuse with `forgePanel/diff-failed` before a status was read or when the host refuses otherwise. A `stale` diff polls the change request at once, once per head commit, and is not cached, so no patch is ever kept under a head it does not describe. The Host keeps the patches `changeRequestDiff` read with their attributes, 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. `changeRequestCommits` returns the provider's `commits` list, or null when the provider does not read commits; `changeRequestDiff` with chosen commits finds them in that list, refuses them as the Branch scope does, and reads `compare` from the first one's parent to the last, uncached. 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. +`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`. A `branch` scope that names a base ref under `refs/heads/` or `refs/remotes/` instead takes `merge-base HEAD` with it once `show-ref --verify` finds the ref, refusing with `forgePanel/base-missing` when the ref does not exist and with `forgePanel/no-fork-point` when HEAD shares no history with it; another ref name is refused before Git runs. It runs `diff --no-ext-diff --no-textconv --submodule=short --ignore-submodules=dirty --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. Before patches and statistics, Git configuration is read to apply command-local clean/process filter overrides. A failed or truncated configuration read stops the comparison: `localDiff` reports `forgePanel/filter-config-failed`, with a retry after correcting Git configuration or the output limit; failed statistics leave the local changes card hidden. Filter enumeration uses `maxDiffBytes` for patches and `maxOutputBytes` for statistics. 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. It gives each file a digest of its paths, status, hunks, and `old mode` and `new mode` lines, plus the blob names of a binary change or the content of an untracked binary file, and a layer from the `dsh-review-layer` and `linguist-generated` lines of the root `.gitattributes`, later lines overriding earlier ones and a value other than a layer name leaving the layer unspecified, with patterns matched by Git's wildmatch rules in [`src/wildmatch.ts`](src/wildmatch.ts) and negated patterns skipped as Git skips them, then from picomatch matches, with dot files included, of `testPaths`, `interfacePaths`, and the built-in rules in [`src/layers.ts`](src/layers.ts), with `.gitattributes` read from the index for Staged, the working tree for the other local scopes, the last chosen commit for chosen commits, and the head or last chosen commit through `fileText` for a change request, where a missing file reads as none. `localCommits` lists the commits between the fork point and HEAD with `log --no-show-signature --topo-order --reverse`, the newest `maxCommits` of them, so the list ends with HEAD's commit. A Branch scope with chosen commits compares the first one's first parent, or the empty tree for a root commit, with the last, refusing commits that are not full object names, not listed, or out of order with `forgePanel/commits-missing`, and a chosen commit whose first parent is not the commit listed before it with `forgePanel/commits-not-linear`; `localText` reads that last commit through `show`. `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`, a patch together with its `.gitattributes`, where a failed patch read cancels the attributes read and both reads end before the result, the patch's failure passing on before the attributes failure, refuse a `too-large` failure with `forgePanel/diff-too-large`, a `stale` failure, meaning the head moved past that status, with `forgePanel/stale-head`, and refuse with `forgePanel/diff-failed` before a status was read or when the host refuses otherwise. A `stale` diff polls the change request at once, once per head commit, and is not cached, so no patch is ever kept under a head it does not describe. The Host keeps the patches `changeRequestDiff` read with their attributes, 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. `changeRequestCommits` returns the provider's `commits` list, or null when the provider does not read commits; `changeRequestDiff` with chosen commits finds them in that list, refuses them as the Branch scope does, and reads `compare` from the first one's parent to the last, uncached. 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. `changeRequestReview` acts on the same visible bindings, refuses a head other than the latest status's with `forgePanel/stale-head` before reading, and reads the provider's `readReview`, mapping `stale` to `forgePanel/stale-head`, `unsupported` to `forgePanel/review-unsupported`, and other failures to `forgePanel/review-failed`. It keeps one read per change request, keyed by head commit and update time, drops the least recently read once the kept reads exceed `maxCachedReviewBytes` as JSON, and reads again once a kept read is `reviewRefreshMs` old or the reader refreshes the view, since resolving a GitHub thread leaves the update time unchanged. `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, then close the run’s storage domain. A later run waits for that closure before reopening the domain and reading its stored states. 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. The tab type enables `keepMounted`, retaining visited bodies and their owning Session references until their tab records end. Its keyed panel hook subscribes only for the visible body; other panel surfaces may keep the shared Session stream open. Its body keeps the scope, base branch, chosen commits, lens, test file switch, and layout choices of each tab in a slot store, keeps viewed marks per Session in `localStorage` under `dsh.forge-viewed.v1.`, at most 20 sources per Session and 50 Sessions, reads through the Remote, and highlights lines with the shared code highlighter; a chip title shows its name with the changes icon. +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. The tab type enables `keepMounted`, retaining visited bodies and their owning Session references until their tab records end. Its keyed panel hook subscribes only for the visible body; other panel surfaces may keep the shared Session stream open. Its body keeps the scope, base branch, chosen commits, lens, test file switch, and layout choices of each tab in a slot store, keeps viewed marks per Session in `localStorage` under `dsh.forge-viewed.v1.`, at most 20 sources per Session and 50 Sessions, reads through the Remote, and passes each file section values that keep their identity across its renders, including the tab's visibility, so a render redraws only the sections whose values changed and a visibility change redraws every section. An expanded file draws its lines, highlighted with the shared code highlighter in the same render, once its section first comes within 1200px of the scrollport or a jump to one of its review threads targets it, keeps them drawn after that, and until then holds a 22px row per drawn line; a chip title shows its name with the changes icon. | Source | Responsibility | |---|---| @@ -92,9 +93,15 @@ The browser entry mounts the Remote, then the UI. One stream per Session feeds t | [`src/client/ChangesTab.tsx`](src/client/ChangesTab.tsx) | Changes view | | [`src/client/BaseMenu.tsx`](src/client/BaseMenu.tsx) | Base branch menu of the Branch scope | | [`src/client/DiffFile.tsx`](src/client/DiffFile.tsx) | One file of the changes view | +| [`src/client/section-reach.ts`](src/client/section-reach.ts) | When file sections draw their lines | | [`src/client/ReviewControls.tsx`](src/client/ReviewControls.tsx) | Lens, test file switch, and viewed progress | | [`src/client/CommitMenu.tsx`](src/client/CommitMenu.tsx) | Commit picker | | [`src/client/viewed-files.ts`](src/client/viewed-files.ts) | Viewed marks kept in the browser | +| [`src/client/ReviewThreads.tsx`](src/client/ReviewThreads.tsx) | Review thread cards and the outdated section | +| [`src/client/ThreadList.tsx`](src/client/ThreadList.tsx) | Thread list | +| [`src/client/review-threads.ts`](src/client/review-threads.ts) | Thread places, list filters, hunk excerpts, and thread quotes | +| [`src/client/thread-state.ts`](src/client/thread-state.ts) | Thread reads and their places in the shown changes | +| [`src/review-cache.ts`](src/review-cache.ts) | Review threads kept in memory | | [`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 | @@ -117,7 +124,7 @@ The browser entry mounts the Remote, then the UI. One stream per Session feeds t ## 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. Add to input contributes a frozen `## User Comment N` quote containing File, Side, Lines, Source, the diff hunk, and Comment; PR quotes also name the reviewed commit, local Branch quotes the base the view compared with, and quotes from chosen commits name them. These quotes enter the normal logged user message only when the composer is sent. Inspection, status, restoring, the Session list, and GitHub review submission add no model context. +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. Add to input contributes a frozen `## User Comment N` quote containing File, Side, Lines, Source, the diff hunk, and Comment; PR quotes also name the reviewed commit, local Branch quotes the base the view compared with, and quotes from chosen commits name them. These quotes enter the normal logged user message only when the composer is sent. Inspection, status, restoring, the Session list, and GitHub review submission add no model context. Add to input on a review thread contributes a frozen `## Review Thread N` quote with File, Side, Lines, the commit those lines number, State, Source, the recorded diff hunk, and every comment's author, time, and body quoted line by line. #### KV Cache effect @@ -139,7 +146,10 @@ None for a new worktree, as its notice belongs to the first request of the Sessi - **Changes scopes** — deferred: a scope for the last turn. The Branch scope's Default needs a remote whose default branch HEAD shares history with, or a worktree the Session created; otherwise a base branch must be chosen. - **Very many branches** — the base branch menu reads every ref before it filters, so a search can reach any branch. Refs over `maxDiffBytes`, about 165,000 at the default, or a listing over `gitTimeoutMs`, leave the menu saying that branches could not be read; Default keeps working. - **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. +- **Undrawn files** — until the reader scrolls near an expanded file, it holds 22 px per drawn line, which leaves out side-by-side pairing, wrapped lines, unchanged-line rows, and review cards, so the scroll range shifts a little as files draw; its saved review comments also appear only once it draws. +- **Find in page over long diffs** — an expanded file draws its lines only once the reader scrolls near it, so the browser's find in page does not match lines of files further down until they are drawn; a page-wide search of the diff needs a find control of the view's own. - **Viewed files on the code host** — viewed marks of a change request stay in this browser and are not read from or written to GitHub's viewed state (`markFileAsViewed`) until the provider's review operations exist; another browser or device starts without them. +- **Review thread writes** — a write through one changes view shows in other open views of the change request at their next status, or after `reviewRefreshMs` for a resolve, which does not change the pull request's update time. GitHub keeps one pending review per user and pull request. A comment draft an earlier version kept in the browser can join the pending review only while its file and lines still match the shown diff; otherwise it can only be edited or deleted. A comment on chosen commits can only be added to the input. Minimized comments show like others, and avatars load from the code host's image server in the browser. - **Review layers** — the built-in rules cover common conventions only, so a repository records others in `.gitattributes`; a path cannot show Rust's inline `#[cfg(test)]` modules or which declarations a language exports, and the Interface layer keeps whole files, so finding changed exports needs a language analyzer that a later lens extension point can supply. `linguist-generated` is read only from the root `.gitattributes`, without nested `.gitattributes` files or attribute macros. - **Chosen commits** — a change request lists at most `maxCommits` commits of the provider, the oldest 250 for GitHub, while the Branch scope lists its newest `maxCommits`; a run must follow first parents, so commits of a merged branch can be shown one at a time but not together with the branch's own; a commit range is read from the host on every view; comments on chosen commits can only be added to the input, as a GitHub review addresses the head. - **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. diff --git a/packages/experimental/client-ui-forge/README.zh.md b/packages/experimental/client-ui-forge/README.zh.md index d5bdf45e9d..6960bca568 100644 --- a/packages/experimental/client-ui-forge/README.zh.md +++ b/packages/experimental/client-ui-forge/README.zh.md @@ -41,14 +41,15 @@ kind: "package-reference" - **变更视图**——展开的变更请求卡片上的“变更”一行与本地变更卡片会打开一个右侧边栏标签页,列出所有变更的文件。本地标签页名为“本地变更”,默认显示“分支”:工作树与未跟踪文件相对于分叉点的变更。它的标题栏写出当前检出的分支(分离 HEAD 时为缩写提交)与基准分支;点击基准分支打开可搜索的菜单:最前面是“默认”,即 Session 创建的 worktree 的基准提交,否则是 HEAD 与代码托管远端默认分支的合并基准;其后依次是远程跟踪分支与本地分支,各按最近提交排序,不含当前检出的分支,不在所创建的 worktree 中时也不含“默认”已经指向的远程跟踪分支。输入文字会立即筛选已列出的行,短暂停顿后列出名称包含该文字(不区分大小写)的分支;按 Enter 选择第一行。选择其他分支后,与 HEAD 和该分支的合并基准比较。标签页切换到其他范围时仍保留这个选择;关闭标签页或重新加载应用后回到“默认”。所选分支已不存在时,它的名称显示删除线;它与 HEAD 没有共同历史时,视图同样给出说明;两种情况都提供“使用默认基准分支”而不是“重试”。视图较窄时,先隐藏“包含未提交的修改”,再隐藏当前分支。“未提交”与 HEAD 比较,“未暂存”与暂存区比较,“已暂存”比较暂存区与 HEAD。变更请求的视图显示托管平台在其 head 处的 diff;当前检出分支就是该变更请求的源分支时,还会说明本地分支比该 head 多出几个提交。工具栏可以跳到某个文件、汇总变更行数、刷新,并在左右对比与单栏对比、自动换行与截断长行之间切换。访问过的变更标签页会在切换标签页与 Session、隐藏侧边栏及调整停靠位置时保持挂载,在 diff 未改变时保留文件折叠、大文件展开、上下文展开、布局与滚动位置。隐藏的变更视图释放面板订阅并推迟 diff 读取;返回时仅在最新 head 或本地摘要不同时重新读取。刷新替换 diff 后会丢弃展开的上下文,内容变化也可能移动阅读位置。视图隐藏时关闭临时菜单。关闭标签页或重新加载应用后不保留这些进度。每个文件是一张独立的卡片,带浅色底的标题在滚动时停在顶部。文件名在前,目录在后,用更小的浅色字显示,空间不够时目录先缩短;悬停时显示完整路径,重命名的文件显示新旧两条路径。每个文件带有新文件、已删除、重命名的标记(重命名标记写出原文件名,只换了目录时写出原路径),“分支”范围下的未跟踪文件还标“未提交”;文件菜单可以复制路径,本地变更可在侧边栏打开整个文件,变更请求可在浏览器中查看该文件在 head 处的页面。标题变窄时,路径、标记和“有新改动”会缩短,行数、“已审阅”和文件菜单始终完整显示。hunk 之间未变更的行折叠成一行,点击后从文件的新版本展开:工作树、“已暂存”时为暂存区,或经托管平台读取的 head 提交;读取 diff 之后文件又有修改时不会展开,直到视图重新读取。二进制文件与超过 `maxDiffBytes` 的未跟踪文件只显示一条说明;hunk 超过 1,000 行的文件先显示为一条写出变更行数的折叠条,点击后才绘制;前面的文件累计变更超过 5,000 行后,其后的文件默认收起。刷新与页面刷新快捷键、每次导航、变更请求的 head 变化,以及“分支”范围的摘要变化时,视图都会重新读取;变更请求的视图会先等到给出 head 的状态再读取,除非没有提供方服务它或那次轮询失败。head 已越过最新状态时,视图保持加载,等到比读取开始时更新的状态后按其 head 重新读取;该状态在读取期间已到达时立即重读。重新读取已显示的变更时,刷新图标会旋转。托管平台或其提供方因超过大小上限拒绝给出 diff 的变更请求(例如 GitHub 上输出超过提供方 `maxOutputBytes` 的 diff)显示“变更太大,无法在这里显示”并提供“在浏览器中查看”;其他读取失败提供“重试”,变更请求还提供“在浏览器中查看”。变更请求的 diff 在缓存期间对每个 head 提交与目标分支只下载一次,因此在同一 head 下重新打开、刷新与导航都不会再从托管平台读取。 - **分层、测试文件、已审阅与提交**——文件树上方的分层透镜保留全部文件,或“接口”“实现”“测试”中一层的文件,并显示各自的数量;不显示文件树时,工具栏以菜单提供透镜。仓库的声明优先:根目录 `.gitattributes` 把文件设为 `dsh-review-layer=test`、`interface` 或 `implementation` 时,文件属于对应的层;标为 `linguist-generated` 的文件属于“测试”。其次是可选的 `testPaths` 与 `interfacePaths`,它们追加在内置规则之前,默认为空。最后是按常见约定写的内置规则:Jest、Vitest、`go test`、pytest、RSpec、XCTest 与 .NET 的测试目录和文件、快照、本地化目录与 lockfile 属于“测试”;包清单、类型声明、头文件、类型存根与 Protocol Buffers、GraphQL、OpenAPI 等接口定义属于“接口”。其余文件属于“实现”。例如在 `.gitattributes` 中写 `*_test.go dsh-review-layer=test` 或 `api/**/*.yaml dsh-review-layer=interface`,即可记录仓库自己的约定。“隐藏测试文件”让文件树与 diff 都不显示“测试”层,也不列出被隐藏的文件,同时停用“测试”透镜。文件标题上的“已审阅”把该文件的这次改动标为已审阅并折叠它;工具栏显示已显示文件中已审阅的数量,按 `V` 标记或取消当前阅读的文件:焦点或文本光标所在的文件,否则是视图顶部的文件。改动与标记时不同的文件显示“有新改动”并重新展开。标记按 Session、变更来源、路径与改动摘要保存在本浏览器中,关闭标签页或重新加载后仍在;同一 Session 的多个标签页共用这些标记:在一个标签页中标记,其他标签页随之更新,且不会覆盖它们的标记。只改文件模式(例如 `chmod +x`)也算新改动。“提交评审”左侧,以及“分支”范围中基准分支右侧的“全部提交”按从旧到新列出提交,读取一次后保留到 head、本地分支或基准变化或刷新为止;单击只看一个提交,按住 Shift 单击看从上次单击的提交到这个提交的连续多个提交,这些提交必须依次是后一个的第一父提交:离开这条链的范围(例如跨越已合并分支的范围)会让菜单保持打开并说明原因;此时视图显示这些提交的改动,在最后一个提交读取未变更的行,提供“添加到输入框”,但不提供 GitHub 评论或评审。“分支”的提交不含未提交与未跟踪的文件。换基准分支或范围后回到全部提交;所选提交已不存在或不再是一条链时提供“查看全部提交”。 +- **评审讨论**——变更请求视图会叠加代码托管平台在所显示 head 处读到的评审讨论。行级讨论显示在它最后一行所在的行之后,位于写下时所在的那一侧;若该行折叠在未变更的行中,则显示在折叠行之后。讨论标明写下时的位置,每条评论显示头像、作者、时间和 Markdown 正文(不显示原始 HTML),并用标签标出变更请求作者、机器人账号,以及当前登录用户尚未提交的评审中的评论。已解决的讨论折叠成一行,可以展开。针对整个文件的讨论显示在文件开头;被后续提交改到而过期的讨论集中在文件的行之后,显示写下时那段 hunk 的末尾,并提供“查看当时的文件”,读取写下讨论时那个提交上的文件并标出相应的行。选择了部分提交时,只显示行号对应最后一个所选提交的新版本侧讨论。文件树和每个文件标题都显示未解决讨论数。每点击一次文件标题上的计数,就按文件中的显示顺序跳到该文件的下一条未解决讨论(所选提交中没有的讨论排在最后),到最后一条后回到第一条;工具栏的“讨论”按钮打开讨论列表。列表按文件树顺序分组,组内讨论的顺序与文件中相同,可显示未解决、已解决或全部讨论,默认显示未解决的讨论;点击讨论会跳到它的位置,若所选提交中没有该讨论,会先切换到全部提交。视图宽度不小于 1,040px 时列表停靠在文件旁;更窄时浮在文件之上,并在跳到讨论前先关闭;窄于 480px 时占满文件区的宽度。读取被截断时,列表会注明。讨论上提供“添加到输入框”(把整个讨论作为引用插入)、“在 GitHub 中查看”,以及当前登录用户可以执行的写操作。没有待提交评审时,回复框立即回复,其下拉菜单可以带着这条回复开始待提交评审;有待提交评审时,回复框把回复加入其中,因为 GitHub 会把用户的每条回复都加入待提交评审。“解决讨论”和“取消解决”切换讨论状态;用户自己评论的菜单可以编辑或删除该评论,删除前会先确认,删除讨论中唯一的评论会一并删除讨论,确认时会说明这一点。用户正在讨论中写回复或编辑评论时,即使讨论已被解决,也保持展开。代码托管平台拒绝写操作时保留输入的文字并说明原因;结果不明时会重新读取讨论,并请用户核对后再重试。每次写操作后都会重新读取讨论,因为解决讨论不会改变 GitHub pull request 的更新时间。视图显示期间,每收到一次状态就重新读取讨论;点击刷新后,即使面板还保留着之前的读取结果,也会从代码托管平台重新读取;读取失败时保留上次的讨论并提供重试,提供方不支持读取讨论时不显示讨论。 检查行先显示轮询预览。不完整时,卡片使用托管平台的总体状态而不显示失败总数,并注明已加载的检查数量尚不完整。打开列表后,经 `changeRequestChecks` 单独读取该 PR 的后续页,时限独立于轮询。读取失败时保留预览并提供重试。关闭列表、head 变化或组件卸载会取消读取,忽略迟到结果;重新打开会读取当前详情。Remote 要求 URL 是该 Session 可见的绑定,并拒绝 head 提交不一致的响应。 diff 按文件树的深度优先顺序排列:同层目录在前、文件在后,各组按显示名称以区分大小写的 UTF-16 码元顺序排序。压缩目录链作为一个完整名称参与比较。筛选或折叠文件树不会改变 diff 的顺序。点击 diff 内容区可将焦点移入。左右方向键按 diff 顺序跳到上一个、下一个文件并展开它;到达首尾时停留。上下方向键保留原有滚动行为。手动滚动后,文件跳转从视口顶部的文件继续。文本编辑、非折叠文本选区、组合键、菜单与文件树控件保留各自的键盘行为。 -`gitCommand`、`gitTimeoutMs`、`graceMs`、`maxOutputBytes`、`readinessTimeoutMs`、`maxBranches`、`worktreePrefix`、`maxDiffBytes`、`maxUntrackedFiles`、`maxCachedDiffBytes`、`maxCommits`、`testPaths` 与 `interfacePaths` 列在[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-experimental-client-ui-forge)中。 +`gitCommand`、`gitTimeoutMs`、`graceMs`、`maxOutputBytes`、`readinessTimeoutMs`、`maxBranches`、`worktreePrefix`、`maxDiffBytes`、`maxUntrackedFiles`、`maxCachedDiffBytes`、`maxCachedReviewBytes`、`reviewRefreshMs`、`maxCommits`、`testPaths` 与 `interfacePaths` 列在[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-experimental-client-ui-forge)中。 -“全部文件”打开可搜索的目录树,并把选中文件滚动到中间;只有一个子目录的路径会合并显示,重命名文件也可以按旧路径搜索。较宽的变更面板会在 diff 旁显示同一棵树,工具栏的“文件树”按钮按“左侧 → 右侧 → 隐藏”循环切换,默认显示在左侧。按钮的无障碍名称说明下一步操作,移动或隐藏文件树时焦点仍留在按钮上。移动或隐藏文件树,以及切换标签页与 Session 时保留搜索、折叠状态、选中文件和宽度。拖动分隔线,或聚焦分隔线后使用方向键,可以调整文件树宽度。文件与文件夹预留相同的展开箭头列,子项相对父目录向内缩进。本地变更与每个 PR 保留独立标签页。划选代码文字或通过行号选择同一已显示 hunk、同一侧的连续行,文字划选与复制都限定在起始侧。点击“+”打开纯文本评论框。使用鼠标时,悬停在某一行上也会在其行号旁显示“+”,行号仍可点击:两栏时显示在悬停的一侧,单栏时显示在该行所属的一侧;按下即选中这一行,拖动可在同一侧扩展范围,松开即打开评论框。评论框支持换行和输入法组合输入,只有点击操作按钮才提交。可拖动底边调整高度。“添加到输入框”将评论作为可移除的引用加入主输入框,引用显示文件名、侧与行号以及评论,悬停时显示文件路径;多条引用只会在用户发送主输入框时一起发送。GitHub 分段按钮用于开始或加入待提交评审,下拉菜单可以立即发布单条评论。评论在引用、暂存或发布后保留在对应代码旁,暂存评论可在那里编辑或删除。“请求更改”需要填写评审总结,弹窗在总结输入框旁说明此要求,并在切换评审决定时播报。PR 标题栏中的“提交评审”打开居中弹窗,只显示待提交数量、总结以及“评论”“批准”“请求更改”选项。新建和删除文件只显示有内容的一侧行号。待提交评审与评论记录按 Session 和页签保存在当前浏览器,包括最初评审的 head。切换 Session 或隐藏面板时保留;关闭页签会清空该页签的评论、总结与评审决定,重新打开从空评审开始。已发布到 GitHub 的评论与已加入主输入框的引用不受影响。head 变化后需要重新选择评论行。代码变化后的记录保留在文件末尾;文件已不在 diff 中时,记录显示在 diff 之后。页签保持打开时,结果不明的提交在重新加载后仍会阻止重复发送,直到用户检查 GitHub 并明确允许再次提交。 +“全部文件”打开可搜索的目录树,并把选中文件滚动到中间;只有一个子目录的路径会合并显示,重命名文件也可以按旧路径搜索。较宽的变更面板会在 diff 旁显示同一棵树,工具栏的“文件树”按钮按“左侧 → 右侧 → 隐藏”循环切换,默认显示在左侧。按钮的无障碍名称说明下一步操作,移动或隐藏文件树时焦点仍留在按钮上。移动或隐藏文件树,以及切换标签页与 Session 时保留搜索、折叠状态、选中文件和宽度。拖动分隔线,或聚焦分隔线后使用方向键,可以调整文件树宽度。文件与文件夹预留相同的展开箭头列,子项相对父目录向内缩进。本地变更与每个 PR 保留独立标签页。划选代码文字或通过行号选择同一已显示 hunk、同一侧的连续行,文字划选与复制都限定在起始侧。点击“+”打开纯文本评论框。使用鼠标时,悬停在某一行上也会在其行号旁显示“+”,行号仍可点击:两栏时显示在悬停的一侧,单栏时显示在该行所属的一侧;按下即选中这一行,拖动可在同一侧扩展范围,松开即打开评论框。评论框支持换行和输入法组合输入,只有点击操作按钮才提交。可拖动底边调整高度。“添加到输入框”将评论作为可移除的引用加入主输入框,引用显示文件名、侧与行号以及评论,悬停时显示文件路径;多条引用只会在用户发送主输入框时一起发送。读到评审讨论且用户可以评审时,GitHub 分段按钮会用这条评论在 GitHub 上开始用户的待提交评审,或把评论加入其中;评论随后显示为带“待提交”标签的讨论,提交评审之前只有用户自己能看到。下拉菜单可以立即发布单条评论;用户有待提交评审时 GitHub 会拒绝单条评论,因此这时没有该菜单。Agent 引用和已发布的评论保留在对应代码旁。“请求更改”需要填写评审总结,弹窗在总结输入框旁说明此要求,并在切换评审决定时播报。PR 标题栏中的“提交评审”打开居中弹窗,只显示待提交数量、总结以及“评论”“批准”“请求更改”选项。有待提交评审时提交该评审,结果不明的提交与直接提交一样,在用户到 GitHub 核对之前阻止再次提交;“丢弃评审”在确认后删除该评审及其中的评论;没有待提交评审时,把总结作为一次评审发布。在用户自己的 PR 上,GitHub 只允许作者评论,因此“批准”和“请求更改”不可用。新建和删除文件只显示有内容的一侧行号。总结、评审决定,以及 Agent 引用与已发布评论的记录按 Session 和页签保存在当前浏览器,包括最初评审的 head。切换 Session 或隐藏面板时保留;关闭页签会清空它们,重新打开从空总结开始。待提交评审、已发布到 GitHub 的评论与已加入主输入框的引用不受影响。早期版本保存在此浏览器中的评论草稿保留在对应代码旁,带“浏览器草稿”标签,可以编辑、删除或加入待提交评审;存在待提交评审时,它们会让“提交评审”保持不可用;没有待提交评审时,“提交评审”把它们连同总结一起发布。head 变化后需要重新选择评论行。代码变化后的记录保留在文件末尾;文件已不在 diff 中时,记录显示在 diff 之后。页签保持打开时,结果不明的提交在重新加载后仍会阻止重复发送,直到用户检查 GitHub 并明确允许再次提交。 ----- @@ -58,7 +59,7 @@ diff 按文件树的深度优先顺序排列:同层目录在前、文件在后
实现细节——点击展开 -`ForgePanelController` 为每个被关注的 Session 保留一个 `SessionPanel`,最后一个关注方离开时销毁它。面板从 Session 的工作目录与绑定开始,在每个 `working-directory/change`、`tool/result` 与 `sandbox/mode` 事件之后重新检查,每次变化都发布新的 `ForgePanelState`。每个变更请求在后续轮询失败时保留最近一次轮询得到的快照。并行 Git 读取失败时会取消并等待同批读取结束。检查一次只运行一个,最多再追加一次;Session 已离开的目录的结果会被丢弃。代码托管平台按远端顺序识别:先是上游所在的远端,然后是 `origin`,最后是其余远端。检查发现提供方识别的远端上有上游分支时,面板调用 `ctx.forgeSession.discoverBranch` 绑定该分支打开状态的变更请求;同一远端与分支在刷新前只查找一次。识别出的托管平台变化时以及刷新时,会在 `readinessTimeoutMs` 内检查登录状态;一次刷新在它触发的检查与登录状态检查结束后完成,轮询结果之后才到达。 +`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=`;`diffBases` 像下文的 `localDiff` 一样找出默认分叉点,在 `maxDiffBytes` 以内对 `refs/heads/` 与 `refs/remotes/` 运行 `for-each-ref --sort=-committerdate`,排除 `origin/HEAD` 等符号引用,每类最多保留 `maxBranches` 个名称包含查询文字的分支。之后,检查会为本地变更卡片统计 `branch` 范围的规模:找出分叉点,对它运行 `diff --numstat -z -M`,采用与下文 `localDiff` 相同的辅助程序和子模块限制,并列出未跟踪文件。每个命令都使用 Session 解析出的沙箱模式:`read-only` 与 `workspace-write` 需要沙箱,`danger-full-access` 无需沙箱后端。命令禁用可选的索引写入、钩子、文件系统监视器、提示与对象的延迟获取,移除继承的 Git 选择器与携带密钥的变量,并各自受 `gitTimeoutMs` 时限与每个输出流的 `maxOutputBytes` 上限约束。Git 打印的顶层目录、Git 目录与公共 Git 目录经 `ctx.fs` 解析为工作目录服务记录的规范进程路径;Git 目录与公共 Git 目录不同的检出是链接 worktree。工作树选项在 `danger-full-access` 沙箱模式下可用,在 `read-only` 下不可用,在 `workspace-write` 下只有两个目录都位于 Session 沙箱策略的规范工作区根内时才可用。 @@ -69,11 +70,11 @@ worktree 请求是一条 `plugin:client-ui-forge/worktree` 记录,由 `forgeWo 每次检查还会判断 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` 拒绝。`branch` 范围指定了 `refs/heads/` 或 `refs/remotes/` 下的基准引用时,`show-ref --verify` 找到该引用后改用 `merge-base HEAD` 与它的结果;引用不存在时以 `forgePanel/base-missing` 拒绝,HEAD 与它没有共同历史时以 `forgePanel/no-fork-point` 拒绝;其他引用名在运行 Git 之前即被拒绝。它以固定的 `a/`、`b/` 前缀和 `core.quotePath=false` 运行 `diff --no-ext-diff --no-textconv --submodule=short --ignore-submodules=dirty --no-color -M`,分别与分叉点、HEAD(第一次提交之前为空树)、暂存区比较,或带 `--cached` 与 HEAD 比较。读取补丁与统计前会查询 Git 配置,应用命令级 clean/process filter 覆盖。配置读取失败或被截断时停止比较:`localDiff` 报告 `forgePanel/filter-config-failed`,修正 Git 配置或读取上限后可重试;统计失败时本地变更卡片保持隐藏。过滤器枚举在读取补丁时使用 `maxDiffBytes` 上限,统计时使用 `maxOutputBytes` 上限。未跟踪文件由 `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,浏览器不会收到原始补丁。Host 为每个文件计算由路径、状态、hunk 以及 `old mode` 与 `new mode` 行组成的摘要(二进制改动加上 blob 名,未跟踪的二进制文件加上内容),并先按根目录 `.gitattributes` 中的 `dsh-review-layer` 与 `linguist-generated` 行确定分层(后面的行覆盖前面的行,不是层名的值视为未指定;模式按 [`src/wildmatch.ts`](src/wildmatch.ts) 中 Git 的 wildmatch 规则匹配,取反模式与 Git 一样跳过),再用 picomatch(包含点文件)依次匹配 `testPaths`、`interfacePaths` 与 [`src/layers.ts`](src/layers.ts) 中的内置规则。`.gitattributes` 的读取来源:“已暂存”读暂存区,其他本地范围读工作树,所选提交读最后一个提交,变更请求经 `fileText` 读 head 或最后一个所选提交,文件不存在时按没有处理。`localCommits` 用 `log --no-show-signature --topo-order --reverse` 列出分叉点到 HEAD 之间最新的 `maxCommits` 个提交,因此列表以 HEAD 的提交结尾。带所选提交的“分支”范围比较第一个提交的第一个父提交(根提交用空树)与最后一个提交;不是完整对象名、不在列表中或顺序颠倒的提交以 `forgePanel/commits-missing` 拒绝,第一父提交不是列表中前一个提交的所选提交以 `forgePanel/commits-not-linear` 拒绝;`localText` 经 `show` 读取那个最后的提交。`localText` 读取一个文件的新版本:“已暂存”经 `show :` 从暂存区读取,其余经 `ctx.fs` 读取;离开检出的路径会被拒绝。`changeRequestDiff` 与 `changeRequestText` 只作用于该 Session 中由提供方服务的可见绑定,否则以 `forgePanel/not-bound` 拒绝;它们经提供方的 `diff` 与 `fileText` 读取绑定最新状态中的 head 提交,`too-large` 失败以 `forgePanel/diff-too-large` 拒绝,`stale` 失败(head 已越过该状态)以 `forgePanel/stale-head` 拒绝,尚未读到状态或托管平台以其他原因拒绝时以 `forgePanel/diff-failed` 拒绝。`stale` 的 diff 会立即重新轮询该变更请求(每个 head 提交一次)且不缓存,因此不会有补丁被存放在它不描述的 head 下。Host 保留 `changeRequestDiff` 读到的补丁及其属性,每个变更请求一份,按 head 提交与目标分支区分,总量超过 `maxCachedDiffBytes` 时丢弃最久未读的;之后在同一 head 与目标分支下的读取不论来自哪个 Session 都从内存返回,读取失败的结果不保留。`changeRequestCommits` 返回提供方 `commits` 的列表,提供方不读取提交时返回 null;带所选提交的 `changeRequestDiff` 在该列表中找到它们,按与“分支”范围相同的规则拒绝,并从第一个提交的父提交到最后一个提交读取 `compare`,不缓存。本地分支比 head 多出的提交数由 `symbolic-ref`、`cat-file -e` 与 `rev-list --count` 得出;当前检出分支不是源分支、本地没有 head 提交或 Git 失败时为 null。 +Session 在所创建的 worktree 中工作时,`localDiff` 以它的 `baseCommit` 为分叉点;否则按代码托管远端的顺序,对每个远端依次运行 `symbolic-ref --quiet --short refs/remotes//HEAD` 与 `merge-base HEAD` 找出分叉点;找不到时 `branch` 范围以 `forgePanel/no-fork-point` 拒绝。`branch` 范围指定了 `refs/heads/` 或 `refs/remotes/` 下的基准引用时,`show-ref --verify` 找到该引用后改用 `merge-base HEAD` 与它的结果;引用不存在时以 `forgePanel/base-missing` 拒绝,HEAD 与它没有共同历史时以 `forgePanel/no-fork-point` 拒绝;其他引用名在运行 Git 之前即被拒绝。它以固定的 `a/`、`b/` 前缀和 `core.quotePath=false` 运行 `diff --no-ext-diff --no-textconv --submodule=short --ignore-submodules=dirty --no-color -M`,分别与分叉点、HEAD(第一次提交之前为空树)、暂存区比较,或带 `--cached` 与 HEAD 比较。读取补丁与统计前会查询 Git 配置,应用命令级 clean/process filter 覆盖。配置读取失败或被截断时停止比较:`localDiff` 报告 `forgePanel/filter-config-failed`,修正 Git 配置或读取上限后可重试;统计失败时本地变更卡片保持隐藏。过滤器枚举在读取补丁时使用 `maxDiffBytes` 上限,统计时使用 `maxOutputBytes` 上限。未跟踪文件由 `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,浏览器不会收到原始补丁。Host 为每个文件计算由路径、状态、hunk 以及 `old mode` 与 `new mode` 行组成的摘要(二进制改动加上 blob 名,未跟踪的二进制文件加上内容),并先按根目录 `.gitattributes` 中的 `dsh-review-layer` 与 `linguist-generated` 行确定分层(后面的行覆盖前面的行,不是层名的值视为未指定;模式按 [`src/wildmatch.ts`](src/wildmatch.ts) 中 Git 的 wildmatch 规则匹配,取反模式与 Git 一样跳过),再用 picomatch(包含点文件)依次匹配 `testPaths`、`interfacePaths` 与 [`src/layers.ts`](src/layers.ts) 中的内置规则。`.gitattributes` 的读取来源:“已暂存”读暂存区,其他本地范围读工作树,所选提交读最后一个提交,变更请求经 `fileText` 读 head 或最后一个所选提交,文件不存在时按没有处理。`localCommits` 用 `log --no-show-signature --topo-order --reverse` 列出分叉点到 HEAD 之间最新的 `maxCommits` 个提交,因此列表以 HEAD 的提交结尾。带所选提交的“分支”范围比较第一个提交的第一个父提交(根提交用空树)与最后一个提交;不是完整对象名、不在列表中或顺序颠倒的提交以 `forgePanel/commits-missing` 拒绝,第一父提交不是列表中前一个提交的所选提交以 `forgePanel/commits-not-linear` 拒绝;`localText` 经 `show` 读取那个最后的提交。`localText` 读取一个文件的新版本:“已暂存”经 `show :` 从暂存区读取,其余经 `ctx.fs` 读取;离开检出的路径会被拒绝。`changeRequestDiff` 与 `changeRequestText` 只作用于该 Session 中由提供方服务的可见绑定,否则以 `forgePanel/not-bound` 拒绝;它们经提供方的 `diff` 与 `fileText` 读取绑定最新状态中的 head 提交,补丁与它的 `.gitattributes` 同时读取,补丁读取失败时取消 `.gitattributes` 的读取,两者都结束后才给出结果,补丁的失败优先于 `.gitattributes` 的失败,`too-large` 失败以 `forgePanel/diff-too-large` 拒绝,`stale` 失败(head 已越过该状态)以 `forgePanel/stale-head` 拒绝,尚未读到状态或托管平台以其他原因拒绝时以 `forgePanel/diff-failed` 拒绝。`stale` 的 diff 会立即重新轮询该变更请求(每个 head 提交一次)且不缓存,因此不会有补丁被存放在它不描述的 head 下。Host 保留 `changeRequestDiff` 读到的补丁及其属性,每个变更请求一份,按 head 提交与目标分支区分,总量超过 `maxCachedDiffBytes` 时丢弃最久未读的;之后在同一 head 与目标分支下的读取不论来自哪个 Session 都从内存返回,读取失败的结果不保留。`changeRequestCommits` 返回提供方 `commits` 的列表,提供方不读取提交时返回 null;带所选提交的 `changeRequestDiff` 在该列表中找到它们,按与“分支”范围相同的规则拒绝,并从第一个提交的父提交到最后一个提交读取 `compare`,不缓存。本地分支比 head 多出的提交数由 `symbolic-ref`、`cat-file -e` 与 `rev-list --count` 得出;当前检出分支不是源分支、本地没有 head 提交或 Git 失败时为 null。`changeRequestReview` 同样只作用于可见的绑定;head 与最新状态的 head 不同时,读取前就以 `forgePanel/stale-head` 拒绝;随后调用提供方的 `readReview`,把 `stale` 映射为 `forgePanel/stale-head`、`unsupported` 映射为 `forgePanel/review-unsupported`、其余失败映射为 `forgePanel/review-failed`。每个变更请求保留一次读取结果,以 head 提交和更新时间为键;保留的结果按 JSON 计超过 `maxCachedReviewBytes` 时,最久未读的先被丢弃;保留时间达到 `reviewRefreshMs` 后,或读者刷新视图时,重新读取,因为解决 GitHub 讨论不会改变更新时间。 `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/` 指明来源,导航参数可以指定要滚动到的文件。标签页类型启用 `keepMounted`,将访问过的正文及其所属 Session 引用保留至标签页记录结束。它的 keyed panel hook 仅为可见正文订阅;其他面板界面仍可能保持共享 Session 流开启。它的正文用 slot store 按标签页保存范围、基准分支、所选提交、透镜、测试文件开关与布局选择,按 Session 把已审阅标记保存在 `localStorage` 的 `dsh.forge-viewed.v1.` 下(每个 Session 最多 20 个来源,最多 50 个 Session),经 Remote 读取,并用共享的代码高亮器高亮各行;标签页标题显示变更图标与视图名称。 +浏览器入口先挂载 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/` 指明来源,导航参数可以指定要滚动到的文件。标签页类型启用 `keepMounted`,将访问过的正文及其所属 Session 引用保留至标签页记录结束。它的 keyed panel hook 仅为可见正文订阅;其他面板界面仍可能保持共享 Session 流开启。它的正文用 slot store 按标签页保存范围、基准分支、所选提交、透镜、测试文件开关与布局选择,按 Session 把已审阅标记保存在 `localStorage` 的 `dsh.forge-viewed.v1.` 下(每个 Session 最多 20 个来源,最多 50 个 Session),经 Remote 读取,并且传给每个文件段的值(包括标签页可见性)在多次渲染之间保持同一标识,所以一次渲染只重绘值有变化的文件段,可见性变化时重绘全部文件段。展开的文件在它的段第一次进入滚动区域 1200px 范围内,或跳转到它的某个评审讨论时,才绘制各行,并在同一次渲染中用共享的代码高亮器高亮,此后一直保留;在此之前按每个绘制行 22px 占位;标签页标题显示变更图标与视图名称。 | 源码 | 职责 | |---|---| @@ -92,9 +93,15 @@ Session 在所创建的 worktree 中工作时,`localDiff` 以它的 `baseCommi | [`src/client/ChangesTab.tsx`](src/client/ChangesTab.tsx) | 变更视图 | | [`src/client/BaseMenu.tsx`](src/client/BaseMenu.tsx) | “分支”范围的基准分支菜单 | | [`src/client/DiffFile.tsx`](src/client/DiffFile.tsx) | 变更视图中的一个文件 | +| [`src/client/section-reach.ts`](src/client/section-reach.ts) | 文件段何时绘制各行 | | [`src/client/ReviewControls.tsx`](src/client/ReviewControls.tsx) | 分层透镜、测试文件开关与已审阅进度 | | [`src/client/CommitMenu.tsx`](src/client/CommitMenu.tsx) | 提交选择器 | | [`src/client/viewed-files.ts`](src/client/viewed-files.ts) | 保存在浏览器中的已审阅标记 | +| [`src/client/ReviewThreads.tsx`](src/client/ReviewThreads.tsx) | 评审讨论卡片与已过期区域 | +| [`src/client/ThreadList.tsx`](src/client/ThreadList.tsx) | 讨论列表 | +| [`src/client/review-threads.ts`](src/client/review-threads.ts) | 讨论位置、列表筛选、hunk 摘录与讨论引用 | +| [`src/client/thread-state.ts`](src/client/thread-state.ts) | 读取讨论并确定它们在所显示变更中的位置 | +| [`src/review-cache.ts`](src/review-cache.ts) | 保存在内存中的评审讨论 | | [`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 缺失卡片 | @@ -117,7 +124,7 @@ Session 在所创建的 worktree 中工作时,`localDiff` 以它的 `baseCommi ## 模型体验 -间接影响,经由工作目录服务与 Session 的对话:关于 Session 新 worktree 的通知会加入第一次请求的消息,“在项目目录继续”会为下一次请求排入同类通知,“创建变更请求”会发送一条请 Agent 创建变更请求的用户消息。“添加到输入框”提供以 `## User Comment N` 开头的冻结引用,包含 File、Side、Lines、Source、diff hunk 与 Comment;PR 引用还包含评审的提交,本地“分支”引用还包含视图比较的基准,来自所选提交的引用还会注明这些提交。只有发送主输入框后,这些引用才进入正常记录的用户消息。检查、状态、恢复、会话列表与 GitHub 评审提交不会增加模型上下文。 +间接影响,经由工作目录服务与 Session 的对话:关于 Session 新 worktree 的通知会加入第一次请求的消息,“在项目目录继续”会为下一次请求排入同类通知,“创建变更请求”会发送一条请 Agent 创建变更请求的用户消息。“添加到输入框”提供以 `## User Comment N` 开头的冻结引用,包含 File、Side、Lines、Source、diff hunk 与 Comment;PR 引用还包含评审的提交,本地“分支”引用还包含视图比较的基准,来自所选提交的引用还会注明这些提交。只有发送主输入框后,这些引用才进入正常记录的用户消息。检查、状态、恢复、会话列表与 GitHub 评审提交不会增加模型上下文。对评审讨论使用“添加到输入框”时,会加入一段固定的 `## Review Thread N` 引用,包含 File、Side、Lines、这些行号所在的提交、State、Source、记录下的 diff hunk,以及逐行引用的每条评论的作者、时间和正文。 #### KV Cache 影响 @@ -139,7 +146,10 @@ Session 在所创建的 worktree 中工作时,`localDiff` 以它的 `baseCommi - **变更范围**——待办:上一轮的范围。“分支”范围的“默认”需要一个默认分支与 HEAD 有共同历史的远端,或 Session 创建的 worktree;否则必须选择基准分支。 - **分支极多**——基准分支菜单在筛选前读取全部引用,这样搜索才能找到任意分支。引用超过 `maxDiffBytes`(默认约 165,000 个)或列出时间超过 `gitTimeoutMs` 时,菜单提示无法读取分支;“默认”仍可使用。 - **大型变更**——本地补丁超过 `maxDiffBytes` 时不显示任何文件;超过托管平台或其提供方大小上限的变更请求 diff 不显示任何文件,只提供“在浏览器中查看”。不自动换行时,左右对比会在栏边截断长行。 +- **尚未绘制的文件**——读者滚动到展开的文件附近之前,它按每个绘制行 22 px 占位,没有计入左右对比的配对、自动换行、未变更行按钮和评审卡片,所以文件绘制时滚动范围会略有变化;它已保存的评审评论也要等它绘制后才显示。 +- **长 diff 中的页内查找**——展开的文件要等读者滚动到附近才绘制各行,所以浏览器的页内查找在更靠后的文件绘制之前匹配不到它们的行;要在整个 diff 中查找,需要视图自己的查找控件。 - **代码托管平台上的已审阅状态**——变更请求的已审阅标记只保存在本浏览器中,在提供方评审接口完成之前,不读取也不写入 GitHub 的已审阅状态(`markFileAsViewed`);换浏览器或设备后没有这些标记。 +- **评审讨论的写入**——在一个变更视图中的写操作,会在该变更请求的其他已打开视图收到下一次状态时显示;解决讨论不会改变 pull request 的更新时间,要等到 `reviewRefreshMs` 之后才显示。GitHub 每个用户在每个 pull request 上只保留一个待提交评审。早期版本保存在浏览器中的评论草稿,只有其文件和行仍与所显示的 diff 一致时才能加入待提交评审,否则只能编辑或删除。对所选提交的评论只能添加到输入框。被折叠隐藏的评论与其他评论显示方式相同;头像由浏览器从代码托管平台的图片服务器加载。 - **分层**——内置规则只覆盖常见约定,其他约定由仓库写在 `.gitattributes` 中;按路径无法识别 Rust 的内联 `#[cfg(test)]` 模块,也无法判断一种语言导出了哪些声明,“接口”层保留整个文件,找出变更的导出需要语言分析器,可由之后的透镜扩展点提供。`linguist-generated` 只读取根目录的 `.gitattributes`,不读子目录中的 `.gitattributes`,也不展开属性宏。 - **所选提交**——变更请求最多列出提供方的 `maxCommits` 个提交(GitHub 为最早的 250 个),“分支”范围则列出最新的 `maxCommits` 个;范围必须沿第一父提交相连,因此已合并分支的提交只能逐个查看,不能与本分支的提交一起查看;每次查看提交范围都从托管平台读取;所选提交上的评论只能添加到输入框,因为 GitHub 评审针对 head。 - **代码审查页**——待办:在左侧导航中新增一个页面,列出用户在所选提供方上的变更请求;[决策笔记](../../../.agents/notes/implemented/feature/2026-10-09-forge-panel.zh.md)记录了它的接入位置。 diff --git a/packages/experimental/client-ui-forge/src/client/ChangeRequestCard.tsx b/packages/experimental/client-ui-forge/src/client/ChangeRequestCard.tsx index 5a90b9e6b3..a5f1abbab2 100644 --- a/packages/experimental/client-ui-forge/src/client/ChangeRequestCard.tsx +++ b/packages/experimental/client-ui-forge/src/client/ChangeRequestCard.tsx @@ -76,6 +76,7 @@ const FAILURE: Record = { 'unauthenticated': 'change.failure.unauthenticated', 'too-large': 'change.failure.unavailable', 'stale': 'change.failure.unavailable', + 'unsupported': 'change.failure.unavailable', 'unavailable': 'change.failure.unavailable', } diff --git a/packages/experimental/client-ui-forge/src/client/ChangedFiles.module.css b/packages/experimental/client-ui-forge/src/client/ChangedFiles.module.css index 4a57098d20..2006c2b546 100644 --- a/packages/experimental/client-ui-forge/src/client/ChangedFiles.module.css +++ b/packages/experimental/client-ui-forge/src/client/ChangedFiles.module.css @@ -5,6 +5,8 @@ .row { box-sizing: border-box; display: flex; align-items: center; gap: 6px; width: 100%; min-width: 0; min-height: 30px; padding: 4px 6px; border: 0; border-radius: var(--dsw-radius-sm); background: transparent; color: inherit; font: inherit; text-align: start; cursor: pointer; } .row svg { flex: none; } .name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +/* Unresolved host review threads of a file, in the thread count chip treatment of the file header. */ +.threads { flex: none; min-width: 16px; padding: 0 5px; box-sizing: border-box; border-radius: var(--dsw-radius-xs); background: color-mix(in srgb, var(--dsw-alias-state-business-primary) 12%, transparent); color: var(--dsw-alias-state-business-primary); font-size: 11px; line-height: 16px; text-align: center; } .selected { background: var(--dsw-alias-interactive-bg-hover); } .chevron { display: inline-flex; flex: 0 0 12px; } .collapsed { transform: rotate(-90deg); } diff --git a/packages/experimental/client-ui-forge/src/client/ChangedFiles.tsx b/packages/experimental/client-ui-forge/src/client/ChangedFiles.tsx index 995bae454d..eb24d62b51 100644 --- a/packages/experimental/client-ui-forge/src/client/ChangedFiles.tsx +++ b/packages/experimental/client-ui-forge/src/client/ChangedFiles.tsx @@ -25,6 +25,8 @@ export interface ChangedFilesProps extends PropsLocale { readonly jump: (path: string) => void /** A menu row participates in the shared menu's keyboard walk. */ readonly menu?: boolean + /** Unresolved host review threads by file path; files without any are absent. */ + readonly threadCounts?: ReadonlyMap } function Rows({ nodes, depth, props }: { @@ -32,10 +34,11 @@ function Rows({ nodes, depth, props }: { readonly depth: number readonly props: ChangedFilesProps }): ReactNode { - const { state, toggle, jump, menu, t } = props + const { state, toggle, jump, menu, threadCounts, t } = props return nodes.map((node) => { const directory = node.kind === 'directory' const expanded = directory && (state.query.trim() !== '' || state.collapsed[node.path] !== true) + const threads = node.kind === 'file' ? threadCounts?.get(node.path) ?? 0 : 0 const label = node.kind === 'file' && node.file.previousPath !== null ? t('diff.renamedPath', { from: node.file.previousPath, path: node.path }) : node.path return
@@ -54,6 +57,7 @@ function Rows({ nodes, depth, props }: { {directory ? : } {node.name} + {threads > 0 && {threads}} {node.kind === 'file' && } diff --git a/packages/experimental/client-ui-forge/src/client/ChangesTab.module.css b/packages/experimental/client-ui-forge/src/client/ChangesTab.module.css index f1603a01d4..9183d560e4 100644 --- a/packages/experimental/client-ui-forge/src/client/ChangesTab.module.css +++ b/packages/experimental/client-ui-forge/src/client/ChangesTab.module.css @@ -333,6 +333,7 @@ } .body { + position: relative; display: flex; flex: 1; min-height: 0; @@ -567,6 +568,12 @@ font: var(--dsw-font-markdown-code-block); } +/* An expanded file the reader has not scrolled near yet: one 22px row (`.line`) per drawn line, so the scroll + range and the positions of the files below stay close to their drawn values. */ +.unreached { + height: calc(var(--forge-rows) * 22px); +} + /* One intrinsic-width track paints every unwrapped unified row across the scrolled width. */ .code:not([data-wrap]):not([data-split]) { display: grid; @@ -859,3 +866,69 @@ .code[data-single-side]:not([data-wrap]) .line { grid-template-columns: var(--forge-number-width) 1.4em max-content; } .reviewFile { padding: 12px 0; } .reviewFile > h3 { margin: 0 10px; font-size: 12px; font-weight: 500; overflow-wrap: anywhere; } + +/* The unresolved thread count of a file opens the thread list at the file. */ +.threadCount { + flex: none; + height: 20px; + padding: 0 6px; + border: 0; + border-radius: var(--dsw-radius-xs); + background: color-mix(in srgb, var(--dsw-alias-state-business-primary) 12%, transparent); + color: var(--dsw-alias-state-business-primary); + font: inherit; + font-size: 11px; + line-height: 20px; + white-space: nowrap; + cursor: pointer; +} + +/* Drawn inside the button, since the file title clips what overflows it. */ +.threadCount:focus-visible { + outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); + outline-offset: -1px; +} + +@media (hover: hover) { + .threadCount:hover { background: color-mix(in srgb, var(--dsw-alias-state-business-primary) 20%, transparent); } +} + +/* A view narrower than 480px shows the count alone. */ +.threadCountNumber { display: none; } + +@container forge-changes (max-width: 479px) { + .threadCountText { display: none; } + .threadCountNumber { display: inline; } +} + +/* Opens the thread list; pressed while the list shows. */ +.threadsToggle { + display: inline-flex; + flex: none; + align-items: center; + gap: 6px; + height: 28px; + padding: 0 8px; + border: 0; + border-radius: var(--dsw-radius-sm); + background: transparent; + color: var(--dsw-alias-label-primary); + font: inherit; + white-space: nowrap; + cursor: pointer; +} + +.threadsToggle[aria-pressed='true'] { background: var(--dsw-alias-interactive-bg-hover); } + +@media (hover: hover) { + .threadsToggle:hover { background: var(--dsw-alias-interactive-bg-hover); } +} + +.threadsTotal { + padding: 0 5px; + border-radius: var(--dsw-radius-xs); + background: var(--forge-fill); + color: var(--dsw-alias-label-secondary); + font-size: 11px; + line-height: 16px; +} diff --git a/packages/experimental/client-ui-forge/src/client/ChangesTab.tsx b/packages/experimental/client-ui-forge/src/client/ChangesTab.tsx index 6c73c8b96f..babc227996 100644 --- a/packages/experimental/client-ui-forge/src/client/ChangesTab.tsx +++ b/packages/experimental/client-ui-forge/src/client/ChangesTab.tsx @@ -5,6 +5,9 @@ * 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. + * File sections receive values that keep their identity across renders, so a + * render of the view redraws only the sections whose own values changed, and + * an expanded file draws its lines once its section comes near the scrollport. */ import { @@ -22,12 +25,16 @@ import { import type { HostObservable, InjectFace, PropsLocale, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' import type { SidebarRightTabInfo } from '@deepseek-ai/dsh-client-ui-sidebar-right/client' import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' +import { assertNever } from '@deepseek-ai/dsh-util-values' import { fileAddressFor, pathPartsOf } from '@deepseek-ai/dsh-util-workspace-path' import type { ForgeBaseBranch, ForgeBaseBranches, ForgeChangeRequestDiff, ForgeCommitRange, ForgeDiffFile, ForgeLocalCommits, ForgeLocalDiff, ForgeLocalScope, ForgePanelChangeRequest, ForgePanelState, } from '../types.ts' -import type { ForgeCommitList, ForgeReviewRequest, ForgeReviewResult } from '@deepseek-ai/dsh-experimental-forge/types' +import type { + ForgeCommitList, ForgeReviewData, ForgeReviewRequest, ForgeReviewResult, ForgeReviewThread, ForgeReviewWrite, ForgeReviewWriteResult, + ForgeThreadId, +} from '@deepseek-ai/dsh-experimental-forge/types' import { sameReviewSource, type ReviewQueue } from './review-queue.ts' import { ReviewThread, useFileReview } from './ReviewThread.tsx' import type { ReviewActions } from './ReviewComposer.tsx' @@ -39,7 +46,8 @@ import type { ChangesLens, createChangesStore } from './changes-store.ts' import { CommitMenu } from './CommitMenu.tsx' import { HideTestsSwitch, LensControl, LensMenu, LENSES, ViewedProgress } from './ReviewControls.tsx' import { viewedStateOf, type ViewedFiles, type ViewedMarks } from './viewed-files.ts' -import { Counts, DiffFile, type DiffFileAction } from './DiffFile.tsx' +import { Counts, DiffFile, type DiffFileAction, type DiffFileHandlers } from './DiffFile.tsx' +import { SectionReach } from './section-reach.ts' import { FileTreePane, type FileTreePosition } from './FileTreePane.tsx' import { changedFileTree, filesInTreeOrder } from './file-tree.ts' import { ChangedFilesMenu, type ChangedFilesProps, type ChangedFilesState } from './ChangedFiles.tsx' @@ -47,6 +55,10 @@ import { drawnLines, matchesHunks, textLines } from './diff-rows.ts' import { ChangeRequestGlyph } from './glyphs.tsx' import type { ForgePanelKey, NS } from './locales.ts' import { RefreshGlyph } from './RefreshGlyph.tsx' +import type { FileThreads, ThreadActions, ThreadFocus, ThreadWrite } from './ReviewThreads.tsx' +import { inFileOrder, type ThreadFilter } from './review-threads.ts' +import { usePlacedThreads, useThreadRead, type PlacedThread } from './thread-state.ts' +import { ThreadList } from './ThreadList.tsx' import type { PanelSnapshot } from './source.ts' import { numberOf, openChangeRequestOf, TONE_LABEL, toneOf } from './status.ts' import css from './ChangesTab.module.css' @@ -56,6 +68,12 @@ type Translate = PropsLocale['t'] /** Kind of a local scope. */ type ScopeKind = ForgeLocalScope['kind'] +/** A file the view scrolls to once, and the thread card it centers there with whether that card takes the focus. */ +interface Wanted { + readonly path: string + readonly center: { readonly id: ForgeThreadId; readonly focus: boolean } | null +} + /** Business face of the changes tab body. */ export interface ChangesTabInjected { /** Append a frozen comment to the main input without submitting it. */ @@ -86,6 +104,14 @@ export interface ChangesTabInjected { readonly viewedFiles: ViewedFiles /** Read one file of a change request at a commit. */ readonly changeRequestText: (url: string, commit: string, path: string) => Promise> + /** Read the review threads of a change request at the head of its latest status; `signal` aborts the read. */ + readonly changeRequestReview: ( + url: string, headSha: string, fresh: boolean, signal: AbortSignal, + ) => Promise> + /** Write to the review threads or the pending review of a change request; never retries a write. */ + readonly changeRequestReviewWrite: (url: string, write: ForgeReviewWrite) => Promise> + /** Quote a whole review thread of the change request at `url` in the main input without submitting it. */ + readonly addThreadToInput: (thread: ForgeReviewThread, url: string, range: string | null) => boolean /** Open a URL outside the app. */ readonly openLink: (url: string) => void /** Copy a path to the clipboard and report the outcome. */ @@ -130,6 +156,12 @@ type ChangesData = readonly fileUrls: Readonly> } +/** Why the code host did not take a review write, and whether it may have applied it. */ +interface WriteRefusal { + readonly message: string + readonly unknown: boolean +} + /** Unchanged lines read for one loaded diff: each file's new side and its expanded gaps. */ interface Expansion { readonly data: ChangesData | null @@ -423,8 +455,8 @@ function OrphanReviews({ path, actions, t }: PropsLocale & { */ export function ChangesTab({ useTabInfo, sessionId, useStore, actions, usePanel, reconnect, localDiff, diffBases, localText, localCommits, changeRequestDiff, - changeRequestText, changeRequestCommits, viewedFiles, - openLink, copyPath, createChangeRequest, addReviewToInput, reviewQueue, submitReview, report, t, + changeRequestText, changeRequestCommits, changeRequestReview, changeRequestReviewWrite, viewedFiles, + openLink, copyPath, createChangeRequest, addReviewToInput, addThreadToInput, reviewQueue, submitReview, report, t, }: ChangesTabProps): ReactNode { const { tab } = useTabInfo() const { navigation, signal } = tab @@ -472,8 +504,49 @@ export function ChangesTab({ setReviewBusy(false) } } + // Every write that may have changed the threads reads them again; writes run one at a time, so a write that starts a + // pending review settles before the next write looks for one. + const [reviewRevision, setReviewRevision] = useState(0) + const writing = useRef>(Promise.resolve()) + /** + * Send one write after the earlier ones. + * @returns undefined once the host took it, else its message and whether its outcome is unknown. + */ + const sendWrite = (url: string, write: ForgeReviewWrite): Promise => { + const run = async (): Promise => { + const response = await changeRequestReviewWrite(url, write) + if (!response.ok) { + if (response.error.code === 'forgePanel/not-bound') { + return { message: t('thread.writeFailed', { message: response.error.message }), unknown: false } + } + setReviewRevision(value => value + 1) + return { message: t('thread.writeUnknown'), unknown: true } + } + const result = response.value + if (result.ok) { + setReviewRevision(value => value + 1) + return undefined + } + switch (result.code) { + case 'unsupported': return { message: t('thread.writeUnsupported'), unknown: false } + case 'failed': + setReviewRevision(value => value + 1) + return { message: t('thread.writeFailed', { message: result.message }), unknown: false } + case 'stale': return { message: t('thread.writeStale'), unknown: false } + case 'unknown': + setReviewRevision(value => value + 1) + return { message: t('thread.writeUnknown'), unknown: true } + /* v8 ignore next -- closed ForgeReviewWriteResult code union exhaustiveness guard */ + default: return assertNever(result.code, 'ForgeReviewWriteResult.code') + } + } + const next = writing.current.then(run, run) + writing.current = next + return next + } + const writeReview = async (url: string, write: ForgeReviewWrite): Promise => (await sendWrite(url, write))?.message const choices = useStore(store => store.byTab[tab.id]) - const [wanted, setWanted] = useState(null) + const [wanted, setWanted] = useState(null) const [treeState, setTreeState] = useState({ query: '', collapsed: {}, selected: null }) const [treePosition, setTreePosition] = useState('left') const [treeWidth, setTreeWidth] = useState(0) @@ -487,7 +560,7 @@ export function ChangesTab({ if (path === undefined) return setTreeState(value => ({ ...value, selected: path })) setChosen(value => ({ ...value, [path]: true })) - setWanted(path) + setWanted({ path, center: null }) }, [choices, navigation.revision, navigation.params, actions, tab.id]) // The tab's choices end with its record. useEffect(() => { @@ -560,11 +633,28 @@ export function ChangesTab({ return () => { controller.abort() } }, [tab.visible, read, refused, waiting, source, localScope, changeCommits, dataKey, localDiff, changeRequestDiff]) const data = loaded !== null && loaded.key === dataKey ? loaded.data : null + // Threads show only while they were read at the head of the latest status and, for the whole change request, at + // the head the shown diff was read at, so their positions number the shown files. + const prHead = entry?.snapshot?.headSha ?? null + const prAuthor = entry?.snapshot?.author ?? null + const threadRead = useThreadRead({ visible: tab.visible, url: source.kind === 'change' ? source.url : null, head: prHead, + updatedAt: entry?.snapshot?.updatedAt ?? null, statusAt, attempt, revision: reviewRevision, read: changeRequestReview }) + const review = data?.kind === 'change' && threadRead.review !== null && threadRead.review.headSha === prHead + && (data.commits !== null || data.headSha === prHead) ? threadRead.review : null + const placed = usePlacedThreads(review, data?.kind === 'change' ? { head: prHead ?? '', end: data.headSha, commits: data.commits } : null) + const [threadList, setThreadList] = useState<{ + readonly open: boolean + readonly filter: ThreadFilter + /** Whether the reader just opened the list and it has not taken the focus yet. */ + readonly claim: boolean + }>({ open: false, filter: 'unresolved', claim: false }) + const threadsToggle = useRef(null) + const [focus, setFocus] = useState(null) const allFiles = useMemo(() => filesInTreeOrder(changedFileTree(data?.files ?? [])), [data]) const files = useMemo(() => allFiles.filter(file => shownBy(file, lens, hideTests)), [allFiles, lens, hideTests]) const lensCounts = useMemo(() => Object.fromEntries(LENSES.map(item => [item, allFiles.filter(file => shownBy(file, item, hideTests)).length])) as Record, [allFiles, hideTests]) - const shownSource = data === null ? null : reviewSourceOf(data) + const shownSource = useMemo(() => data === null ? null : reviewSourceOf(data), [data]) const viewedKey = shownSource === null ? '' : viewedSourceOf(shownSource) const marks = useSyncExternalStore(viewedFiles.subscribe, () => viewedFiles.marksOf(viewedKey)) // The centered loader marks a first read; the toolbar's glyph turns only while shown changes are read again. @@ -582,19 +672,36 @@ export function ChangesTab({ const expandedByDefault = (file: ForgeDiffFile): boolean => initial.has(file.path) && viewedStateOf(marks, file.path, file.digest) !== 'viewed' const isExpanded = (file: ForgeDiffFile): boolean => chosen[file.path] ?? expandedByDefault(file) const sections = useRef(new Map()) - const scrollport = useRef(null) + const scrollport = useRef(null) + const reach = useMemo(() => new SectionReach(), []) + const attachScrollport = useCallback((element: HTMLDivElement | null) => { + scrollport.current = element + reach.attach(element) + }, [reach]) // A short final file cannot align with the viewport top; retain its jump until the reader scrolls away. const jumpPosition = useRef<{ readonly data: ChangesData; readonly top: number } | null>(null) useLayoutEffect(() => { if (!tab.visible || wanted === null || data === null) return // A file the lens or the test switch hides is shown before the view scrolls to it. - const hidden = allFiles.find(file => file.path === wanted && !shownBy(file, lens, hideTests)) + const hidden = allFiles.find(file => file.path === wanted.path && !shownBy(file, lens, hideTests)) if (hidden !== undefined) { if (lens !== 'all') actions.lensed(tab.id, 'all') if (hideTests && hidden.layer === 'test') actions.toggledTests(tab.id) return } - sections.current.get(wanted)?.scrollIntoView({ block: 'start' }) + // The browser's scroll anchoring keeps the section in place while the sections above it draw their lines. + const section = sections.current.get(wanted.path) + section?.scrollIntoView({ block: 'start' }) + // A thread the reader opened from the thread list or a file's unresolved count centers inside its file, which the jump + // expanded and revealed; the jump ends here, so a later jump to the file stops at its header. + const { center } = wanted + if (center !== null) { + /* v8 ignore next 4 -- a centered thread's file is drawn and its card unfolded, so both are found. */ + const card = [...section?.querySelectorAll('[data-forge-thread]') ?? []] + .find(element => element.dataset.forgeThread === center.id) + card?.scrollIntoView({ block: 'center' }) + if (center.focus) card?.focus({ preventScroll: true }) + } if (scrollport.current !== null) jumpPosition.current = { data, top: scrollport.current.scrollTop } setWanted(null) }, [tab.visible, wanted, data, allFiles, lens, hideTests, actions, tab.id]) @@ -655,16 +762,68 @@ export function ChangesTab({ return list } - // A review addresses the head commit, so it is submitted from the view of the whole change request. + // A review addresses the head commit, so it is submitted from the view of the whole change request. A pending review on + // the code host is submitted or discarded there; without one, the summary and any comment drafts kept in the browser + // are published as one review. + const pendingReview = review?.pendingReview ?? null + // Logins compare without case, as GitHub's do. + const ownChangeRequest = review?.viewer != null && prAuthor !== null && review.viewer.toLowerCase() === prAuthor.toLowerCase() + const finishWrite = async (send: () => Promise, done: ForgePanelKey): Promise => { + if (reviewRunning.current) return + reviewRunning.current = true + setReviewBusy(true) + setReviewError(null) + try { + const failure = await send() + if (failure !== undefined) { + setReviewError(failure) + return + } + report(done) + setReviewOpen(false) + } finally { + reviewRunning.current = false + setReviewBusy(false) + } + } const reviewForm = tab.visible && data !== null && reviewOpen && data.kind === 'change' && data.commits === null && { setReviewOpen(false) }} + url={data.url} headSha={data.headSha} pending={pendingReview} own={ownChangeRequest} busy={reviewBusy} error={reviewError} + close={() => { setReviewOpen(false) }} check={() => { openLink(data.url) }} reconcile={() => { queue.finish(false); setReviewError(null) }} submit={() => { const latest = queue.getSnapshot() - void publishReview(data.url, { kind: 'review', headSha: data.headSha, event: latest.event, - body: latest.body, comments: latest.comments.map(item => item.comment) }, url => queue.finish(true, url)) + if (pendingReview === null) { + void publishReview(data.url, { kind: 'review', headSha: data.headSha, event: latest.event, + body: latest.body, comments: latest.comments.map(item => item.comment) }, url => queue.finish(true, url)) + return + } + // The submission is kept as the write to reconcile until the host answers, so an unknown outcome blocks another + // submission, which would otherwise publish a second review once the pending review no longer reads. + void finishWrite(async () => { + if (!queue.begin({ kind: 'review', headSha: data.headSha, event: latest.event, body: latest.body, comments: [] })) { + return t('review.saveFailed') + } + const refusal = await sendWrite(data.url, { kind: 'submit-pending', headSha: data.headSha, review: pendingReview.id, + event: latest.event, body: latest.body }) + if (refusal === undefined) { + if (!queue.finish(true)) report('review.saveFailed') + return undefined + } + if (!refusal.unknown) queue.finish(false) + return refusal.message + }, 'review.submitted') + }} + discard={pendingReview === null ? undefined : () => { + void finishWrite(() => writeReview(data.url, { kind: 'discard-pending', headSha: data.headSha, review: pendingReview.id }), 'review.discarded') }} t={t} /> + /** Add a comment to the pending review on the code host at the head the comment was selected on. */ + const addPending = (selection: ReviewSelection): Promise => { + if (selection.source.kind !== 'change') return Promise.resolve(t('review.stale')) + const { path, side, startLine, endLine, body } = selection.comment + return writeReview(selection.source.url, { kind: 'add-pending', headSha: selection.source.headSha, comment: { path, side, startLine, endLine, body } }) + } + const recordActions: ReviewActions = { source: shownSource ?? (source.kind === 'local' ? { kind: 'local', scope, base: null, baseRef: baseRefOf(localScope), commits: commitsOf(localScope) } @@ -676,10 +835,57 @@ export function ChangesTab({ .filter(path => !data?.files.some(file => file.path === path)) const orphanRecords = absentPaths.map(path => ) - const jump = (path: string): void => { + // Thread groups keep their identity across renders that change neither the threads nor what their cards share, so + // only the sections whose threads changed redraw. + const url = data?.kind === 'change' ? data.url : null + const threadActions = useMemo((): ThreadActions | null => review === null || url === null ? null : { + author: prAuthor, + addToInput: (thread, range) => addThreadToInput(thread, url, range), + openLink, + readText: (commit, path) => changeRequestText(url, commit, path), + inputFailed: () => { live.current.report('thread.inputFailed') }, + focused: focus, + writes: { + write: (write: ThreadWrite) => live.current.writeReview(url, { ...write, headSha: review.headSha }), + reviewStarted: review.pendingReview !== null, + canReview: review.viewerCanReview, + }, + }, [review, url, prAuthor, addThreadToInput, openLink, changeRequestText, focus]) + const fileThreads = useMemo(() => new Map(threadActions === null ? [] + : [...placed.files].map(([path, group]) => [path, { ...group, actions: threadActions }])), [placed.files, threadActions]) + /** Show a file's next unresolved thread in display order after the one last shown, or its first. */ + const nextThread = (path: string): void => { + const order = inFileOrder(placed.entries.filter(({ thread }) => thread.path === path && !thread.resolved), + allFiles.find(file => file.path === path)) + const next = order[(order.findIndex(({ thread }) => thread.id === focus?.id) + 1) % order.length] + /* v8 ignore next -- the count that calls this shows only while the file has an unresolved thread. */ + if (next !== undefined) openThread(next, false) + } + /** Close the thread list at the reader's request and return the focus to its toggle. */ + const closeThreadList = (): void => { + setThreadList(value => ({ ...value, open: false })) + threadsToggle.current?.focus() + } + /** + * Show a thread: all commits for a thread the chosen ones do not hold, whose diff holds the file of every thread with a + * position at the head, then its file, with the thread unfolded and centered; `focusCard` moves the focus to it. A + * thread on a file the shown changes lack opens on the host. + */ + const openThread = ({ thread, place }: PlacedThread, focusCard: boolean): void => { + if (place.kind === 'elsewhere') actions.committed(tab.id, null) + else if (!allFiles.some(file => file.path === thread.path)) { + const url = thread.comments[0]?.url ?? null + if (url !== null) openLink(url) + return + } + setFocus(value => ({ id: thread.id, revision: (value?.revision ?? 0) + 1 })) + setRevealed(value => new Set([...value, thread.path])) + jump(thread.path, { id: thread.id, focus: focusCard }) + } + const jump = (path: string, center: Wanted['center'] = null): void => { setTreeState(value => ({ ...value, selected: path })) setChosen(value => ({ ...value, [path]: true })) - setWanted(path) + setWanted({ path, center }) } /** * The file being read: the file last jumped to while the scroll stays there, else the first file whose section still @@ -728,7 +934,7 @@ export function ChangesTab({ const section = sections.current.get(file.path) const viewport = scrollport.current if (viewed && section !== undefined && viewport !== null - && section.getBoundingClientRect().top < viewport.getBoundingClientRect().top) setWanted(file.path) + && section.getBoundingClientRect().top < viewport.getBoundingClientRect().top) setWanted({ path: file.path, center: null }) // A new mark collapses the file through its default; clearing a mark expands it. setChosen(({ [file.path]: _choice, ...value }) => viewed ? value : { ...value, [file.path]: true }) } @@ -751,6 +957,66 @@ export function ChangesTab({ markViewed(data, file, viewedStateOf(marks, file.path, file.digest) !== 'viewed') } + // What the section handlers and comment actions read when they run, replaced on every render. The assignment happens + // during render rather than in an effect because the file menus read their actions while this same render draws them; + // an effect would hand them the previous diff's links. A render React discards leaves values derived from the same + // props and state, which the retried render assigns again. + const callbacks = { + data, expandedByDefault, expand, markViewed, fileActions, publishReview, writeReview, addPending, report, nextThread, t, + } + const live = useRef(callbacks) + live.current = callbacks + const handlers = useMemo((): DiffFileHandlers => ({ + toggle: (file) => { + const byDefault = live.current.expandedByDefault(file) + setChosen(value => ({ ...value, [file.path]: !(value[file.path] ?? byDefault) })) + }, + reveal: (path) => { setRevealed(value => new Set([...value, path])) }, + expand: (file, index) => { + const { data: owner, expand: open } = live.current + /* v8 ignore next -- sections render only while changes are loaded */ + if (owner !== null) open(owner, file, index) + }, + view: (file, viewed) => { + const { data: owner, markViewed: mark } = live.current + /* v8 ignore next -- sections render only while changes are loaded */ + if (owner !== null) mark(owner, file, viewed) + }, + actions: (file) => { + const { data: owner, fileActions: list } = live.current + /* v8 ignore next -- sections render only while changes are loaded */ + return owner === null ? [] : list(owner, file) + }, + section: (path, element) => { + if (element === null) sections.current.delete(path) + else sections.current.set(path, element) + }, + nextThread: (path) => { live.current.nextThread(path) }, + reach, + }), [reach]) + const reviewStarted = pendingReview !== null + const publishingBlocked = reviewState.uncertain !== null + // Comments on a commit range or on local changes go to the input only; a whole change request also takes GitHub comments: + // into its pending review once the threads say the user may review, and on their own while the user has no pending + // review, since GitHub refuses a single comment beside one. + const whole = data?.kind === 'change' && data.commits === null + const queueable = whole && review?.viewerCanReview === true + const single = whole && pendingReview === null + const fileReview = useMemo((): ReviewActions | undefined => shownSource === null ? undefined : { + source: shownSource, + addToInput: addReviewToInput, + records: queue, openLink, reviewStarted, + onQuoted: (selection) => { if (!queue.recordAgent(selection)) live.current.report('review.saveFailed') }, + publishingBlocked, + ...queueable ? { queue: (selection: ReviewSelection) => live.current.addPending(selection) } : {}, + ...single ? { + publish: (selection: ReviewSelection) => selection.source.kind === 'change' + ? live.current.publishReview(selection.source.url, { kind: 'comment', headSha: selection.source.headSha, comment: selection.comment }, + url => queue.published(selection, url)) + : Promise.resolve(live.current.t('review.stale')), + } : {}, + }, [shownSource, addReviewToInput, queue, openLink, reviewStarted, publishingBlocked, queueable, single]) + let body: ReactNode if (data === null) { if (state === null && snapshot?.disconnected) { @@ -784,7 +1050,7 @@ export function ChangesTab({ body =
{orphanRecords}
{t('lens.empty')}
} else { body = ( -
{ if (jumpPosition.current?.top !== event.currentTarget.scrollTop) jumpPosition.current = null @@ -796,38 +1062,20 @@ export function ChangesTab({ key={path} visible={tab.visible} file={file} - review={{ source: reviewSourceOf(data), - addToInput: addReviewToInput, - records: queue, openLink, reviewStarted: reviewState.comments.length > 0, - onQuoted: (selection) => { if (!queue.recordAgent(selection)) report('review.saveFailed') }, - publishingBlocked: reviewState.uncertain !== null, - ...data.kind === 'local' || data.commits !== null ? {} : { - queue: selection => queue.add(selection), - publish: selection => selection.source.kind === 'change' - ? publishReview(selection.source.url, { kind: 'comment', headSha: selection.source.headSha, comment: selection.comment }, - url => queue.published(selection, url)) - : Promise.resolve(t('review.stale')), - }, - }} + review={fileReview} expanded={isExpanded(file)} - onToggle={() => { setChosen(value => ({ ...value, [path]: !(value[path] ?? expandedByDefault(file)) })) }} 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)} + threads={fileThreads.get(path)} + drawNow={wanted !== null && wanted.center !== null && wanted.path === path} viewed={viewedStateOf(marks, path, file.digest)} - onViewed={(viewed) => { markViewed(data, file, viewed) }} - sectionRef={(element) => { - if (element === null) sections.current.delete(path) - else sections.current.set(path, element) - }} + handlers={handlers} t={t} /> ) @@ -841,6 +1089,7 @@ export function ChangesTab({ filter: (query) => { setTreeState(value => ({ ...value, query })) }, toggle: (path) => { setTreeState(value => ({ ...value, collapsed: { ...value.collapsed, [path]: value.collapsed[path] !== true } })) }, jump, + ...review === null ? {} : { threadCounts: placed.unresolved }, } // The toolbar collapses every file while any is expanded, as the files on screen show, and expands every file otherwise. const anyExpanded = files.some(isExpanded) @@ -866,7 +1115,8 @@ export function ChangesTab({ ? { actions.based(tab.id, next) }} commitMenu={commitMenu} createChangeRequest={createChangeRequest} t={t} /> - : { setReviewOpen(true) }} t={t} />} {!reviewOpen && reviewState.error !== null && {t(reviewState.error === 'load' ? 'review.loadFailed' : 'review.saveFailed')}} {!reviewOpen && reviewState.uncertain !== null && !reviewBusy && source.kind === 'change' &&
@@ -889,6 +1139,14 @@ export function ChangesTab({
) diff --git a/packages/experimental/client-ui-forge/src/client/DiffFile.tsx b/packages/experimental/client-ui-forge/src/client/DiffFile.tsx index a66175afb4..e07bb2f722 100644 --- a/packages/experimental/client-ui-forge/src/client/DiffFile.tsx +++ b/packages/experimental/client-ui-forge/src/client/DiffFile.tsx @@ -1,10 +1,12 @@ /** * 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. + * unchanged lines between them collapsed into rows that expand them. A section + * draws and highlights its lines once it comes within reach of the scrollport; + * it redraws only when its own props change. */ -import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from 'react' +import { memo, useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from 'react' import clsx from 'clsx' import { flushSync } from 'react-dom' import { @@ -14,16 +16,19 @@ import { } 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 { drawnLines, segmentsOf, splitRows, type DiffRow, type SplitCell, type SplitRow } from './diff-rows.ts' import { codeSideAt, readCodeSelection } from './code-selection.ts' import { reviewMatches, selectReviewLines, type ReviewSelection } from './review.ts' import { ReviewThread, useFileReview } from './ReviewThread.tsx' +import { OutdatedThreads, ThreadCard, type FileThreads, type LineThread, type ThreadActions } from './ReviewThreads.tsx' +import { gapOf } from './review-threads.ts' import type { FileReviewState } from './review-queue.ts' import { ReviewComposer, type ReviewActions } from './ReviewComposer.tsx' import reviewCss from './ReviewComposer.module.css' import { FileLabel } from './FileLabel.tsx' import type { ForgePanelKey, NS } from './locales.ts' import type { ViewedState } from './viewed-files.ts' +import type { SectionReach } from './section-reach.ts' import css from './ChangesTab.module.css' type Translate = PropsLocale['t'] @@ -39,30 +44,47 @@ export interface DiffFileAction { /** 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 +/** + * What every file section of one changes view calls. The view passes the same + * object on every render, so a section redraws only when its own values change. + */ +export interface DiffFileHandlers { + /** Expand or collapse a file. */ + readonly toggle: (file: ForgeDiffFile) => void + /** Show a large file anyway. */ + readonly reveal: (path: string) => void + /** Expand one gap of a file. */ + readonly expand: (file: ForgeDiffFile, index: number) => void + /** Mark a file viewed, which also collapses it, or clear the mark. */ + readonly view: (file: ForgeDiffFile, viewed: boolean) => void + /** Rows of a file's menu, read whenever the menu draws. */ + readonly actions: (file: ForgeDiffFile) => readonly DiffFileAction[] + /** Receive a file's section element, which scrolling to the file targets, or null once it unmounts. */ + readonly section: (path: string, element: HTMLElement | null) => void + /** Show a file's next unresolved host thread in display order after the last one shown, or its first. */ + readonly nextThread: (path: string) => void + /** Tells each expanded section when it comes within reach of the scrollport, which draws its lines. */ + readonly reach: SectionReach +} + /** Props of one file section. */ export interface DiffFileProps { /** Whether the owning tab is visible and may show temporary menus. */ readonly visible: boolean /** Comment destinations and the source being reviewed. */ - readonly review?: ReviewActions + readonly review?: ReviewActions | undefined /** 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. */ @@ -71,14 +93,14 @@ export interface DiffFileProps { 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[] /** Whether the reader marked this change viewed, or marked an earlier change of the file. */ readonly viewed: ViewedState - /** Mark the file viewed, which also collapses it, or clear the mark. */ - readonly onViewed: (viewed: boolean) => void - /** Receives the section element, which scrolling to the file targets. */ - readonly sectionRef: (element: HTMLElement | null) => void + /** Review threads of the file from the code host, or undefined while none are read; the view keeps their identity. */ + readonly threads?: FileThreads | undefined + /** Whether the section draws its lines at once, as the target of a thread jump, whose card must exist to be centered. */ + readonly drawNow?: boolean | undefined + /** Actions shared by every section of the view. */ + readonly handlers: DiffFileHandlers /** Forge panel copy. */ readonly t: Translate } @@ -144,8 +166,8 @@ function pairKind(pair: SplitRow): 'context' | 'change' { } /** The hunks of one file with their collapsed and expanded unchanged lines. */ -function Lines({ file, text, open, onExpand, split, wrap, review, visible, comments, t }: Pick & { readonly comments: FileReviewState }): ReactNode { +function Lines({ file, text, open, handlers, split, wrap, review, visible, comments, threads, t }: Pick & { readonly comments: FileReviewState }): ReactNode { const lines = typeof text === 'object' ? text : undefined const columns = split && file.additions > 0 && file.deletions > 0 const singleSide = file.status === 'added' ? 'R' : file.status === 'deleted' ? 'L' : null @@ -313,15 +335,31 @@ function Lines({ file, text, open, onExpand, split, wrap, review, visible, comme remaining.delete(thread) } } + // A host thread follows the row of its last line, or the collapsed unchanged lines that hold it. + const unplaced = new Set(threads === undefined ? [] : threads.lines.map(item => ({ ...item, actions: threads.actions }))) + const threadCard = (item: LineThread & { readonly actions: ThreadActions }, columns: boolean): ReactNode => + + const appendThreads = (old: number | null, next: number | null): void => { + for (const item of unplaced) { + if ((item.place.side === 'L' ? old : next) !== item.place.endLine) continue + rows.push(threadCard(item, columns)) + unplaced.delete(item) + } + } const endsAt = (old: number | null, next: number | null): boolean => selection !== null && (selection.comment.side === 'L' ? old : next) === selection.comment.endLine for (const [at, segment] of prepared.entries()) { if (segment.kind === 'gap') { rows.push() + for (const item of unplaced) { + if (gapOf(file, item.place.side, item.place.endLine) !== segment.index) continue + rows.push(threadCard(item, columns)) + unplaced.delete(item) + } } else if (columns) { for (const [index, pair] of splitRows(segment.rows).entries()) { rows.push(
) } + for (const item of unplaced) rows.push(threadCard(item, false)) if (!inserted) rows.push(editor) return
{rows}
} -function Body(props: DiffFileProps): ReactNode { - const { file, large, revealed, onReveal, review, t } = props +/** Whether a file's body draws its hunks line by line rather than a note or a collapsed strip. */ +function drawsLines(file: ForgeDiffFile, large: boolean, revealed: boolean): boolean { + return !file.binary && !file.oversized && file.hunks.length > 0 && (!large || revealed) +} + +/** The file's lines or the note that replaces them, with its saved comments; host threads on lines follow when no lines show. */ +function Content(props: DiffFileProps): ReactNode { + const { file, large, revealed, handlers, review, threads, t } = props const comments = useFileReview(review, file.path) const records = review === undefined ? null : comments.threads.map(thread => ) - if (file.binary) return <>

{t('diff.binary')}

{records} - if (file.oversized) return <>

{t('diff.fileTooLarge')}

{records} - if (file.hunks.length === 0) return records + const lineThreads = threads?.lines.map(item => ) + if (file.binary) return <>

{t('diff.binary')}

{records}{lineThreads} + if (file.oversized) return <>

{t('diff.fileTooLarge')}

{records}{lineThreads} + if (file.hunks.length === 0) return <>{records}{lineThreads} if (large && !revealed) { // A large file starts as one collapsed strip, drawn and opened like the collapsed unchanged lines. return ( - <>{records} + {records}{lineThreads} ) } return } +/** Host threads on the whole file, the lines, then the host threads that later commits made outdated. */ +function Body(props: DiffFileProps): ReactNode { + const { threads, t } = props + return <> + {threads?.file.map(thread => )} + + {threads !== undefined && } + +} + /** File actions live only while their owning tab is visible. */ -function FileMenu({ name, actions, t }: { +function FileMenu({ name, file, handlers, t }: { readonly name: string - readonly actions: readonly DiffFileAction[] + readonly file: ForgeDiffFile + readonly handlers: DiffFileHandlers readonly t: Translate }): ReactNode { const [menuOpen, setMenuOpen] = useState(false) @@ -422,7 +482,7 @@ function FileMenu({ name, actions, t }: { )} > - {actions.map(action => ( + {handlers.actions(file).map(action => ( { @@ -438,13 +498,29 @@ function FileMenu({ name, actions, t }: { } /** - * Render one file of a diff. - * @param props - the file, its section and gap state, layout choices, menu actions, and copy. + * Render one file of a diff. Until the section first comes within reach of the scrollport or a thread jump targets it, + * an expanded file that draws lines shows a box of about their height instead. + * @param props - the file, its section and gap state, layout choices, shared actions, and copy. * @returns the file section. */ -export function DiffFile(props: DiffFileProps): ReactNode { - const { file, expanded, onToggle, badge, tooltip, actions, viewed, onViewed, sectionRef, t } = props +function DiffFileSection(props: DiffFileProps): ReactNode { + const { file, expanded, large, revealed, badge, tooltip, viewed, threads, handlers, t } = props const name = file.path.slice(file.path.lastIndexOf('/') + 1) + const element = useRef(null) + const [reached, setReached] = useState(false) + // A thread jump draws the section for good, so it does not fall back to the box once the jump ends. + if (props.drawNow === true && !reached) setReached(true) + const waiting = expanded && !reached && props.drawNow !== true && drawsLines(file, large, revealed) + useEffect(() => { + /* v8 ignore next -- React attaches the section before it runs effects */ + if (!waiting || element.current === null) return + return handlers.reach.observe(element.current, () => { setReached(true) }) + }, [waiting, handlers.reach]) + const sectionRef = useCallback((node: HTMLElement | null) => { + element.current = node + handlers.section(file.path, node) + }, [handlers, file.path]) + const placeholder: CSSProperties & { '--forge-rows': number } = { '--forge-rows': drawnLines(file) } return (
@@ -454,7 +530,7 @@ export function DiffFile(props: DiffFileProps): ReactNode { className={css.fileToggle} aria-expanded={expanded} aria-label={t(expanded ? 'change.collapse' : 'change.expand', { name })} - onClick={onToggle} + onClick={() => { handlers.toggle(file) }} > @@ -466,18 +542,30 @@ export function DiffFile(props: DiffFileProps): ReactNode { {viewed === 'changed' && ( {t('viewed.changed')} )} + {threads !== undefined && threads.unresolved > 0 && ( + + + + )} {badge !== '' && {badge}} {!file.binary && } - {props.visible && } + {props.visible && }
- {expanded && } + {expanded && (waiting ?
: )} ) } + +/** One file of a diff, redrawn only when its props change. */ +export const DiffFile = memo(DiffFileSection) diff --git a/packages/experimental/client-ui-forge/src/client/ReviewComposer.tsx b/packages/experimental/client-ui-forge/src/client/ReviewComposer.tsx index 94407dfee8..82e482c35f 100644 --- a/packages/experimental/client-ui-forge/src/client/ReviewComposer.tsx +++ b/packages/experimental/client-ui-forge/src/client/ReviewComposer.tsx @@ -15,7 +15,7 @@ export interface ReviewActions { readonly source: ReviewSource /** Browser records shared by the changes tab. */ readonly records?: ReviewQueue - /** Whether another comment already started a pending review. */ + /** Whether the signed-in user has a pending review on the code host that the next comment joins. */ readonly reviewStarted?: boolean /** Open the confirmed publication URL. */ readonly openLink?: (url: string) => void @@ -27,26 +27,32 @@ export interface ReviewActions { readonly onQuoted?: (selection: ReviewSelection) => void /** Publish one comment; an error string preserves the editor. */ readonly publish?: (selection: ReviewSelection) => Promise - /** Keep one comment for the final review; false preserves the editor. */ - readonly queue?: (selection: ReviewSelection) => boolean + /** Add one comment to the pending review on the code host, starting one when needed; an error string preserves the editor. */ + readonly queue?: (selection: ReviewSelection) => Promise } -function GitHubAction({ disabled, busy, started, queue, publish, t }: PropsLocale & { +/** + * Render a primary action with an optional second action in its menu. + * @param props - the main action, the optional menu action, the menu's name, and availability. + * @returns the split button. + */ +export function SplitAction({ disabled, label, run, menuLabel, menu, other }: { readonly disabled: boolean - readonly busy: boolean - readonly started: boolean - readonly queue: () => void - readonly publish: (() => void) | undefined + readonly label: string + readonly run: () => void + /** Accessible name of the menu button. */ + readonly menuLabel: string + /** Label of the action in the menu, or undefined without one. */ + readonly other: string | undefined + readonly menu: () => void }): ReactNode { const [open, setOpen] = useState(false) return
- - {publish !== undefined && { setOpen(false) }} + + {other !== undefined && { setOpen(false) }} anchor={}> - { setOpen(false); publish() }}>{t('review.publish')} + aria-label={menuLabel} onClick={() => { setOpen(value => !value) }}>}> + { setOpen(false); menu() }}>{other} }
} @@ -73,13 +79,14 @@ export function ReviewComposer({ selection, actions, close, visible = true, spli useEffect(() => { input.current?.focus() }, []) const comment = (): ReviewSelection => ({ ...selection, comment: { ...selection.comment, body: body.current } }) const range = reviewRange(selection.comment) - const publish = async (): Promise => { - if (running.current || actions.publish === undefined) return + /** Send the comment to the code host, closing the form once it is taken and keeping its text otherwise. */ + const send = async (destination: ((selection: ReviewSelection) => Promise) | undefined): Promise => { + if (running.current || destination === undefined) return running.current = true setBusy(true) setError(null) try { - const failure = await actions.publish(comment()) + const failure = await destination(comment()) if (failure === undefined) close() else setError(failure) } catch (_error) { @@ -108,14 +115,12 @@ export function ReviewComposer({ selection, actions, close, visible = true, spli if (actions.addToInput(selected)) { actions.onQuoted?.(selected); close() } else setError(t('review.inputFailed')) }}>{t('review.addToInput')} - {visible && actions.queue !== undefined && { - if (actions.queue?.(comment()) === true) close() - else setError(t('review.saveFailed')) - }} publish={actions.publish === undefined ? undefined : () => { void publish() }} t={t} />} + {visible && actions.queue !== undefined && { void send(actions.queue) }} menuLabel={t('review.githubActions')} + other={actions.publish === undefined ? undefined : t('review.publish')} menu={() => { void send(actions.publish) }} />} {actions.queue === undefined && actions.publish !== undefined && } + disabled={busy || actions.publishingBlocked === true || empty} onClick={() => { void send(actions.publish) }}>{t('review.publish')}}
diff --git a/packages/experimental/client-ui-forge/src/client/ReviewControls.module.css b/packages/experimental/client-ui-forge/src/client/ReviewControls.module.css index a08dc8e88e..655cf83171 100644 --- a/packages/experimental/client-ui-forge/src/client/ReviewControls.module.css +++ b/packages/experimental/client-ui-forge/src/client/ReviewControls.module.css @@ -1,6 +1,7 @@ -/* Review controls of the changes toolbar. The lens draws the segmented track and raised indicator of the shared - segmented control at the toolbar's 28px control height and 12px text, with each segment's file count after its label. */ -.lens { +/* Review controls of the changes toolbar. The segmented radio group of the lens and the thread list's filter draws the + track and raised indicator of the shared segmented control at the toolbar's 28px control height and 12px text, with + each segment's count after its label. */ +.segmented { box-sizing: border-box; display: grid; grid-auto-columns: minmax(0, 1fr); diff --git a/packages/experimental/client-ui-forge/src/client/ReviewControls.tsx b/packages/experimental/client-ui-forge/src/client/ReviewControls.tsx index 735c0148c4..b6765c158a 100644 --- a/packages/experimental/client-ui-forge/src/client/ReviewControls.tsx +++ b/packages/experimental/client-ui-forge/src/client/ReviewControls.tsx @@ -1,7 +1,7 @@ /** * Review controls of the changes toolbar: the lens that keeps the files of one review layer, as a full-width * radio group above the file tree or as a menu where no tree shows; the switch that hides test files; and the - * share of shown files the reader marked viewed. + * share of shown files the reader marked viewed. The thread list shares the segmented radio group. */ import { useRef, useState, type KeyboardEvent, type ReactNode } from 'react' @@ -33,17 +33,35 @@ export interface LensProps extends PropsLocale { readonly choose: (lens: ChangesLens) => void } +/** One segment of a {@link SegmentedChoice}. */ +export interface Segment { + /** Value the segment chooses. */ + readonly value: Value + /** Localized label. */ + readonly label: string + /** Count shown after the label, or null for none. */ + readonly count: number | null + /** Localized hover text of a disabled segment, or null for an enabled one. */ + readonly disabledTip: string | null +} + /** - * Render the lens as a radio group whose segments share its width; arrow keys, Home, and End move between enabled lenses. - * @param props - the lens, its counts, whether test files hide, the choose action, and copy. + * Render a radio group whose segments share its width, each with a count after its label; arrow keys, Home, and End + * move between enabled segments. + * @param props - the segments, the chosen value, the choose action, and the group's localized accessible name. * @returns the radio group. */ -export function LensControl({ lens, counts, hideTests, choose, t }: LensProps): ReactNode { +export function SegmentedChoice({ segments, value, choose, label }: { + readonly segments: readonly Segment[] + readonly value: Value + readonly choose: (value: Value) => void + readonly label: string +}): ReactNode { const group = useRef(null) - const enabled = LENSES.filter(item => item !== 'test' || !hideTests) + const enabled = segments.filter(segment => segment.disabledTip === null).map(segment => segment.value) const move = (event: KeyboardEvent): void => { - const at = enabled.indexOf(lens) - let next: ChangesLens | undefined + const at = enabled.indexOf(value) + let next: Value | undefined switch (event.key) { case 'ArrowLeft': case 'ArrowUp': next = enabled[(at + enabled.length - 1) % enabled.length]; break case 'ArrowRight': case 'ArrowDown': next = enabled[(at + 1) % enabled.length]; break @@ -53,28 +71,40 @@ export function LensControl({ lens, counts, hideTests, choose, t }: LensProps): } event.preventDefault() event.stopPropagation() - /* v8 ignore next -- at most the test lens is disabled, so every key lands on an enabled lens */ + /* v8 ignore next -- every caller keeps a segment enabled, so every key lands on one */ if (next === undefined) return choose(next) - group.current?.querySelector(`[data-forge-lens="${next}"]`)?.focus() + group.current?.querySelector(`[data-segment="${next}"]`)?.focus() } return ( -
- {LENSES.map((item) => { - const disabled = item === 'test' && hideTests +
+ {segments.map((segment) => { const button = ( - ) - return disabled ? {button} : button + return segment.disabledTip === null ? button : {button} })}
) } +/** + * Render the lens as a radio group whose segments share its width. + * @param props - the lens, its counts, whether test files hide, the choose action, and copy. + * @returns the radio group. + */ +export function LensControl({ lens, counts, hideTests, choose, t }: LensProps): ReactNode { + // A disabled lens shows no count, since hidden test files are not counted. + return item === 'test' && hideTests + ? { value: item, label: t(LENS_LABEL[item]), count: null, disabledTip: t('lens.testHidden') } + : { value: item, label: t(LENS_LABEL[item]), count: counts[item], disabledTip: null })} /> +} + /** * Render the lens as a menu button, for a toolbar without the file tree beside it. * @param props - the lens, its counts, whether test files hide, the choose action, copy, and whether the owning tab is visible. diff --git a/packages/experimental/client-ui-forge/src/client/ReviewThread.tsx b/packages/experimental/client-ui-forge/src/client/ReviewThread.tsx index f626c2dc47..7abb3a7f67 100644 --- a/packages/experimental/client-ui-forge/src/client/ReviewThread.tsx +++ b/packages/experimental/client-ui-forge/src/client/ReviewThread.tsx @@ -1,4 +1,4 @@ -/** Inline comment records and editing of a pending review at its captured code location. */ +/** Inline comment records and the editing of comment drafts kept in the browser at their captured code location. */ import { useCallback, useState, useSyncExternalStore, type ReactNode } from 'react' import { Button, Tag } from '@deepseek-ai/dsh-client-ui-primitives' @@ -24,7 +24,8 @@ export function useFileReview(actions: ReviewActions | undefined, path: string): } /** - * Render a saved comment at its line, with pending edits kept in the browser queue. + * Render a saved comment at its line. A comment draft kept in the browser can be edited, deleted, or moved into the + * pending review on the code host. * @param props - record, edit availability, source actions, and localized copy. * @returns one compact inline comment card. */ @@ -38,11 +39,27 @@ export function ReviewThread({ thread, actions, locked, outdated, split, t }: Pr const [editing, setEditing] = useState(false) const [body, setBody] = useState(thread.selection.comment.body) const [error, setError] = useState(null) + const [moving, setMoving] = useState(false) const comment = thread.selection.comment const quoted = editing && thread.kind === 'pending' ? { ...thread.selection, comment: { ...comment, body } } : thread.selection const range = reviewRange(comment) - const status = thread.kind === 'pending' ? 'review.pending' : thread.kind === 'published' ? 'review.published' : 'review.quoted' + const status = thread.kind === 'pending' ? 'review.browserDraft' : thread.kind === 'published' ? 'review.published' : 'review.quoted' const save = (): boolean => actions.records?.editThread(thread.key, body) === true + const queue = actions.queue + /** Add the draft to the pending review on the code host, then delete it from the browser. */ + const move = async (add: NonNullable): Promise => { + setMoving(true) + setError(null) + try { + const failure = await add(thread.selection) + if (failure !== undefined) setError(failure) + else if (actions.records?.editThread(thread.key, null) !== true) setError(t('review.saveFailed')) + } catch (_error) { + setError(t('review.unknown')) + } finally { + setMoving(false) + } + } return
@@ -65,13 +82,15 @@ export function ReviewThread({ thread, actions, locked, outdated, split, t }: Pr }}>{t('review.addToInput')} {thread.kind === 'pending' && <> {editing - ? - : } - } + + {queue !== undefined && !editing && !outdated && } } {thread.kind === 'published' && actions.openLink !== undefined && }
diff --git a/packages/experimental/client-ui-forge/src/client/ReviewThreads.module.css b/packages/experimental/client-ui-forge/src/client/ReviewThreads.module.css new file mode 100644 index 0000000000..e4363071da --- /dev/null +++ b/packages/experimental/client-ui-forge/src/client/ReviewThreads.module.css @@ -0,0 +1,325 @@ +/* Review thread cards share the inline review form's card material; comment rows follow the conversation's 13px detail size. */ +.card, +.folded { + box-sizing: border-box; + width: calc(100% - 20px); + max-width: 720px; + margin: 10px; + border: 0.5px solid var(--dsw-alias-border-l3); + border-radius: var(--dsw-radius-md); + background: var(--dsw-alias-bg-layer-1); + color: var(--dsw-alias-label-primary); + font: var(--dsw-font-xs-13); + font-family: Inter, var(--dsw-font-family); + white-space: normal; +} + +.card { padding: 10px 12px; } + +/* A card a jump from a covering thread list focused; a pointer jump shows no ring. */ +.card:focus { outline: none; } + +.card:focus-visible { + outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); + outline-offset: -1px; +} + +.card[data-split][data-side="R"], +.folded[data-split][data-side="R"] { + margin-inline-start: clamp(10px, calc(100% - 740px), calc(50% + 10px)); +} + +.card[data-resolved] { + /* An elevated surface: the excerpt and the file at that commit scroll inside it, so the thumb takes the l2 pair, + which inherits down to them (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); + background: var(--dsw-alias-bg-layer-2); +} + +.folded { + display: flex; + align-items: center; + gap: 6px; + min-height: 32px; + padding: 4px 12px; + color: var(--dsw-alias-label-secondary); +} + +.folded > svg { flex: none; color: var(--dsw-alias-state-success-primary); } + +.foldedText { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.header { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + margin-bottom: 8px; +} + +.place { + min-width: 0; + color: var(--dsw-alias-label-secondary); + font-size: 12px; + overflow-wrap: anywhere; +} + +.spacer { flex: 1; } + +/* Text actions inside a card: no fill, accent ink, the same height as the tags beside them. */ +.link { + display: inline-flex; + flex: none; + align-items: center; + gap: 2px; + padding: 0; + border: 0; + background: transparent; + color: var(--dsw-alias-state-business-primary); + font: inherit; + font-size: 12px; + cursor: pointer; +} + +.link:disabled { color: var(--dsw-alias-label-dimmed); cursor: default; } + +.link:focus-visible { + border-radius: var(--dsw-radius-xs); + outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); + outline-offset: 2px; +} + +@media (hover: hover) { + .link:not(:disabled):hover { text-decoration: underline; } +} + +.turned { transform: rotate(-90deg); } + +.comments { + display: flex; + flex-direction: column; + gap: 12px; +} + +.comment { + display: flex; + gap: 8px; + min-width: 0; +} + +.avatar { + display: inline-flex; + flex: none; + align-items: center; + justify-content: center; + overflow: hidden; + border-radius: 50%; + corner-shape: round; + background: var(--dsw-alias-interactive-bg-hover); + color: var(--dsw-alias-label-secondary); + font-weight: 500; + line-height: 1; + user-select: none; +} + +.avatar img { + display: block; + width: 100%; + height: 100%; + border-radius: 50%; + corner-shape: round; + object-fit: cover; +} + +.commentMain { + flex: 1; + min-width: 0; +} + +.commentHeader { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + min-height: 20px; +} + +.login { font-weight: 500; overflow-wrap: anywhere; } + +.time { color: var(--dsw-alias-label-tertiary); font-size: 12px; } + +.body { + min-width: 0; + margin-top: 2px; + overflow-wrap: anywhere; +} + +.actions { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin: 10px -8px -4px; +} + +/* A reply or a comment edit uses the inline review form's field; its actions sit under it like the form's. */ +.form { margin-top: 10px; } + +.formActions { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + gap: 8px; + margin-top: 8px; +} + +/* The collapsed reply field, a field-shaped button that opens the reply form. */ +.replyPrompt { + display: block; + box-sizing: border-box; + width: 100%; + min-height: 32px; + margin-top: 10px; + padding: 6px 12px; + border: 0.5px solid var(--dsw-alias-border-l4); + border-radius: var(--dsw-radius-md); + background: var(--dsw-alias-bg-layer-3); + color: var(--dsw-alias-label-dimmed); + font: inherit; + text-align: start; + cursor: text; +} + +.replyPrompt:focus-visible { + outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); + outline-offset: 2px; +} + +.menuAnchor { display: inline-flex; flex: none; } + +/* The comment menu's icon button keeps the 20px row height of the header it ends. */ +.icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + padding: 0; + border: 0; + border-radius: var(--dsw-radius-xs); + background: transparent; + color: var(--dsw-alias-label-secondary); + cursor: pointer; +} + +.icon[aria-expanded="true"] { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-primary); } + +.icon:focus-visible { + outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); + outline-offset: 1px; +} + +@media (hover: hover) { + .icon:hover { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-primary); } +} + +/* Confirming a deletion replaces nothing: the comment stays readable above the question. */ +.confirm { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + margin-top: 8px; +} + +.confirmText { flex: 1 1 auto; min-width: 0; color: var(--dsw-alias-label-secondary); overflow-wrap: anywhere; } + +.confirmError, +.actionError { flex-basis: 100%; margin-top: 0; } + +.actionError { padding-inline: 8px; } + +/* The end of the recorded hunk, drawn like the diff rows it came from. */ +.excerpt, +.original { + margin-bottom: 10px; + overflow-x: auto; + border: 0.5px solid var(--dsw-alias-border-l2); + border-radius: var(--dsw-radius-sm); + font: var(--dsw-font-markdown-code-block); +} + +.excerptLine, +.originalLine { + min-height: 20px; + padding: 0 10px; + line-height: 20px; + white-space: pre; +} + +.excerptHeader { color: var(--dsw-alias-label-tertiary); } + +.added { background: var(--dsw-alias-file-diff-added-bg); } + +.deleted { background: var(--dsw-alias-file-diff-deleted-bg); } + +.original { + max-height: 320px; + overflow: auto; +} + +.originalLine { + display: grid; + grid-template-columns: max-content minmax(0, 1fr); + gap: 12px; +} + +.originalNumber { + min-width: 3ch; + color: var(--dsw-alias-label-tertiary); + text-align: right; + user-select: none; +} + +.marked { background: var(--dsw-alias-interactive-bg-hover); } + +.originalNote { + padding: 8px 10px; + color: var(--dsw-alias-label-secondary); + font-family: Inter, var(--dsw-font-family); +} + +/* Outdated threads of one file, after its lines. */ +.outdated { + border-top: 0.5px solid var(--dsw-alias-border-l3); + padding-bottom: 2px; +} + +.outdatedHeader { + display: flex; + align-items: center; + gap: 6px; + min-height: 32px; + padding: 0 12px; + color: var(--dsw-alias-label-secondary); + font: var(--dsw-font-xs-13); + font-family: Inter, var(--dsw-font-family); +} + +/* A changes view narrower than 480px gives the code more width; the query names no container, since CSS Modules rename + a container name per file, and the nearest inline-size container is the changes view. */ +@container (max-width: 479px) { + .card, + .folded, + .card[data-split][data-side="R"], + .folded[data-split][data-side="R"] { + width: calc(100% - 12px); + margin: 6px; + } +} diff --git a/packages/experimental/client-ui-forge/src/client/ReviewThreads.tsx b/packages/experimental/client-ui-forge/src/client/ReviewThreads.tsx new file mode 100644 index 0000000000..658356b09e --- /dev/null +++ b/packages/experimental/client-ui-forge/src/client/ReviewThreads.tsx @@ -0,0 +1,445 @@ +/** + * Review threads of a change request inside the changes view: a thread card at its lines or at the top of its file, a + * resolved thread folded into one row, and the outdated threads of a file in a section after its lines. A card offers + * the writes the signed-in user may make: a reply, published at once or added to the pending review, resolving or + * unresolving the thread, and editing or deleting the user's own comments. + */ + +import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react' +import clsx from 'clsx' +import { + Button, IconCheckOutlineRegular, IconChevronDownOutlineRegular, IconEllipsisOutlineRegular, IconRightUpOutlineRegular, MarkdownText, + Menu, Tag, Tooltip, type MarkdownLabels, +} from '@deepseek-ai/dsh-client-ui-primitives' +import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' +import type { ForgeReviewComment, ForgeReviewThread, ForgeReviewWrite, ForgeThreadId } from '@deepseek-ai/dsh-experimental-forge/types' +import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' +import { formatAgo } from './format.ts' +import { anchorRange, hunkExcerpt, type ThreadPlace } from './review-threads.ts' +import { SplitAction } from './ReviewComposer.tsx' +import type { NS } from './locales.ts' +import css from './ReviewThreads.module.css' +import composerCss from './ReviewComposer.module.css' + +type Translate = PropsLocale['t'] + +/** Each member of a union without its `headSha`. */ +type WithoutHead = T extends unknown ? Omit : never + +/** A write to one thread or its comments, without the head commit the changes view adds. */ +export type ThreadWrite = WithoutHead> + +/** Writes the signed-in user can make to the threads of one changes view. */ +export interface ThreadWrites { + /** Send a write; resolves to the message to show, or undefined once the code host took it. */ + readonly write: (write: ThreadWrite) => Promise + /** Whether the user has a pending review, which every reply then joins. */ + readonly reviewStarted: boolean + /** Whether the user can add a reply to a pending review, starting one when needed. */ + readonly canReview: boolean +} + +/** Reads, actions, and identities every thread card of one changes view shares. */ +export interface ThreadActions { + /** Login of the change request's author, or null when the host hides it. */ + readonly author: string | null + /** Quote the whole thread in the main input without sending it; false when the input refused it. */ + readonly addToInput: (thread: ForgeReviewThread, range: string | null) => boolean + /** Open a URL outside the app. */ + readonly openLink: (url: string) => void + /** Read a file of the change request's repository at a commit. */ + readonly readText: (commit: string, path: string) => Promise> + /** Report that the input refused a thread. */ + readonly inputFailed: () => void + /** The latest jump to a thread, which unfolds that thread and its section until the reader folds them again; null before any. */ + readonly focused: ThreadFocus | null + /** Writes the user can make, or null when the code host takes none from this view. */ + readonly writes: ThreadWrites | null +} + +/** One jump to a thread; each jump has its own revision, so a jump to the same thread unfolds it again. */ +export interface ThreadFocus { + readonly id: ForgeThreadId + readonly revision: number +} + +/** A host thread shown on lines of one side. */ +export interface LineThread { + readonly thread: ForgeReviewThread + readonly place: Extract +} + +/** Host threads of one file and the actions their cards share. */ +export interface FileThreads { + /** Threads on lines, which follow the row of their last line. */ + readonly lines: readonly LineThread[] + /** Threads on the whole file, shown above its lines. */ + readonly file: readonly ForgeReviewThread[] + /** Threads no longer on the head's lines, shown after them. */ + readonly outdated: readonly ForgeReviewThread[] + /** Unresolved threads of the file in every place, including threads the chosen commits do not show. */ + readonly unresolved: number + /** Reads, actions, and identities shared by the cards. */ + readonly actions: ThreadActions +} + +/** The commit a thread was written on, abbreviated, or null for a file thread. */ +function writtenOn(thread: ForgeReviewThread): string | null { + return thread.original === null ? null : thread.original.commit.slice(0, 7) +} + +/** + * Build the Markdown chrome copy of comment bodies. + * @param t - forge panel copy. + * @returns labels for code fences and footnotes. + */ +export function useMarkdownLabels(t: Translate): MarkdownLabels { + return useMemo(() => ({ + code: { copyLabel: t('thread.copy'), copiedLabel: t('thread.copied'), + toolbarLabels: { codeLabel: t('thread.codeBlock'), wrapLabel: t('thread.wrap'), unwrapLabel: t('thread.unwrap') } }, + footnotes: t('thread.footnotes'), + }), [t]) +} + +/** + * Render an author's avatar, or the first letter of the login when the image is missing or fails. + * @param props - the comment's author and the avatar size. + * @returns the round avatar. + */ +export function Avatar({ author, size = 20 }: { readonly author: ForgeReviewComment['author']; readonly size?: number }): ReactNode { + const [failed, setFailed] = useState(false) + const login = author?.login ?? '?' + return +} + +/** Run one write at a time for a card section, keeping its error until the next attempt. */ +function useWriting(t: Translate): { + readonly busy: boolean + readonly error: string | null + readonly run: (send: () => Promise) => Promise +} { + const [busy, setBusy] = useState(false) + const [error, setError] = useState(null) + const running = useRef(false) + const run = async (send: () => Promise): Promise => { + if (running.current) return false + running.current = true + setBusy(true) + setError(null) + try { + const failure = await send() + setError(failure ?? null) + return failure === undefined + } catch (_error) { + setError(t('thread.writeUnknown')) + return false + } finally { + running.current = false + setBusy(false) + } + } + return { busy, error, run } +} + +/** A plain-text field with Cancel and the actions that send it, for a reply or a comment edit. */ +function TextForm({ label, initial, busy, error, cancel, children }: { + readonly label: string + readonly initial: string + readonly busy: boolean + readonly error: string | null + readonly cancel: { readonly label: string; readonly run: () => void } + /** The sending actions, given the current text and whether it is blank. */ + readonly children: (text: string, blank: boolean) => ReactNode +}): ReactNode { + const [text, setText] = useState(initial) + return
+