Merge branch 'master' into fix/ci-21078ac-scan-dot-dirs
This commit is contained in:
commit
a6fc2ac61b
126 files changed
+1937
-560
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 寿命均不代表任务优先级。等待模型期间不占用应用。需要确定执行顺序时,由明确的任务协调决定。
|
||||
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
|
||||
+1
-1
@@ -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。应用级观察刷新窗口清单,单窗口观察保留其他窗口的图片映射。窗口尺寸变化使旧图片失效,移动窗口保留局部坐标。相对坐标的端点保持在窗口内,不随截图分辨率改变。
|
||||
|
||||
|
||||
+19
@@ -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
|
||||
@@ -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.
|
||||
@@ -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 请求。
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -141,7 +141,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 +152,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: {
|
||||
@@ -1175,6 +1175,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 +1254,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<void>((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')
|
||||
|
||||
@@ -1156,15 +1156,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 +1186,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:279`](../packages/experimental/forge/src/types.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Poll cadence and fetch deadline for watched change requests. */
|
||||
@@ -1561,7 +1559,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 +1578,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
|
||||
}
|
||||
```
|
||||
|
||||
@@ -1158,15 +1158,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 +1188,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:279`](../packages/experimental/forge/src/types.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Poll cadence and fetch deadline for watched change requests. */
|
||||
@@ -1563,7 +1561,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 +1580,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
|
||||
}
|
||||
```
|
||||
|
||||
@@ -71,9 +71,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<string | ForgeFailure>
|
||||
diff(ref: ChangeRequestRef, headSha: string, signal: AbortSignal, base?: string): Promise<string | ForgeFailure>
|
||||
/** Read the text of `path` at `commit` in the change request's repository. */
|
||||
fileText(ref: ChangeRequestRef, commit: string, path: string, signal: AbortSignal): Promise<string | ForgeFailure>
|
||||
/** Build the web page of `path` at `commit` in the change request's repository. */
|
||||
@@ -199,6 +200,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. */
|
||||
@@ -907,7 +913,8 @@ async fetchChecks(ref: ChangeRequestRef, signal: AbortSignal): Promise<ChangeReq
|
||||
* computes them. The first `stale` failure at a head commit polls a watched
|
||||
* change request again at once, so its next status names the current head;
|
||||
* later stale reads at the same commit wait for the regular poll, which
|
||||
* bounds the reads while the host's answers disagree.
|
||||
* bounds the reads while the host's answers disagree. When the latest status of a watched change request names
|
||||
* `headSha`, its `baseSha` goes to the provider, which may then read the patch while it confirms the head.
|
||||
* @param ref - change request served by a registered provider.
|
||||
* @param headSha - head commit the patch must describe, usually the head of the latest status.
|
||||
* @param signal - cancellation of the read.
|
||||
|
||||
@@ -71,9 +71,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<string | ForgeFailure>
|
||||
diff(ref: ChangeRequestRef, headSha: string, signal: AbortSignal, base?: string): Promise<string | ForgeFailure>
|
||||
/** Read the text of `path` at `commit` in the change request's repository. */
|
||||
fileText(ref: ChangeRequestRef, commit: string, path: string, signal: AbortSignal): Promise<string | ForgeFailure>
|
||||
/** Build the web page of `path` at `commit` in the change request's repository. */
|
||||
@@ -199,6 +200,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. */
|
||||
@@ -907,7 +913,8 @@ async fetchChecks(ref: ChangeRequestRef, signal: AbortSignal): Promise<ChangeReq
|
||||
* computes them. The first `stale` failure at a head commit polls a watched
|
||||
* change request again at once, so its next status names the current head;
|
||||
* later stale reads at the same commit wait for the regular poll, which
|
||||
* bounds the reads while the host's answers disagree.
|
||||
* bounds the reads while the host's answers disagree. When the latest status of a watched change request names
|
||||
* `headSha`, its `baseSha` goes to the provider, which may then read the patch while it confirms the head.
|
||||
* @param ref - change request served by a registered provider.
|
||||
* @param headSha - head commit the patch must describe, usually the head of the latest status.
|
||||
* @param signal - cancellation of the read.
|
||||
|
||||
@@ -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
|
||||
@@ -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 <path>` 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 <path>` 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.
|
||||
@@ -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 <path>` 锁定它,或在插件页 Forge 套件的 worktree 行上开启**归档时不删除工作树**(`retainOnArchive`),它会保留所有检出目录。被忽略的文件不会保留。
|
||||
1. 如果检出目录必须供 DSH 之外继续使用,请保持其 Session 未归档,在归档前用 `git worktree lock <path>` 锁定它,或在插件页 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 日志无需迁移。
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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 后需要重新授权。
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 和未匹配的扩展名保留纯文本回退,与加载方式无关。
|
||||
|
||||
@@ -73,7 +73,7 @@ export function PreviewFrame({
|
||||
{handoff}
|
||||
<NavigatorToggleSeat useTabInfo={useTabInfo} renderFactorySlot={renderFactorySlot} />
|
||||
</div>
|
||||
<div className={css.content} data-textpreview-content>
|
||||
<div className={css.content} data-textpreview-content data-files-navigator-row>
|
||||
{children}
|
||||
<NavigatorSeat useTabInfo={useTabInfo} renderFactorySlot={renderFactorySlot} />
|
||||
</div>
|
||||
|
||||
@@ -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<HTMLElement>('[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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -41,7 +41,7 @@ The [registration](src/client/index.ts) assembles the page, navigator, toggle, a
|
||||
<a id="the-navigator"></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.
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ kind: "package-reference"
|
||||
<a id="the-navigator"></a>
|
||||
## 导航栏
|
||||
|
||||
用 `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 也不关闭它。文件页打开的文件沿用落定分栏自己的选择,尚未做过选择的分栏为隐藏。
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -113,7 +113,7 @@ export function FilesBody(props: FilesBodyProps): ReactNode {
|
||||
</Tooltip>
|
||||
{renderSlot('sidebar.right.tab.files.actions', { absolutePath: cwd })}
|
||||
</div>
|
||||
<div className={css.content} data-files-content>{navigator}</div>
|
||||
<div className={css.content} data-files-content data-files-navigator-row>{navigator}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
en: c7e1b7d87ccccd2e
|
||||
zh: 42d864b918a6175c
|
||||
/deepseek-ai-dsh-experimental-client-ui-forge/understand-the-implementation:
|
||||
en: 7495fe1e0d0ee643
|
||||
zh: f820a5790ae6e2a7
|
||||
en: eaf062b1439ed8b3
|
||||
zh: d7c6a68779774825
|
||||
/deepseek-ai-dsh-experimental-client-ui-forge/further-exploration:
|
||||
en: fa4d8c579ffc49ff
|
||||
zh: 5856ed7469e4585b
|
||||
@@ -30,8 +30,8 @@
|
||||
en: 2bc25a99e17789c4
|
||||
zh: 4bec723fbf249d28
|
||||
/deepseek-ai-dsh-experimental-client-ui-forge/known-limitations-and-deferred-work:
|
||||
en: 5e29ac36aad15d02
|
||||
zh: e46339bb637a69ef
|
||||
en: 4124d453778daf7b
|
||||
zh: 33cab3137381942e
|
||||
/deepseek-ai-dsh-experimental-client-ui-forge/known-limitations-and-deferred-work/dev-note:
|
||||
en: e67f633dc6fe7773
|
||||
zh: 3bb0c20c93f16f98
|
||||
@@ -58,7 +58,7 @@ All files opens a searchable directory tree with the selected file scrolled to i
|
||||
<details>
|
||||
<summary>Implementation internals — click to expand</summary>
|
||||
|
||||
`ForgePanelController` keeps one `SessionPanel` per followed Session and disposes it when the last follower leaves. A panel starts with the Session's working directory and bindings, re-inspects after each `working-directory/change`, `tool/result`, and `sandbox/mode` event, and publishes a new `ForgePanelState` for every change. Each change request keeps the latest snapshot a poll returned while later polls fail. A failed parallel Git read cancels and joins its siblings. Inspections run one at a time with one trailing rerun, and a result for a directory the Session has since left is dropped. The code host comes from the remotes in order: the upstream's remote, then `origin`, then the rest. When an inspection finds an upstream branch on a remote a provider recognizes, the panel asks `ctx.forgeSession.discoverBranch` to bind that branch's open change requests, once per remote and branch until a refresh. A sign-in probe runs when the recognized host changes and on refresh, within `readinessTimeoutMs`; a refresh settles once the inspection and the probe it started have ended, and poll results arrive later.
|
||||
`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=<maxBranches>`; `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 +69,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/<remote>/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 :<path>` for Staged and through `ctx.fs` otherwise, and refuses a path that leaves the checkout. `changeRequestDiff` and `changeRequestText` act only on a visible binding a provider serves, else they refuse with `forgePanel/not-bound`; they read the head commit of the binding's latest status through the provider's `diff` and `fileText`, refuse a `too-large` failure with `forgePanel/diff-too-large`, 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 Line truncated
|
||||
`localDiff` takes the fork point from the created worktree's `baseCommit` when the Session works in it, else, for each remote in the code host order, from `symbolic-ref --quiet --short refs/remotes/<remote>/HEAD` and `merge-base HEAD`; without one the `branch` scope refuses with `forgePanel/no-fork-point`. 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 :<path>` for Staged and through `ctx.fs` otherwise, and refuses a path that leaves the checkout. `changeRequestDiff` and `changeRequestText` act only on a visible binding a provider serves, else they refuse with `forgePanel/not-bound`; they read the head commit of the binding's latest status through the provider's `diff` and `fileText`, 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 tLine truncated
|
||||
|
||||
`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/<session>/local` and `dsh-resource://forge-changes/<session>/change/<url>`, name the source, and a navigation may name a file to scroll to. The tab type enables `keepMounted`, retaining visited bodies and their owning Session references until their tab records end. Its 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.<Session>`, 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/<session>/local` and `dsh-resource://forge-changes/<session>/change/<url>`, name the source, and a navigation may name a file to scroll to. The tab type enables `keepMounted`, retaining visited bodies and their owning Session references until their tab records end. Its 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.<Session>`, 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, 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,6 +92,7 @@ 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 |
|
||||
@@ -139,6 +140,8 @@ 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 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.
|
||||
|
||||
@@ -58,7 +58,7 @@ diff 按文件树的深度优先顺序排列:同层目录在前、文件在后
|
||||
<details>
|
||||
<summary>实现细节——点击展开</summary>
|
||||
|
||||
`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=<maxBranches>`;`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 +69,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/<remote>/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 :<path>` 从暂存区读取,其余经 `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/<remote>/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 :<path>` 从暂存区读取,其余经 `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` 得出;当前检出分Line truncated
|
||||
|
||||
`changeRequestStates` 为会话列表推送调用方可读的、已列出的 Session 所绑定的每个变更请求的最近编号、标题、状态、草稿标记、检查汇总、评审结论与可合并性:先是一个 `baseline` 帧,之后是 `changed` 帧。所有流共用一次运行。初始化之前和帧之间均响应取消;最后一条流结束及插件卸载会等待初始化与已接受的写入完成,再关闭本次运行的存储域。后续运行会等待该域关闭后重新打开,并读取已存状态。列举期间收到的绑定更新优先于初始快照。它经 `sessionQuery` 列出 Session 而不加载它们,已加载 Session 的可见绑定取自 `ctx.forgeSession`,其他 Session 的取自缓存的 `forgeChangeRequests` 摘要;它跟随已加载 Session 的绑定变化,并经 `ctx.forge.watch` 订阅每个已绑定的变更请求;已关闭的按空闲间隔轮询,已合并的保留订阅以接收显式刷新结果。每个变化了的状态以其 URL 的 SHA-256 摘要为键存入 `forge_panel_change_requests` 存储域,仅在所有已列出 Session 的绑定均已知时删除无人绑定的已存状态;写入失败时记录日志,状态仍保留在内存中。宿主缺少 Session 查询、投影缓存、存储或 Session 存储服务时,流以失败结束。
|
||||
|
||||
浏览器入口先挂载 Remote,再挂载界面。每个 Session 一条流,同时供给标签页、标题栏按钮、新会话选项与创建卡片;第一个订阅方出现时打开,最后一个离开时关闭,传输重置后重新打开,在 Session scope 保留期间保存最后的状态。释放该 scope 时关闭流并移除缓存状态。新会话选项填入 `conversation.hero.options`,worktree 卡片填入 `conversation.input.dock`。worktree 卡片显示正在进行的创建,有消息在等待时“取消”调用 `stopCreation`;或显示消息等待时失败或被停止的那次尝试,“重试”调用 `retryWorktree`,“不使用工作树”调用 `cancelWorktree`。标签页用 Remote 宿主信息中的宿主主目录缩短路径,每次传输重置后重新读取。标签页把自己是否可见报告给标题栏按钮的按下状态。标题栏按钮收起停靠的标签页,或关闭其浮动标签页。toast 位于 `shell.overlay` 条目中,因此比标签页存在得更久。只要 `sidebar.session.row.trailing` 中的会话行图标或 `sidebar.session.row.hover` 中的悬浮卡片部分有一个已挂载,就由同一条变更请求状态流供给它们;两者都从会话列表的摘要值中读取 Session 的 worktree 与绑定。变更请求卡片接收变更请求、它的快照与可选操作,其他界面可以复用。变更视图是 `forge-changes` 资源标签页类型:它的地址 `dsh-resource://forge-changes/<session>/local` 与 `dsh-resource://forge-changes/<session>/change/<url>` 指明来源,导航参数可以指定要滚动到的文件。标签页类型启用 `keepMounted`,将访问过的正文及其所属 Session 引用保留至标签页记录结束。它的 keyed panel hook 仅为可见正文订阅;其他面板界面仍可能保持共享 Session 流开启。它的正文用 slot store 按标签页保存范围、基准分支、所选提交、透镜、测试文件开关与布局选择,按 Session 把已审阅标记保存在 `localStorage` 的 `dsh.forge-viewed.v1.<Session>` 下(每个 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/<session>/local` 与 `dsh-resource://forge-changes/<session>/change/<url>` 指明来源,导航参数可以指定要滚动到的文件。标签页类型启用 `keepMounted`,将访问过的正文及其所属 Session 引用保留至标签页记录结束。它的 keyed panel hook 仅为可见正文订阅;其他面板界面仍可能保持共享 Session 流开启。它的正文用 slot store 按标签页保存范围、基准分支、所选提交、透镜、测试文件开关与布局选择,按 Session 把已审阅标记保存在 `localStorage` 的 `dsh.forge-viewed.v1.<Session>` 下(每个 Session 最多 20 个来源,最多 50 个 Session),经 Remote 读取,并且传给每个文件段的值(包括标签页可见性)在多次渲染之间保持同一标识,所以一次渲染只重绘值有变化的文件段,可见性变化时重绘全部文件段。展开的文件在它的段第一次进入滚动区域 1200px 范围内时才绘制各行,并在同一次渲染中用共享的代码高亮器高亮,此后一直保留;在此之前按每个绘制行 22px 占位;标签页标题显示变更图标与视图名称。
|
||||
|
||||
| 源码 | 职责 |
|
||||
|---|---|
|
||||
@@ -92,6 +92,7 @@ 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) | 保存在浏览器中的已审阅标记 |
|
||||
@@ -139,6 +140,8 @@ Session 在所创建的 worktree 中工作时,`localDiff` 以它的 `baseCommi
|
||||
- **变更范围**——待办:上一轮的范围。“分支”范围的“默认”需要一个默认分支与 HEAD 有共同历史的远端,或 Session 创建的 worktree;否则必须选择基准分支。
|
||||
- **分支极多**——基准分支菜单在筛选前读取全部引用,这样搜索才能找到任意分支。引用超过 `maxDiffBytes`(默认约 165,000 个)或列出时间超过 `gitTimeoutMs` 时,菜单提示无法读取分支;“默认”仍可使用。
|
||||
- **大型变更**——本地补丁超过 `maxDiffBytes` 时不显示任何文件;超过托管平台或其提供方大小上限的变更请求 diff 不显示任何文件,只提供“在浏览器中查看”。不自动换行时,左右对比会在栏边截断长行。
|
||||
- **尚未绘制的文件**——读者滚动到展开的文件附近之前,它按每个绘制行 22 px 占位,没有计入左右对比的配对、自动换行、未变更行按钮和评审卡片,所以文件绘制时滚动范围会略有变化;它已保存的评审评论也要等它绘制后才显示。
|
||||
- **长 diff 中的页内查找**——展开的文件要等读者滚动到附近才绘制各行,所以浏览器的页内查找在更靠后的文件绘制之前匹配不到它们的行;要在整个 diff 中查找,需要视图自己的查找控件。
|
||||
- **代码托管平台上的已审阅状态**——变更请求的已审阅标记只保存在本浏览器中,在提供方评审接口完成之前,不读取也不写入 GitHub 的已审阅状态(`markFileAsViewed`);换浏览器或设备后没有这些标记。
|
||||
- **分层**——内置规则只覆盖常见约定,其他约定由仓库写在 `.gitattributes` 中;按路径无法识别 Rust 的内联 `#[cfg(test)]` 模块,也无法判断一种语言导出了哪些声明,“接口”层保留整个文件,找出变更的导出需要语言分析器,可由之后的透镜扩展点提供。`linguist-generated` 只读取根目录的 `.gitattributes`,不读子目录中的 `.gitattributes`,也不展开属性宏。
|
||||
- **所选提交**——变更请求最多列出提供方的 `maxCommits` 个提交(GitHub 为最早的 250 个),“分支”范围则列出最新的 `maxCommits` 个;范围必须沿第一父提交相连,因此已合并分支的提交只能逐个查看,不能与本分支的提交一起查看;每次查看提交范围都从托管平台读取;所选提交上的评论只能添加到输入框,因为 GitHub 评审针对 head。
|
||||
|
||||
@@ -567,6 +567,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;
|
||||
|
||||
@@ -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 {
|
||||
@@ -39,7 +42,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'
|
||||
@@ -564,7 +568,7 @@ export function ChangesTab({
|
||||
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<ChangesLens, number>, [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,7 +586,12 @@ 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<string, HTMLElement>())
|
||||
const scrollport = useRef<HTMLDivElement>(null)
|
||||
const scrollport = useRef<HTMLDivElement | null>(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(() => {
|
||||
@@ -594,6 +603,7 @@ export function ChangesTab({
|
||||
if (hideTests && hidden.layer === 'test') actions.toggledTests(tab.id)
|
||||
return
|
||||
}
|
||||
// The browser's scroll anchoring keeps the section in place while the sections above it draw their lines.
|
||||
sections.current.get(wanted)?.scrollIntoView({ block: 'start' })
|
||||
if (scrollport.current !== null) jumpPosition.current = { data, top: scrollport.current.scrollTop }
|
||||
setWanted(null)
|
||||
@@ -751,6 +761,58 @@ 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 live = useRef({ data, expandedByDefault, expand, markViewed, fileActions, publishReview, report, t })
|
||||
live.current = { data, expandedByDefault, expand, markViewed, fileActions, publishReview, report, t }
|
||||
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)
|
||||
},
|
||||
reach,
|
||||
}), [reach])
|
||||
const reviewStarted = reviewState.comments.length > 0
|
||||
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.
|
||||
const publishable = data?.kind === 'change' && data.commits === 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,
|
||||
...publishable ? {
|
||||
queue: selection => queue.add(selection),
|
||||
publish: selection => 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, publishable])
|
||||
|
||||
let body: ReactNode
|
||||
if (data === null) {
|
||||
if (state === null && snapshot?.disconnected) {
|
||||
@@ -784,7 +846,7 @@ export function ChangesTab({
|
||||
body = <div className={css.files}>{orphanRecords}<div className={css.centered}>{t('lens.empty')}</div></div>
|
||||
} else {
|
||||
body = (
|
||||
<div ref={scrollport} className={css.files} data-forge-changes-scroll="" role="region"
|
||||
<div ref={attachScrollport} className={css.files} data-forge-changes-scroll="" role="region"
|
||||
aria-label={t('diff.allFiles', { count: files.length })} aria-keyshortcuts="ArrowLeft ArrowRight V" tabIndex={0}
|
||||
onKeyDown={navigateFiles} onScroll={(event) => {
|
||||
if (jumpPosition.current?.top !== event.currentTarget.scrollTop) jumpPosition.current = null
|
||||
@@ -796,38 +858,18 @@ 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)}
|
||||
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}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -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,7 +16,7 @@ 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'
|
||||
@@ -24,6 +26,7 @@ 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<typeof NS>['t']
|
||||
@@ -39,30 +42,45 @@ 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
|
||||
/** 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<number>
|
||||
/** Expand one gap. */
|
||||
readonly onExpand: (index: number) => void
|
||||
/** Whether deletions and additions show in two columns; a file with only additions or only deletions keeps one. */
|
||||
readonly split: boolean
|
||||
/** Whether long lines wrap. */
|
||||
@@ -71,14 +89,10 @@ 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
|
||||
/** Actions shared by every section of the view. */
|
||||
readonly handlers: DiffFileHandlers
|
||||
/** Forge panel copy. */
|
||||
readonly t: Translate
|
||||
}
|
||||
@@ -144,8 +158,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<DiffFileProps,
|
||||
'file' | 'text' | 'open' | 'onExpand' | 'split' | 'wrap' | 'review' | 'visible' | 't'> & { readonly comments: FileReviewState }): ReactNode {
|
||||
function Lines({ file, text, open, handlers, split, wrap, review, visible, comments, t }: Pick<DiffFileProps,
|
||||
'file' | 'text' | 'open' | 'handlers' | 'split' | 'wrap' | 'review' | 'visible' | 't'> & { 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
|
||||
@@ -318,7 +332,7 @@ function Lines({ file, text, open, onExpand, split, wrap, review, visible, comme
|
||||
for (const [at, segment] of prepared.entries()) {
|
||||
if (segment.kind === 'gap') {
|
||||
rows.push(<button key={`gap-${segment.index}`} type="button" className={css.gap} disabled={text === 'loading'}
|
||||
data-forge-diff-gap={segment.index} onClick={() => { onExpand(segment.index) }}>
|
||||
data-forge-diff-gap={segment.index} onClick={() => { handlers.expand(file, segment.index) }}>
|
||||
<IconChevronsUpDownOutlineRegular size={12} />
|
||||
{segment.count === null ? t('diff.unchangedRest') : t('diff.unchanged', { count: segment.count.toLocaleString('en-US') })}
|
||||
</button>)
|
||||
@@ -374,8 +388,13 @@ function Lines({ file, text, open, onExpand, split, wrap, review, visible, comme
|
||||
}}>{rows}</div>
|
||||
}
|
||||
|
||||
/** 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)
|
||||
}
|
||||
|
||||
function Body(props: DiffFileProps): ReactNode {
|
||||
const { file, large, revealed, onReveal, review, t } = props
|
||||
const { file, large, revealed, handlers, review, t } = props
|
||||
const comments = useFileReview(review, file.path)
|
||||
const records = review === undefined ? null : comments.threads.map(thread => <ReviewThread key={thread.key} thread={thread}
|
||||
actions={review} locked={comments.locked} outdated={!reviewMatches(thread.selection, review.source, file)} split={false} t={t} />)
|
||||
@@ -385,7 +404,7 @@ function Body(props: DiffFileProps): ReactNode {
|
||||
if (large && !revealed) {
|
||||
// A large file starts as one collapsed strip, drawn and opened like the collapsed unchanged lines.
|
||||
return (
|
||||
<><button type="button" className={css.gap} onClick={onReveal}>
|
||||
<><button type="button" className={css.gap} onClick={() => { handlers.reveal(file.path) }}>
|
||||
<IconChevronsUpDownOutlineRegular size={12} />
|
||||
{t('diff.largeCollapsed', { count: (file.additions + file.deletions).toLocaleString('en-US') })}
|
||||
</button>{records}</>
|
||||
@@ -395,9 +414,10 @@ function Body(props: DiffFileProps): ReactNode {
|
||||
}
|
||||
|
||||
/** 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 +442,7 @@ function FileMenu({ name, actions, t }: {
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
{actions.map(action => (
|
||||
{handlers.actions(file).map(action => (
|
||||
<MenuItemButton
|
||||
key={action.label}
|
||||
onSelect={() => {
|
||||
@@ -438,13 +458,27 @@ 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, 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, handlers, t } = props
|
||||
const name = file.path.slice(file.path.lastIndexOf('/') + 1)
|
||||
const element = useRef<HTMLElement | null>(null)
|
||||
const [reached, setReached] = useState(false)
|
||||
const waiting = expanded && !reached && 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 (
|
||||
<section ref={sectionRef} className={css.file} data-forge-diff-file={file.path} data-expanded={expanded ? '' : undefined}
|
||||
data-viewed={viewed}>
|
||||
@@ -454,7 +488,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) }}
|
||||
>
|
||||
<IconChevronDownOutlineRegular size={14} className={css.chevron} />
|
||||
</button>
|
||||
@@ -472,12 +506,15 @@ export function DiffFile(props: DiffFileProps): ReactNode {
|
||||
{!file.binary && <Counts file={file} t={t} />}
|
||||
<label className={css.viewed} title={t('viewed.hint')}>
|
||||
<input type="checkbox" checked={viewed === 'viewed'} aria-label={t('viewed.aria', { name })}
|
||||
onChange={(event) => { onViewed(event.target.checked) }} />
|
||||
onChange={(event) => { handlers.view(file, event.target.checked) }} />
|
||||
<span className={css.viewedLabel} aria-hidden="true">{t('viewed.label')}</span>
|
||||
</label>
|
||||
{props.visible && <FileMenu name={name} actions={actions} t={t} />}
|
||||
{props.visible && <FileMenu name={name} file={file} handlers={handlers} t={t} />}
|
||||
</div>
|
||||
{expanded && <Body {...props} />}
|
||||
{expanded && (waiting ? <div className={css.unreached} style={placeholder} data-forge-diff-unreached="" /> : <Body {...props} />)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/** One file of a diff, redrawn only when its props change. */
|
||||
export const DiffFile = memo(DiffFileSection)
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Reach of the changes view's file sections: a section's lines mount once the
|
||||
* section first comes within a margin of the files scrollport, and stay
|
||||
* mounted, so long diffs draw only what the reader approaches.
|
||||
*/
|
||||
|
||||
/** Distance beyond the scrollport's edges within which a section counts as reached. */
|
||||
const REACH_MARGIN = '1200px 0px'
|
||||
|
||||
const noop = (): void => {}
|
||||
|
||||
/**
|
||||
* Watches the file sections of one changes view against its scrollport. Without
|
||||
* `IntersectionObserver` every section is reached at once.
|
||||
*/
|
||||
export class SectionReach {
|
||||
private observer: IntersectionObserver | undefined
|
||||
private readonly waiting = new Map<Element, () => void>()
|
||||
|
||||
/**
|
||||
* Measure sections against `root`, or stop measuring when it unmounts. Sections that are still waiting move to the new root.
|
||||
* @param root - the files scrollport, or null.
|
||||
*/
|
||||
attach(root: HTMLElement | null): void {
|
||||
this.observer?.disconnect()
|
||||
this.observer = undefined
|
||||
if (root === null || typeof IntersectionObserver === 'undefined') return
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
if (!entry.isIntersecting) continue
|
||||
const reached = this.waiting.get(entry.target)
|
||||
/* v8 ignore next -- the observer reports only sections still waiting, which it stops watching on release */
|
||||
if (reached === undefined) continue
|
||||
this.waiting.delete(entry.target)
|
||||
observer.unobserve(entry.target)
|
||||
reached()
|
||||
}
|
||||
}, { root, rootMargin: REACH_MARGIN })
|
||||
this.observer = observer
|
||||
for (const element of this.waiting.keys()) observer.observe(element)
|
||||
}
|
||||
|
||||
/**
|
||||
* Call `reached` once when `element` comes within reach.
|
||||
* @param element - a file section inside the scrollport.
|
||||
* @param reached - runs once, from the observer's callback or at once without `IntersectionObserver`.
|
||||
* @returns the disposer that stops watching before the section is reached.
|
||||
*/
|
||||
observe(element: Element, reached: () => void): () => void {
|
||||
if (typeof IntersectionObserver === 'undefined') {
|
||||
reached()
|
||||
return noop
|
||||
}
|
||||
this.waiting.set(element, reached)
|
||||
this.observer?.observe(element)
|
||||
return () => {
|
||||
this.waiting.delete(element)
|
||||
this.observer?.unobserve(element)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -418,9 +418,7 @@ export class ForgePanelController extends TypertRemoteService {
|
||||
const version = JSON.stringify([headSha, baseBranch])
|
||||
let read = this.patches.get(key, version)
|
||||
if (read === undefined) {
|
||||
const patch = await this.home.forge.diff(ref, headSha, signal)
|
||||
if (typeof patch !== 'string') throw readError(patch)
|
||||
read = { patch, attributes: await this.attributesAt(ref, headSha, signal) }
|
||||
read = await this.patchAt(ref, headSha, async operation => await this.home.forge.diff(ref, headSha, operation), signal)
|
||||
this.patches.set(key, version, read)
|
||||
}
|
||||
const files = this.layers.classify(parseDiff(read.patch), read.attributes)
|
||||
@@ -683,13 +681,41 @@ export class ForgePanelController extends TypertRemoteService {
|
||||
if (start === 'not-linear') throw new RemoteError('forgePanel/commits-not-linear', 'The chosen commits are not one run of first parents', {})
|
||||
const parent = start.parent
|
||||
if (parent === null) throw new RemoteError('forgePanel/diff-failed', 'The first chosen commit has no parent to compare with', {})
|
||||
const patch = await this.home.forge.compare(ref, parent, commits.to, signal)
|
||||
if (typeof patch !== 'string') throw readError(patch)
|
||||
const files = this.layers.classify(parseDiff(patch), await this.attributesAt(ref, commits.to, signal))
|
||||
const { patch, attributes } = await this.patchAt(
|
||||
ref, commits.to, async operation => await this.home.forge.compare(ref, parent, commits.to, operation), signal,
|
||||
)
|
||||
const files = this.layers.classify(parseDiff(patch), attributes)
|
||||
const fileUrls = Object.fromEntries(files.map(file => [file.path, this.home.forge.fileUrl(ref, commits.to, file.path)]))
|
||||
return { headSha: commits.to, files, localAhead: null, fileUrls }
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a patch that ends at `commit` together with the root `.gitattributes` at that commit. A failed patch read
|
||||
* cancels the attributes read. Both reads end before the result: the patch's failure passes on first, and the
|
||||
* attributes failure only after the patch was read, so the outcome does not depend on which read ends first.
|
||||
* @param ref - change request whose repository holds the commit.
|
||||
* @param commit - commit the patch ends at.
|
||||
* @param readPatch - reads the patch under the given cancellation.
|
||||
* @param signal - request cancellation.
|
||||
* @returns the patch and the attributes text, empty when the commit has none.
|
||||
* @throws the Remote error for a refused patch or attributes read, or `signal.reason` when it aborts.
|
||||
*/
|
||||
private async patchAt(
|
||||
ref: ChangeRequestRef, commit: string, readPatch: (signal: AbortSignal) => Promise<string | ForgeFailure>, signal: AbortSignal,
|
||||
): Promise<{ readonly patch: string; readonly attributes: string }> {
|
||||
const unneeded = new AbortController()
|
||||
const patch = readPatch(signal).then((result) => {
|
||||
if (typeof result !== 'string') throw readError(result)
|
||||
return result
|
||||
})
|
||||
const attributes = this.attributesAt(ref, commit, AbortSignal.any([signal, unneeded.signal]))
|
||||
void patch.then(undefined, () => { unneeded.abort() })
|
||||
const [text, rules] = await Promise.allSettled([patch, attributes])
|
||||
if (text.status === 'rejected') throw text.reason
|
||||
if (rules.status === 'rejected') throw rules.reason
|
||||
return { patch: text.value, attributes: rules.value }
|
||||
}
|
||||
|
||||
/** The root `.gitattributes` of a change request's repository at a commit, empty when the commit has none. */
|
||||
private async attributesAt(ref: ChangeRequestRef, commit: string, signal: AbortSignal): Promise<string> {
|
||||
const attributes = await this.home.forge.fileText(ref, commit, ATTRIBUTES_PATH, signal)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/** Live panel state of one followed Session, shared by all of its followers. */
|
||||
|
||||
import { isDeepStrictEqual } from 'node:util'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { ChangeRequestSnapshot, ForgeReadiness, ForgeRepository } from '@deepseek-ai/dsh-experimental-forge'
|
||||
import type { SessionChangeRequest } from '@deepseek-ai/dsh-experimental-forge-session'
|
||||
@@ -230,6 +231,8 @@ export class SessionPanel {
|
||||
}
|
||||
|
||||
private publish(next: ForgePanelState): void {
|
||||
// A reinspection after a tool result usually finds the same checkout; equal states reach no follower.
|
||||
if (isDeepStrictEqual(next, this.current)) return
|
||||
this.current = next
|
||||
for (const notify of [...this.followers]) notify()
|
||||
}
|
||||
|
||||
@@ -729,9 +729,10 @@ describe('ChangesTab base branch', () => {
|
||||
})
|
||||
|
||||
it('records review comments under the compared ref and names the base the Host reports', async () => {
|
||||
const renderDiff = DiffFileModule.DiffFile
|
||||
// The memoized section draws through its inner component, which the spy observes.
|
||||
const renderDiff = DiffFileModule.DiffFile.type
|
||||
let source: ReviewActions['source'] | undefined
|
||||
vi.spyOn(DiffFileModule, 'DiffFile').mockImplementation((props) => {
|
||||
vi.spyOn(DiffFileModule.DiffFile, 'type').mockImplementation((props) => {
|
||||
source = props.review?.source
|
||||
return renderDiff(props)
|
||||
})
|
||||
@@ -805,6 +806,48 @@ describe('ChangesTab base branch', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('ChangesTab drawing work', () => {
|
||||
it('draws the lines of an expanded file once its section comes within reach of the scrollport', async () => {
|
||||
const watched = new Map<Element, IntersectionObserverCallback>()
|
||||
let root: Element | Document | null | undefined
|
||||
vi.stubGlobal('IntersectionObserver', class {
|
||||
constructor(readonly report: IntersectionObserverCallback, options?: IntersectionObserverInit) { root = options?.root }
|
||||
observe(element: Element): void { watched.set(element, this.report) }
|
||||
unobserve(element: Element): void { watched.delete(element) }
|
||||
disconnect(): void { watched.clear() }
|
||||
})
|
||||
try {
|
||||
const m = mount()
|
||||
await settle()
|
||||
const app = m.section('src/app.ts')
|
||||
expect(root).toBe(m.view.container.querySelector('[data-forge-changes-scroll]'))
|
||||
expect(app.querySelector('[data-forge-diff-unreached]')).not.toBeNull()
|
||||
expect(rowTexts(app)).toEqual([])
|
||||
|
||||
act(() => {
|
||||
// The double reports only the entries, which is all the view reads.
|
||||
Reflect.apply(watched.get(app)!, undefined, [[{ target: app, isIntersecting: true }], undefined])
|
||||
})
|
||||
expect(app.querySelector('[data-forge-diff-unreached]')).toBeNull()
|
||||
expect(rowTexts(app).length).toBeGreaterThan(0)
|
||||
expect(watched.has(app)).toBe(false)
|
||||
} finally {
|
||||
vi.unstubAllGlobals()
|
||||
}
|
||||
})
|
||||
|
||||
it('leaves file sections undrawn while the reader filters the file list', async () => {
|
||||
// The memoized section draws through its inner component, which the spy counts.
|
||||
const draw = vi.spyOn(DiffFileModule.DiffFile, 'type')
|
||||
const m = mount()
|
||||
await settle()
|
||||
const drawn = draw.mock.calls.length
|
||||
fireEvent.change(m.view.getAllByRole('textbox', { name: 'Filter files…' })[0]!, { target: { value: 'app' } })
|
||||
await settle()
|
||||
expect(draw.mock.calls.length).toBe(drawn)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ChangesTab layout', () => {
|
||||
it('draws lines side by side, then in one column, and wraps them by choice', async () => {
|
||||
const m = mount()
|
||||
@@ -1339,9 +1382,10 @@ describe('line review actions', () => {
|
||||
expect(tree.getByRole('button', { name: 'src/app.ts' })).toBeTruthy()
|
||||
})
|
||||
it('refuses a local reference passed to the PR publication callback', async () => {
|
||||
const renderDiff = DiffFileModule.DiffFile
|
||||
// The memoized section draws through its inner component, which the spy observes.
|
||||
const renderDiff = DiffFileModule.DiffFile.type
|
||||
let publish: ReviewActions['publish']
|
||||
vi.spyOn(DiffFileModule, 'DiffFile').mockImplementation((props) => {
|
||||
vi.spyOn(DiffFileModule.DiffFile, 'type').mockImplementation((props) => {
|
||||
publish = props.review?.publish
|
||||
return renderDiff(props)
|
||||
})
|
||||
|
||||
@@ -306,6 +306,8 @@ describe('forgePanel.follow', () => {
|
||||
|
||||
expect(moved.readiness).toEqual({ state: 'unauthenticated', message: 'sign in' })
|
||||
expect(opened.provider.probes).toEqual(['fake.test', 'other.test'])
|
||||
// A new commit gives the next inspection a state of its own to publish.
|
||||
await git(repo, 'commit', '--allow-empty', '-m', 'again')
|
||||
shellTurn(opened.session, 2, [{ callId: 'again', command: 'ls', output: '' }])
|
||||
await follower.next(state => state.checkout.state === 'ready')
|
||||
expect(follower.states.at(-1)?.readiness).toEqual({ state: 'unauthenticated', message: 'sign in' })
|
||||
@@ -726,6 +728,57 @@ describe('forgePanel change request diffs', () => {
|
||||
.rejects.toMatchObject({ code: 'forgePanel/diff-failed', message: 'down' })
|
||||
})
|
||||
|
||||
it('reads the patch and the .gitattributes together, and reports the patch\'s failure before the attributes failure', async () => {
|
||||
const { repo } = await project()
|
||||
const opened = await open(repo)
|
||||
shellTurn(opened.session, 1, [{ callId: 'create', command: 'gh pr create --fill', output: `${pullUrl(7)}\n` }])
|
||||
await reader(opened).until(state => polled(state, pullUrl(7)))
|
||||
const read = async (): Promise<unknown> => await opened.ctx.forgePanel.changeRequestDiff(opened.session, pullUrl(7), null, signal)
|
||||
const cancelled: string[] = []
|
||||
// The patch read ends only after the attributes read has failed, so the attributes failure always comes first.
|
||||
let attributesFailed = Promise.withResolvers<undefined>()
|
||||
const later = <T>(value: T) => async (): Promise<T> => {
|
||||
await attributesFailed.promise
|
||||
return value
|
||||
}
|
||||
|
||||
// Failed reads are not kept, so each case reads again.
|
||||
const diff = vi.spyOn(opened.provider, 'diff').mockImplementation(later<string | ForgeFailure>({ code: 'too-large', message: 'diff over 300 files' }))
|
||||
const text = vi.spyOn(opened.provider, 'fileText').mockImplementation(async () => {
|
||||
attributesFailed.resolve(undefined)
|
||||
return { code: 'unavailable', message: 'attributes down' }
|
||||
})
|
||||
await expect(read()).rejects.toMatchObject({ code: 'forgePanel/diff-too-large', message: 'diff over 300 files' })
|
||||
attributesFailed = Promise.withResolvers()
|
||||
diff.mockImplementation(later<string | ForgeFailure>({ code: 'stale', message: 'head moved' }))
|
||||
await expect(read()).rejects.toMatchObject({ code: 'forgePanel/stale-head', message: 'head moved' })
|
||||
attributesFailed = Promise.withResolvers()
|
||||
diff.mockImplementation(later<string | ForgeFailure>(PATCH))
|
||||
await expect(read()).rejects.toMatchObject({ code: 'forgePanel/diff-failed', message: 'attributes down' })
|
||||
|
||||
// A failed patch read cancels the attributes read it no longer needs.
|
||||
diff.mockResolvedValue({ code: 'unavailable', message: 'patch down' })
|
||||
text.mockImplementation(async (_ref, _commit, _path, operation) => {
|
||||
await new Promise((resolve) => { operation.addEventListener('abort', resolve, { once: true }) })
|
||||
cancelled.push('attributes')
|
||||
throw operation.reason
|
||||
})
|
||||
await expect(read()).rejects.toMatchObject({ code: 'forgePanel/diff-failed', message: 'patch down' })
|
||||
expect(cancelled).toEqual(['attributes'])
|
||||
|
||||
// The patch read finishes only once the attributes read has started, so reading them one after the other never ends.
|
||||
const attributesStarted = Promise.withResolvers<undefined>()
|
||||
diff.mockImplementation(async () => {
|
||||
await attributesStarted.promise
|
||||
return PATCH
|
||||
})
|
||||
text.mockImplementation(async () => {
|
||||
attributesStarted.resolve(undefined)
|
||||
return 'src/** linguist-generated\n'
|
||||
})
|
||||
await expect(read()).resolves.toMatchObject({ files: [{ path: 'src/a.ts', layer: 'test' }] })
|
||||
})
|
||||
|
||||
it('lists the commits of a change request and reads consecutive ones from the first one\'s parent', async () => {
|
||||
const { repo } = await project()
|
||||
const opened = await open(repo)
|
||||
@@ -1726,13 +1779,12 @@ describe('forgePanel actions', () => {
|
||||
expect(settled).toBe(false)
|
||||
inspection.release()
|
||||
await inspected
|
||||
await follower.next(() => true)
|
||||
|
||||
const probe = Promise.withResolvers<ForgeReadiness>()
|
||||
opened.provider.readinessImpl = () => probe.promise
|
||||
settled = false
|
||||
const probed = opened.ctx.forgePanel.refresh(opened.session, signal).then(() => { settled = true })
|
||||
await follower.next(() => true)
|
||||
await vi.waitFor(() => { expect(opened.provider.probes).toHaveLength(3) })
|
||||
await settle()
|
||||
expect(settled).toBe(false)
|
||||
probe.resolve({ state: 'ready', message: null })
|
||||
@@ -1740,6 +1792,24 @@ describe('forgePanel actions', () => {
|
||||
expect(opened.provider.probes).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('publishes nothing when a reinspection and a sign-in probe find what the panel already shows', async () => {
|
||||
const { repo } = await project()
|
||||
const opened = await open(repo)
|
||||
const follower = reader(opened)
|
||||
await follower.until(state => ready(state) && state.readiness !== null)
|
||||
await settle()
|
||||
const published = follower.states.length
|
||||
const inspections = opened.sandbox.calls.length
|
||||
|
||||
shellTurn(opened.session, 1, [{ callId: 'list', command: 'ls', output: '' }])
|
||||
await opened.ctx.forgePanel.refresh(opened.session, signal)
|
||||
await settle()
|
||||
|
||||
expect(opened.sandbox.calls.length).toBeGreaterThan(inspections)
|
||||
expect(opened.provider.probes).toHaveLength(2)
|
||||
expect(follower.states).toHaveLength(published)
|
||||
})
|
||||
|
||||
it('refreshes a Session outside a checkout without polling change requests that no provider serves', async () => {
|
||||
const opened = await open(await plain())
|
||||
shellTurn(opened.session, 1, [{ callId: 'create', command: 'gh pr create --fill', output: 'https://unserved.test/a/b/pull/3\n' }])
|
||||
@@ -1749,8 +1819,10 @@ describe('forgePanel actions', () => {
|
||||
|
||||
await opened.ctx.forgePanel.refresh(opened.session, signal)
|
||||
|
||||
const refreshed = await follower.next(state => state.checkout.state === 'none')
|
||||
expect(refreshed.changeRequests).toEqual([{ url: 'https://unserved.test/a/b/pull/3', origin: 'created', ref: null, update: null, snapshot: null }])
|
||||
// The refresh found the same state, which the panel already published.
|
||||
const refreshed = follower.states.at(-1)
|
||||
expect(refreshed?.checkout.state).toBe('none')
|
||||
expect(refreshed?.changeRequests).toEqual([{ url: 'https://unserved.test/a/b/pull/3', origin: 'created', ref: null, update: null, snapshot: null }])
|
||||
expect(opened.sandbox.calls.length).toBeGreaterThan(inspections)
|
||||
expect(opened.provider.fetches).toEqual([])
|
||||
expect(opened.provider.probes).toEqual([])
|
||||
|
||||
@@ -6,6 +6,7 @@ import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { DiffFile, type DiffFileProps } from '../src/client/DiffFile.tsx'
|
||||
import { en } from '../src/client/locales.ts'
|
||||
import { ReviewQueue } from '../src/client/review-queue.ts'
|
||||
import { SectionReach } from '../src/client/section-reach.ts'
|
||||
import { selectReviewLines } from '../src/client/review.ts'
|
||||
import type { ReviewActions } from '../src/client/ReviewComposer.tsx'
|
||||
import type { ForgeDiffFile } from '../src/types.ts'
|
||||
@@ -20,9 +21,12 @@ function mount(overrides: Partial<DiffFileProps> = {}) {
|
||||
const addToInput = vi.fn().mockReturnValue(true)
|
||||
const records = new ReviewQueue('diff-review')
|
||||
const review: ReviewActions = { source, addToInput, records, onQuoted: (item) => { records.recordAgent(item) } }
|
||||
const props: DiffFileProps = { file, visible: true, expanded: true, onToggle: vi.fn(), large: false, revealed: false, onReveal: vi.fn(),
|
||||
text: undefined, open: new Set(), onExpand: vi.fn(), split: true, wrap: true, badge: '', tooltip: 'a.txt', actions: [],
|
||||
viewed: 'unviewed', onViewed: vi.fn(), sectionRef: vi.fn(), review, t, ...overrides }
|
||||
const handlers = {
|
||||
toggle: vi.fn(), reveal: vi.fn(), expand: vi.fn(), view: vi.fn(), actions: () => [], section: vi.fn(), reach: new SectionReach(),
|
||||
}
|
||||
const props: DiffFileProps = { file, visible: true, expanded: true, large: false, revealed: false,
|
||||
text: undefined, open: new Set(), split: true, wrap: true, badge: '', tooltip: 'a.txt',
|
||||
viewed: 'unviewed', handlers, review, t, ...overrides }
|
||||
const view = render(<DiffFile {...props} />)
|
||||
const line = (name: string) => view.getByRole('button', { name: `Select line ${name}` })
|
||||
const code = (side: 'L' | 'R', no: number): HTMLElement => {
|
||||
@@ -248,3 +252,28 @@ it('places left and right records in unified rows and retains outdated records f
|
||||
expect(m.container.querySelectorAll('[data-forge-review-thread]')).toHaveLength(3)
|
||||
expect(within(m.container).getByRole('button', { name: /changed lines/ })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('draws a file\'s highlighted lines in the same render, so a changed file never shows its previous text', () => {
|
||||
const code = (text: string): ForgeDiffFile => ({ ...file, path: 'a.ts', additions: 1, deletions: 0, status: 'added',
|
||||
hunks: [{ oldStart: 0, oldLines: 0, newStart: 1, newLines: 1, lines: [`+${text}`] }] })
|
||||
const m = mount({ file: code('const value = old()'), split: false })
|
||||
const line = (): HTMLElement => m.code('R', 1)
|
||||
expect(line().textContent).toBe('const value = old()')
|
||||
expect(line().hasAttribute('data-diff-code')).toBe(true)
|
||||
|
||||
m.rerender(<DiffFile {...m.props} file={code('const value = renewed()')} split={false} />)
|
||||
expect(line().textContent).toBe('const value = renewed()')
|
||||
expect(line().hasAttribute('data-diff-code')).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps a text selection across a render that leaves the file unchanged', () => {
|
||||
const m = mount({ file: { ...file, path: 'a.ts' } })
|
||||
const start = m.code('R', 3)
|
||||
const node = start.firstChild
|
||||
act(() => { document.getSelection()!.setBaseAndExtent(start, 0, start, start.childNodes.length) })
|
||||
const selected = document.getSelection()?.toString()
|
||||
expect(selected).toBe('new first')
|
||||
m.rerender(<DiffFile {...m.props} file={{ ...file, path: 'a.ts' }} wrap={false} />)
|
||||
expect(m.code('R', 3).firstChild).toBe(node)
|
||||
expect(document.getSelection()?.toString()).toBe(selected)
|
||||
})
|
||||
@@ -347,13 +347,13 @@ export class FakeProvider implements ForgeProvider {
|
||||
return Promise.resolve(this.actionResult)
|
||||
}
|
||||
|
||||
diff(ref: ChangeRequestRef, headSha: string): Promise<string | ForgeFailure> {
|
||||
diff(ref: ChangeRequestRef, headSha: string, _signal: AbortSignal): Promise<string | ForgeFailure> {
|
||||
this.diffs.push(ref.number)
|
||||
this.diffHeads.push(headSha)
|
||||
return Promise.resolve(this.patches.get(ref.number) ?? { code: 'not-found', message: `no diff of #${ref.number}` })
|
||||
}
|
||||
|
||||
fileText(_ref: ChangeRequestRef, commit: string, path: string): Promise<string | ForgeFailure> {
|
||||
fileText(_ref: ChangeRequestRef, commit: string, path: string, _signal: AbortSignal): Promise<string | ForgeFailure> {
|
||||
return Promise.resolve(this.texts.get(`${commit}:${path}`) ?? { code: 'not-found', message: `no ${path} at ${commit}` })
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
// @vitest-environment jsdom
|
||||
/** File sections reach the scrollport once. */
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { SectionReach } from '../src/client/section-reach.ts'
|
||||
|
||||
/** An IntersectionObserver double that records its options and the elements it watches. */
|
||||
class FakeObserver {
|
||||
static readonly made: FakeObserver[] = []
|
||||
readonly watched = new Set<Element>()
|
||||
disconnected = false
|
||||
constructor(readonly report: IntersectionObserverCallback, readonly options: IntersectionObserverInit | undefined) {
|
||||
FakeObserver.made.push(this)
|
||||
}
|
||||
observe(element: Element): void { this.watched.add(element) }
|
||||
unobserve(element: Element): void { this.watched.delete(element) }
|
||||
disconnect(): void {
|
||||
this.disconnected = true
|
||||
this.watched.clear()
|
||||
}
|
||||
/** Report each element as intersecting or not. */
|
||||
fire(entries: readonly (readonly [Element, boolean])[]): void {
|
||||
// The production callback reads only the entries, so the double passes itself as the observer.
|
||||
Reflect.apply(this.report, undefined, [entries.map(([target, isIntersecting]) => ({ target, isIntersecting })), this])
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
FakeObserver.made.length = 0
|
||||
})
|
||||
|
||||
describe('SectionReach', () => {
|
||||
it('reaches every section at once where the browser has no IntersectionObserver', () => {
|
||||
vi.stubGlobal('IntersectionObserver', undefined)
|
||||
const reach = new SectionReach()
|
||||
reach.attach(document.createElement('div'))
|
||||
const reached = vi.fn()
|
||||
const stop = reach.observe(document.createElement('section'), reached)
|
||||
expect(reached).toHaveBeenCalledOnce()
|
||||
stop()
|
||||
})
|
||||
|
||||
it('reaches a section once it intersects the scrollport margin and moves waiting sections to a new scrollport', () => {
|
||||
vi.stubGlobal('IntersectionObserver', FakeObserver)
|
||||
const reach = new SectionReach()
|
||||
const near = document.createElement('section')
|
||||
const far = document.createElement('section')
|
||||
const gone = document.createElement('section')
|
||||
const reachedNear = vi.fn()
|
||||
const reachedFar = vi.fn()
|
||||
reach.observe(near, reachedNear)
|
||||
reach.observe(far, reachedFar)
|
||||
const stopGone = reach.observe(gone, vi.fn())
|
||||
expect(FakeObserver.made).toHaveLength(0)
|
||||
|
||||
const root = document.createElement('div')
|
||||
reach.attach(root)
|
||||
const first = FakeObserver.made[0]!
|
||||
expect(first.options).toEqual({ root, rootMargin: '1200px 0px' })
|
||||
expect([...first.watched]).toEqual([near, far, gone])
|
||||
stopGone()
|
||||
expect([...first.watched]).toEqual([near, far])
|
||||
|
||||
first.fire([[near, true], [far, false]])
|
||||
expect(reachedNear).toHaveBeenCalledOnce()
|
||||
expect(reachedFar).not.toHaveBeenCalled()
|
||||
expect([...first.watched]).toEqual([far])
|
||||
|
||||
const other = document.createElement('div')
|
||||
reach.attach(other)
|
||||
expect(first.disconnected).toBe(true)
|
||||
const second = FakeObserver.made[1]!
|
||||
expect(second.options?.root).toBe(other)
|
||||
expect([...second.watched]).toEqual([far])
|
||||
second.fire([[far, true]])
|
||||
expect(reachedFar).toHaveBeenCalledOnce()
|
||||
|
||||
reach.attach(null)
|
||||
expect(second.disconnected).toBe(true)
|
||||
expect(FakeObserver.made).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
@@ -15,11 +15,11 @@
|
||||
en: 1777e7bf6785f986
|
||||
zh: e3daac1074ebb74d
|
||||
/deepseek-ai-dsh-experimental-cua-macos/use-this-package:
|
||||
en: 30fefafc1915db8a
|
||||
zh: e819f1e722c0d614
|
||||
en: e51a6c3625873430
|
||||
zh: 0a6998897532a05f
|
||||
/deepseek-ai-dsh-experimental-cua-macos/use-this-package/connect-an-external-execution-environment:
|
||||
en: b734a0907747580e
|
||||
zh: 8110c5951b70e100
|
||||
en: 652b0ce8892e522c
|
||||
zh: 101c20aa18b36c05
|
||||
/deepseek-ai-dsh-experimental-cua-macos/understand-the-implementation:
|
||||
en: 4e82b85a5aa72eee
|
||||
zh: 4e004b7a205d8688
|
||||
|
||||
@@ -60,11 +60,11 @@ Source builds default to `sourceSigning: ad-hoc`, without creating certificates
|
||||
| `agentPreset` | `ptc` | Register tools and guidance only for Agents bound to this preset; `false` registers globally. |
|
||||
| `allowedApps` | `[]` | Legacy path/signature grants, imported once for removal; they do not authorize new bundle-ID identities. |
|
||||
| `helperPath` | package-owned executable | Absolute executable inside the signed helper `.app`; a bare binary fails with `NATIVE_APPLICATION_REQUIRED`. |
|
||||
| `requireSystemPointerApproval` | `true` | Ask on the first explicit request to move the system pointer. With `false`, only explicitly requested moves use it. |
|
||||
| `sourceSigning` | `ad-hoc` | Source signing: `ad-hoc`, or `{ identity, keychain }` for an existing certificate and keychain path. Ignored for installed packages and `helperPath`. |
|
||||
| `buildTimeoutMs` | `300000` | Maximum native preparation time. |
|
||||
| `timeoutMs` | `30000` | Per-operation deadline; cancellation can leave partial input. |
|
||||
| `connectTimeoutMs` | `5000` | Deadline for connecting to or starting the shared helper. |
|
||||
| `systemPointerHover` | `false` | Allow `mouseMove` to move the visible cursor when AX hover is unavailable. |
|
||||
| `permissionPollIntervalMs` | `1000` | Interval in milliseconds between permission and position checks while the native guide is open. |
|
||||
| `screenshotTimeoutMs` | `5000` | Screenshot deadline including queue time, capped by `timeoutMs`. |
|
||||
| `axTimeoutMs` | `3000` | Deadline per Accessibility system call, configured independently of the complete operation. |
|
||||
@@ -117,7 +117,7 @@ Use the `cua` API to discover and select the application (`listApps`, `getState`
|
||||
|
||||
`getWindow` reads its initial tree silently. An owned window without an AX surface returns an empty snapshot and remains available for screenshots and coordinate input. Within one API instance, the facade keeps one current accessibility snapshot per application handle, shared by every app and window object for that handle, including repeated selections. Reading any window replaces that snapshot; element operations use the latest snapshot ID, so use IDs from the latest tree and do not assume an older ID still identifies the same control. A failed read publishes no partial snapshot; read again without repeating the preceding input. Window objects own screenshots and pointer input. Keyboard methods stay on `app` because input follows the app’s current focus.
|
||||
|
||||
`mouseMove(target)` defaults to AX hover. An enabled target or ancestor must advertise both `AXShowAlternateUI` and `AXShowDefaultUI`. Moving into its descendants keeps the UI open, and leaving restores it. Without an AX route the helper returns `HOVER_UNAVAILABLE` before sending input. Click a visible control or use `performAction` with an advertised action. For browser content without these AX actions, use browser tools. Enable `systemPointerHover` only when moving the user’s visible cursor is acceptable. That route requires a foreground, unobscured target and leaves the cursor at the destination.
|
||||
Ordinary `mouseMove(target)` defaults to AX hover. Without both `AXShowAlternateUI` and `AXShowDefaultUI` on an enabled target or ancestor, it returns `HOVER_UNAVAILABLE` without prompting. Only when the task requires physical movement should the main task call `win.mouseMove(target, { useSystemPointer: true })`. On approval the Host rechecks the window and point and dispatches this move. Pass `useSystemPointer: true` on every physical move. Later explicit moves reuse the decision until the session closes or DSH restarts. Omitting the option or passing `false` uses AX. Refusal sends no input.
|
||||
|
||||
`paste(text, { format: "text" | "html" })` temporarily publishes the requested clipboard format and invokes the application’s enabled standard Paste menu command. It keeps the temporary format until a pasteboard reader requests its data, then restores every captured original format while it still owns the clipboard, preserving a later copy. Paste operations share a cross-process lock and cancellation joins helper cleanup. A forced process exit can prevent restoration. Unsupported Paste commands fail without typing fallback; `typeText` remains separate key-event input.
|
||||
|
||||
@@ -187,7 +187,7 @@ In persistent PTC, use the host-provided bridge initialization once and retain t
|
||||
Signatures show resolved return values after await. ActionResult = { dispatched: boolean, foregroundChanged?: boolean | null }; foregroundChanged is omitted when not sampled and null when the sample is unavailable. JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }; numbers must be finite. App is the application object with readonly app.appHandle: string (opaque) and the methods listed below for app level. Window is the window object with readonly window.windowId: number and the methods listed below for window level. Neither object carries pid.
|
||||
|
||||
Setup and discovery:
|
||||
- cua.getCapabilities(options?: { emit?: boolean }) -> { platform: string, permissions: { accessibility: boolean, screenRecording: boolean }, appLevelAccessibility: boolean, keyboard: ('app' | 'window')[], sessionLocked: boolean | null, methods: string[], coordinateSpace: 'screenshot-pixels', browser: false } — inspect reported permissions, sessionLocked, methods, and keyboard scopes without requesting permissions.
|
||||
- cua.getCapabilities(options?: { emit?: boolean }) -> { platform: string, permissions: { accessibility: boolean, screenRecording: boolean }, appLevelAccessibility: boolean, systemPointer: boolean, keyboard: ('app' | 'window')[], sessionLocked: boolean | null, methods: string[], coordinateSpace: 'screenshot-pixels', browser: false } — inspect reported permissions, sessionLocked, methods, and keyboard scopes without requesting permissions.
|
||||
- cua.listApps(options?: { emit?: boolean }) -> { id: string, displayName: string, isRunning: true, pid: number }[] — enumerate running applications.
|
||||
- cua.findApps(query: string, options?: { emit?: boolean }) -> ({ id: string, displayName: string, path: string } & ({ isRunning: true, pid: number } | { isRunning: false }))[] — search installed applications, including stopped matches; only running matches carry pid.
|
||||
- cua.getState(options?: { emit?: boolean }) -> { apps: { id: string, displayName: string, isRunning: true, pid: number }[], browsers: [] } — summarize running applications; this is not an accessibility observation.
|
||||
@@ -212,7 +212,7 @@ Element ids are opaque strings from the latest successful tree; the object facad
|
||||
## Simulated mouse
|
||||
Use an explicit window for screenshots and pointer input.
|
||||
- getScreenshot(options?: { emit?: boolean }) -> { bytes: Uint8Array, mimeType: 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif', pixelWidth: number, pixelHeight: number, windowId: number } — capture the selected window as one encoded image; bytes is a JavaScript byte array, and mimeType identifies its encoding, so do not assume PNG.
|
||||
- mouseMove(target: string | [number, number], options?: { relative?: boolean }) -> { dispatched: boolean, foregroundChanged?: boolean | null, hoverRoute: 'accessibility' | 'system-pointer', systemPointerMoved: boolean } — request a mouse move to the target without pressing a button; inspect hoverRoute and systemPointerMoved, and expect no automatic pointer restoration. Default hover requires both AXShowAlternateUI and AXShowDefaultUI on an enabled element or ancestor. Without an AX route it returns HOVER_UNAVAILABLE before sending input. Prefer click on a visible control or performAction with an advertised action. For web content without an AX hover action, use browser tools. Only systemPointerHover: true permits moving the visible cursor. If this returns FOREGROUND_REQUIRED, activate the app and check that the point is unobscured.
|
||||
- mouseMove(target: string | [number, number], options?: { relative?: boolean, useSystemPointer?: boolean }) -> { dispatched: boolean, foregroundChanged?: boolean | null, hoverRoute: 'accessibility' | 'system-pointer', systemPointerMoved: boolean } — request a mouse move to the target without pressing a button; inspect hoverRoute and systemPointerMoved, and expect no automatic pointer restoration. Default hover requires both AXShowAlternateUI and AXShowDefaultUI on an enabled element or ancestor. Without that route, HOVER_UNAVAILABLE is returned before sending input. Physical movement requires an unobscured foreground target. Explain any necessary activation and observe the target again.
|
||||
- click(target: string | [number, number], options?: { mouseButton?: 'left' | 'right' | 'middle', clickCount?: 1 | 2, modifiers?: ('command' | 'control' | 'option' | 'shift' | 'fn')[], holdMs?: number, relative?: boolean }) -> ActionResult — click an element id or point, or perform a left-button double-click; holdMs controls the press duration of each press. click is a Window method: select a window with const win = await app.getWindow(windowId), then call win.click(target). For a background control’s default action, use app.invoke(id). For a mouse click on a background app, call app.activate() first and re-read the target. Both target forms send mouse-down and mouse-up events. click(id) uses the center of the full element bounds. If that center is outside the window, take a fresh win.getScreenshot() and use click([x, y]) on a visible part of the control in screenshot pixels. A background app can ignore the events even when dispatched is true. Verify the expected effect, such as a changed selection, before typing or retrying.
|
||||
- scroll(options: { x: number, y: number, deltaLinesX: number, deltaLinesY: number, relative?: boolean }) -> ActionResult — scroll at a point; deltas are integers from -10 to 10 with at least one nonzero, and positive values reveal content above or left.
|
||||
- drag(from: [number, number], to: [number, number], options?: { mouseButton?: 'left' | 'right' | 'middle', modifiers?: ('command' | 'control' | 'option' | 'shift' | 'fn')[], relative?: boolean }) -> ActionResult — drag between two points of one window.
|
||||
@@ -227,6 +227,8 @@ Available at app or window level depending on the platform.
|
||||
- sendKeyEvent(key: string, direction: 'down' | 'up') -> ActionResult — send 'down' to hold a key across tool calls in one run_code, for example W down, D down, D up, W up. Holding a key on a physical keyboard can produce repeated key-down events after a delay. To send one such repeat for a non-modifier key this run already holds, send 'down' again. Each call posts one event. A control can filter repeats. Use the app's response to decide whether and when to repeat or release the key. When this run_code returns, throws, or the user pauses it, macOS releases only its remaining keys at their original recipients.
|
||||
|
||||
Raw tools use { method, params }: include appHandle for app calls and windowId for window calls, and use snapshotId with elementId for element routes. The emit option is facade-only: emit is local output control, so raw callers must follow the raw operation schema for admitted params. getAccessibilityTree and getWindow read through raw getState; getWindows maps to listWindows; getScreenshot maps to screenshot; cua.getState maps to listApps; every other named operation uses the same method name.
|
||||
|
||||
Only if the task cannot be completed without moving the user’s pointer, call await win.mouseMove(target, { useSystemPointer: true }). Prefer an advertised accessibility action, a click on a visible control, or browser tools when possible. The question interrupts the user. When approval is disabled an explicit move can act immediately. Pass true on every physical move, even after approval. Omitting the option or passing false uses the default hover route without prompting. Approval or refusal lasts until this session closes or DSH restarts. A refusal sends no input. If the user dismisses the request, ask again only if they request it. Wait for an outstanding request to finish. Subagents cannot request physical movement.
|
||||
```
|
||||
|
||||
#### Token effect
|
||||
|
||||
@@ -60,11 +60,11 @@ pnpm dsh headless --patch packages/experimental/cua-macos/examples/macos.cordis.
|
||||
| `agentPreset` | `ptc` | 仅为绑定该 preset 的 Agent 注册工具和指引;`false` 表示全局注册。 |
|
||||
| `allowedApps` | `[]` | 旧路径和签名许可,只导入 profile 存储一次供撤销,不授予新 Bundle ID 身份访问权限。 |
|
||||
| `helperPath` | 包内可执行文件 | 已签名 helper `.app` 内可执行文件的绝对路径;裸二进制会触发 `NATIVE_APPLICATION_REQUIRED`。 |
|
||||
| `requireSystemPointerApproval` | `true` | 主任务首次显式请求移动系统指针时询问用户。设为 `false` 时仅本次显式移动免询问。 |
|
||||
| `sourceSigning` | `ad-hoc` | 源码签名:`ad-hoc` 临时签名,或 `{ identity, keychain }` 指定已有证书及钥匙串路径。安装版和 `helperPath` 忽略此项。 |
|
||||
| `buildTimeoutMs` | `300000` | 原生组件准备的最长时间。 |
|
||||
| `timeoutMs` | `30000` | 单次操作截止时间,取消后可能已发生部分输入。 |
|
||||
| `connectTimeoutMs` | `5000` | 连接或启动共享 helper 的截止时间。 |
|
||||
| `systemPointerHover` | `false` | AX 悬停不可用时,允许 `mouseMove` 移动可见光标。 |
|
||||
| `permissionPollIntervalMs` | `1000` | 原生引导打开期间检查权限和窗口位置的间隔,单位为毫秒。 |
|
||||
| `screenshotTimeoutMs` | `5000` | 截图总期限,包含排队时间,且不超过 `timeoutMs`。 |
|
||||
| `axTimeoutMs` | `3000` | 每次 Accessibility 系统调用的截止时间,与完整操作截止独立配置。 |
|
||||
@@ -117,7 +117,7 @@ void await window.getScreenshot()
|
||||
|
||||
`getWindow` 静默读取初始树。已确认归属但没有 AX 界面的窗口返回空快照,仍可截图和发送坐标输入。在单个 API 实例内,门面按应用句柄保存一份当前辅助功能快照,该句柄的所有应用对象和窗口对象共享它,包括重复选择。读取任一窗口都会替换该快照;元素操作使用最新快照 ID,因此应使用最新树中的元素 ID,不应假定旧 ID 仍指向同一控件。读取失败不会发布部分快照;可以重新读取,但不要重复此前的输入。窗口对象负责截图和指针输入。键盘方法保留在 `app` 上,因为输入跟随应用的当前焦点。
|
||||
|
||||
`mouseMove(target)` 默认通过 AX 悬停。目标或其祖先必须已启用,并同时声明 `AXShowAlternateUI` 和 `AXShowDefaultUI`。进入子控件会保持展开,离开后恢复默认界面。如果没有可用的 AX 路径,helper 返回 `HOVER_UNAVAILABLE`,不发送输入。可点击已经可见的控件,或用 `performAction` 执行控件声明的动作。浏览器内容缺少这些 AX 动作时,请使用浏览器工具。只有允许移动用户可见光标时才开启 `systemPointerHover`。这条路径要求目标在前台且未被遮挡,操作后光标停在目标位置。
|
||||
普通 `mouseMove(target)` 默认通过辅助功能悬停。如果目标或其祖先没有同时声明 `AXShowAlternateUI` 和 `AXShowDefaultUI`,则返回 `HOVER_UNAVAILABLE`,不会弹窗。只有任务确实需要移动用户的鼠标时,主任务才调用 `win.mouseMove(target, { useSystemPointer: true })`。用户允许后,Host 重新检查窗口和目标位置,再执行本次移动。每次移动系统指针都要显式传 `useSystemPointer: true`。省略参数或传 `false` 时使用辅助功能悬停。拒绝时不派发输入。会话记住此次选择,直到关闭会话或重启 DSH。
|
||||
|
||||
`paste(text, { format: "text" | "html" })` 临时提供指定格式的剪贴板内容,并调用应用已启用的标准 Paste 菜单命令。临时格式保留到剪贴板读取方请求其数据,然后只在仍持有剪贴板时恢复保存的全部原格式,保留期间产生的新复制内容。粘贴操作共用跨进程锁,取消会等待辅助进程清理。强制终止进程可能阻止恢复。应用不支持 Paste 命令时明确失败,不自动改成逐字输入;`typeText` 仍单独提供按键事件输入。
|
||||
|
||||
@@ -187,7 +187,7 @@ In persistent PTC, use the host-provided bridge initialization once and retain t
|
||||
Signatures show resolved return values after await. ActionResult = { dispatched: boolean, foregroundChanged?: boolean | null }; foregroundChanged is omitted when not sampled and null when the sample is unavailable. JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }; numbers must be finite. App is the application object with readonly app.appHandle: string (opaque) and the methods listed below for app level. Window is the window object with readonly window.windowId: number and the methods listed below for window level. Neither object carries pid.
|
||||
|
||||
Setup and discovery:
|
||||
- cua.getCapabilities(options?: { emit?: boolean }) -> { platform: string, permissions: { accessibility: boolean, screenRecording: boolean }, appLevelAccessibility: boolean, keyboard: ('app' | 'window')[], sessionLocked: boolean | null, methods: string[], coordinateSpace: 'screenshot-pixels', browser: false } — inspect reported permissions, sessionLocked, methods, and keyboard scopes without requesting permissions.
|
||||
- cua.getCapabilities(options?: { emit?: boolean }) -> { platform: string, permissions: { accessibility: boolean, screenRecording: boolean }, appLevelAccessibility: boolean, systemPointer: boolean, keyboard: ('app' | 'window')[], sessionLocked: boolean | null, methods: string[], coordinateSpace: 'screenshot-pixels', browser: false } — inspect reported permissions, sessionLocked, methods, and keyboard scopes without requesting permissions.
|
||||
- cua.listApps(options?: { emit?: boolean }) -> { id: string, displayName: string, isRunning: true, pid: number }[] — enumerate running applications.
|
||||
- cua.findApps(query: string, options?: { emit?: boolean }) -> ({ id: string, displayName: string, path: string } & ({ isRunning: true, pid: number } | { isRunning: false }))[] — search installed applications, including stopped matches; only running matches carry pid.
|
||||
- cua.getState(options?: { emit?: boolean }) -> { apps: { id: string, displayName: string, isRunning: true, pid: number }[], browsers: [] } — summarize running applications; this is not an accessibility observation.
|
||||
@@ -212,7 +212,7 @@ Element ids are opaque strings from the latest successful tree; the object facad
|
||||
## Simulated mouse
|
||||
Use an explicit window for screenshots and pointer input.
|
||||
- getScreenshot(options?: { emit?: boolean }) -> { bytes: Uint8Array, mimeType: 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif', pixelWidth: number, pixelHeight: number, windowId: number } — capture the selected window as one encoded image; bytes is a JavaScript byte array, and mimeType identifies its encoding, so do not assume PNG.
|
||||
- mouseMove(target: string | [number, number], options?: { relative?: boolean }) -> { dispatched: boolean, foregroundChanged?: boolean | null, hoverRoute: 'accessibility' | 'system-pointer', systemPointerMoved: boolean } — request a mouse move to the target without pressing a button; inspect hoverRoute and systemPointerMoved, and expect no automatic pointer restoration. Default hover requires both AXShowAlternateUI and AXShowDefaultUI on an enabled element or ancestor. Without an AX route it returns HOVER_UNAVAILABLE before sending input. Prefer click on a visible control or performAction with an advertised action. For web content without an AX hover action, use browser tools. Only systemPointerHover: true permits moving the visible cursor. If this returns FOREGROUND_REQUIRED, activate the app and check that the point is unobscured.
|
||||
- mouseMove(target: string | [number, number], options?: { relative?: boolean, useSystemPointer?: boolean }) -> { dispatched: boolean, foregroundChanged?: boolean | null, hoverRoute: 'accessibility' | 'system-pointer', systemPointerMoved: boolean } — request a mouse move to the target without pressing a button; inspect hoverRoute and systemPointerMoved, and expect no automatic pointer restoration. Default hover requires both AXShowAlternateUI and AXShowDefaultUI on an enabled element or ancestor. Without that route, HOVER_UNAVAILABLE is returned before sending input. Physical movement requires an unobscured foreground target. Explain any necessary activation and observe the target again.
|
||||
- click(target: string | [number, number], options?: { mouseButton?: 'left' | 'right' | 'middle', clickCount?: 1 | 2, modifiers?: ('command' | 'control' | 'option' | 'shift' | 'fn')[], holdMs?: number, relative?: boolean }) -> ActionResult — click an element id or point, or perform a left-button double-click; holdMs controls the press duration of each press. click is a Window method: select a window with const win = await app.getWindow(windowId), then call win.click(target). For a background control’s default action, use app.invoke(id). For a mouse click on a background app, call app.activate() first and re-read the target. Both target forms send mouse-down and mouse-up events. click(id) uses the center of the full element bounds. If that center is outside the window, take a fresh win.getScreenshot() and use click([x, y]) on a visible part of the control in screenshot pixels. A background app can ignore the events even when dispatched is true. Verify the expected effect, such as a changed selection, before typing or retrying.
|
||||
- scroll(options: { x: number, y: number, deltaLinesX: number, deltaLinesY: number, relative?: boolean }) -> ActionResult — scroll at a point; deltas are integers from -10 to 10 with at least one nonzero, and positive values reveal content above or left.
|
||||
- drag(from: [number, number], to: [number, number], options?: { mouseButton?: 'left' | 'right' | 'middle', modifiers?: ('command' | 'control' | 'option' | 'shift' | 'fn')[], relative?: boolean }) -> ActionResult — drag between two points of one window.
|
||||
@@ -227,6 +227,8 @@ Available at app or window level depending on the platform.
|
||||
- sendKeyEvent(key: string, direction: 'down' | 'up') -> ActionResult — send 'down' to hold a key across tool calls in one run_code, for example W down, D down, D up, W up. Holding a key on a physical keyboard can produce repeated key-down events after a delay. To send one such repeat for a non-modifier key this run already holds, send 'down' again. Each call posts one event. A control can filter repeats. Use the app's response to decide whether and when to repeat or release the key. When this run_code returns, throws, or the user pauses it, macOS releases only its remaining keys at their original recipients.
|
||||
|
||||
Raw tools use { method, params }: include appHandle for app calls and windowId for window calls, and use snapshotId with elementId for element routes. The emit option is facade-only: emit is local output control, so raw callers must follow the raw operation schema for admitted params. getAccessibilityTree and getWindow read through raw getState; getWindows maps to listWindows; getScreenshot maps to screenshot; cua.getState maps to listApps; every other named operation uses the same method name.
|
||||
|
||||
Only if the task cannot be completed without moving the user’s pointer, call await win.mouseMove(target, { useSystemPointer: true }). Prefer an advertised accessibility action, a click on a visible control, or browser tools when possible. The question interrupts the user. When approval is disabled an explicit move can act immediately. Pass true on every physical move, even after approval. Omitting the option or passing false uses the default hover route without prompting. Approval or refusal lasts until this session closes or DSH restarts. A refusal sends no input. If the user dismisses the request, ask again only if they request it. Wait for an outstanding request to finish. Subagents cannot request physical movement.
|
||||
```
|
||||
|
||||
#### Token 影响
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
en: 2a69d89b7fc95998
|
||||
zh: 084575bb31fb40dd
|
||||
/dsh-cua-native-helper/observation-and-input:
|
||||
en: 0808c25f70ce6eff
|
||||
zh: 820864a2b18b8a24
|
||||
en: 7e63469ddd8c4e72
|
||||
zh: dbc87b8d8953c7e7
|
||||
/dsh-cua-native-helper/verification:
|
||||
en: c87945aeec587bf5
|
||||
zh: e5d69e4927e2fed3
|
||||
|
||||
@@ -123,7 +123,7 @@ Tree observation and action discovery treat a generic AX failure reading one ele
|
||||
|
||||
`paste` publishes one plain-text or HTML clipboard format, dispatches the application's enabled standard Paste menu command, and waits for a clipboard data request. It then restores the preserved formats only while its ownership marker and change count still match. The Host serializes its pastes across helpers with a file lock. maxClipboardBytes bounds both input and preserved contents; unsupported menus and oversized data fail before dispatch. A later copy is preserved. Cancellation restores owned clipboard data when possible; forced termination cannot guarantee restoration, and a data request does not prove the target completed editing. After dispatch, `pasteReadTimeoutMs` bounds waiting for clipboard consumption. Expiry returns `PASTE_NOT_CONSUMED`, closes the promised data provider, and restores the original clipboard only while still owned. The failure does not prove the app will never process its queued Paste command; inspect the target before deciding whether to retry.
|
||||
|
||||
`mouseMove` reveals or restores alternate UI through an enabled element or ancestor advertising both `AXShowAlternateUI` and `AXShowDefaultUI`. Without an AX route it returns `HOVER_UNAVAILABLE` before sending input. The macOS provider sets `systemPointerHover` in the connection handshake. Diagnostic clients that omit this setting may opt in per request with `allowSystemPointer: true`. The default is false. Only true permits posting a system pointer move after checking the foreground process and window hit.
|
||||
`mouseMove` reveals or restores alternate UI through an enabled element or ancestor advertising both `AXShowAlternateUI` and `AXShowDefaultUI`. Without an AX route it returns `HOVER_UNAVAILABLE` before sending input. The Host resolves the requested route against session approval or the configured exemption before sending `useSystemPointer` to the helper.
|
||||
|
||||
The opt-in system pointer route returns `FOREGROUND_REQUIRED` if the target is obscured or belongs to a background app. The result reports the route and whether the visible cursor moved in `hoverRoute` and `systemPointerMoved`. It never presses a button or restores the cursor after a read. Later input may interrupt the hover. Clicks send their button events at the target without an off-window priming click.
|
||||
|
||||
|
||||
@@ -123,7 +123,7 @@ AppKit 在主事件循环上运行。阻塞式 socket 接收与握手使用后
|
||||
|
||||
`paste` 提供一种纯文本或 HTML 剪贴板格式,调用应用已启用的标准 Paste 菜单命令,并等待剪贴板数据请求。随后仅在所有权标记和修改计数仍匹配时恢复原有格式。Host 通过文件锁串行化不同 helper 的粘贴。maxClipboardBytes 同时限制输入和保留内容,不支持的菜单和过大的数据在分派前失败。后续复制会被保留。取消会尽可能恢复仍持有的剪贴板数据,强制结束不保证恢复,数据请求也不证明目标已完成编辑。 分派完成后,`pasteReadTimeoutMs` 限制等待剪贴板读取的时间。到期返回 `PASTE_NOT_CONSUMED`,关闭延迟数据提供方,并仅在仍持有剪贴板时恢复原内容。该失败不能证明应用以后不会处理已排队的 Paste 命令;决定是否重试前先检查目标内容。
|
||||
|
||||
默认情况下,`mouseMove` 通过同一窗口内启用的控件或祖先声明的 `AXShowAlternateUI` 和 `AXShowDefaultUI` 展开或恢复界面。缺少这条路径时返回 `HOVER_UNAVAILABLE`,不发送输入。macOS 提供方在连接握手中设置 `systemPointerHover`。未在握手中指定该配置的诊断客户端,可以在单次请求中传入 `allowSystemPointer: true`。默认值为 false。只有值为 true 才允许移动全局光标,且须检查前台进程和鼠标命中的窗口。
|
||||
默认情况下,`mouseMove` 通过同一窗口内启用的控件或祖先声明的 `AXShowAlternateUI` 和 `AXShowDefaultUI` 展开或恢复界面。缺少这条路径时返回 `HOVER_UNAVAILABLE`,不发送输入。Host 根据本会话的授权或免询问配置决定本次悬停的路径,再向 helper 传入 `useSystemPointer`。
|
||||
|
||||
开启后,系统指针路线会检查前台应用和坐标命中的窗口,不满足条件时返回 `FOREGROUND_REQUIRED`。返回值 `hoverRoute` 和 `systemPointerMoved` 说明走了哪条路线以及是否移动了可见光标。悬停不会按下鼠标,也不会在读取后还原光标。后续输入可能中断悬停。点击只在目标点发送按键事件,不先在窗口外点击。
|
||||
|
||||
|
||||
@@ -2,13 +2,6 @@
|
||||
import ApplicationServices
|
||||
|
||||
final class AXHover {
|
||||
/// Only diagnostic clients without a connection policy may opt in per request.
|
||||
static func requireSystemPointer(connectionSetting: Bool?, requestSetting: Bool?) throws {
|
||||
guard connectionSetting ?? requestSetting ?? false else {
|
||||
throw NativeFailure(code: "HOVER_UNAVAILABLE", message: "No enabled element at this target or in its ancestors offers both AXShowAlternateUI and AXShowDefaultUI. System-pointer hover is disabled; this provider has no other pointer-free hover route. No input was sent and the pointer did not move. Use click on an available control or performAction with an action from listActions.")
|
||||
}
|
||||
}
|
||||
|
||||
private var revealed: (element: AXUIElement, windowID: UInt32)?
|
||||
|
||||
/// Entering revealed descendants preserves their parent UI; leaving restores it once.
|
||||
|
||||
@@ -78,7 +78,6 @@ actor AXWorker {
|
||||
private let axTimeout: Float
|
||||
private let textEventInterval: Double
|
||||
private let maxClickHoldMs: Int
|
||||
private let systemPointerHover: Bool?
|
||||
private let sessionState: @Sendable () -> Bool?
|
||||
private var access: AXAccess?
|
||||
private let heldKeys: HeldKeys
|
||||
@@ -87,14 +86,13 @@ actor AXWorker {
|
||||
private var hovers: [String: AXHover] = [:]
|
||||
private var closed = false
|
||||
|
||||
init(cancellation: CancellationLatch, axTimeout: Float, textEventInterval: Double, maxClickHoldMs: Int, systemPointerHover: Bool? = nil, heldKeys: HeldKeys = HeldKeys(),
|
||||
init(cancellation: CancellationLatch, axTimeout: Float, textEventInterval: Double, maxClickHoldMs: Int, heldKeys: HeldKeys = HeldKeys(),
|
||||
sessionState: @escaping @Sendable () -> Bool? = NativePlatform.sessionLocked) {
|
||||
self.heldKeys = heldKeys
|
||||
self.cancellation = cancellation
|
||||
self.axTimeout = axTimeout
|
||||
self.textEventInterval = textEventInterval
|
||||
self.maxClickHoldMs = maxClickHoldMs
|
||||
self.systemPointerHover = systemPointerHover
|
||||
self.sessionState = sessionState
|
||||
}
|
||||
private func identifier(_ kind: String) throws -> String {
|
||||
@@ -108,13 +106,13 @@ actor AXWorker {
|
||||
access = created
|
||||
return created
|
||||
}
|
||||
private func moveUsingAX(_ element: AXUIElement?, windowID: UInt32, handle: String, params: RequestParameters) throws -> Bool {
|
||||
let requestSetting = try params.optionalBoolean("allowSystemPointer")
|
||||
private func moveUsingAX(_ element: AXUIElement?, windowID: UInt32, handle: String) throws -> DriverOutcome {
|
||||
let hover = hovers[handle] ?? AXHover()
|
||||
hovers[handle] = hover
|
||||
if try hover.move(to: element, in: windowID, access: ax()) { return true }
|
||||
try AXHover.requireSystemPointer(connectionSetting: systemPointerHover, requestSetting: requestSetting)
|
||||
return false
|
||||
guard try hover.move(to: element, in: windowID, access: ax()) else {
|
||||
throw NativeFailure(code: "HOVER_UNAVAILABLE", message: "No enabled element at this target or in its ancestors offers both AXShowAlternateUI and AXShowDefaultUI. No input was sent and the pointer did not move. Use click on an available control or performAction with an action from listActions.")
|
||||
}
|
||||
return .result(Self.mouseMoveResult(route: "accessibility", pointerMoved: false))
|
||||
}
|
||||
private func inputs() -> (KeyboardInput, WindowPointerInput) {
|
||||
if let keyboard, let pointer { return (keyboard, pointer) }
|
||||
@@ -367,6 +365,7 @@ actor AXWorker {
|
||||
try ax().selectText(element(handle, params), text: params.string("text"), prefix: params.optionalString("prefix"),
|
||||
suffix: params.optionalString("suffix"), mode: params.optionalString("selectionType") ?? "text")
|
||||
case "click", "mouseMove":
|
||||
let useSystemPointer = try params.optionalBoolean("useSystemPointer") ?? false
|
||||
let holdMs = params.contains("holdMs") ? try params.integer("holdMs", in: 0...maxClickHoldMs) : nil
|
||||
let button = try params.optionalString("mouseButton") ?? "left"
|
||||
let count = params.contains("clickCount") ? try params.integer("clickCount", in: 1...2) : 1
|
||||
@@ -386,9 +385,7 @@ actor AXWorker {
|
||||
}
|
||||
if method == "mouseMove" {
|
||||
_ = try NativePlatform.ownedWindow(identity.pid, enclosing.id)
|
||||
if try moveUsingAX(selected, windowID: enclosing.id, handle: handle, params: params) {
|
||||
return .result(Self.mouseMoveResult(route: "accessibility", pointerMoved: false))
|
||||
}
|
||||
if !useSystemPointer { return try moveUsingAX(selected, windowID: enclosing.id, handle: handle) }
|
||||
} else {
|
||||
try ax().requireEnabled(selected)
|
||||
}
|
||||
@@ -405,12 +402,10 @@ actor AXWorker {
|
||||
let route = WindowRoute(owner: identity, windowID: window.windowID, bounds: window.bounds)
|
||||
if method == "mouseMove" {
|
||||
try WindowPointerInput.validate(point, in: route)
|
||||
if elementWindow == nil {
|
||||
if !useSystemPointer {
|
||||
var hit = try ax().element(at: CGPoint(x: window.bounds.x + point.x, y: window.bounds.y + point.y), owner: identity)
|
||||
if let target = hit, try ax().windowID(target) != window.windowID { hit = nil }
|
||||
if try moveUsingAX(hit, windowID: window.windowID, handle: handle, params: params) {
|
||||
return .result(Self.mouseMoveResult(route: "accessibility", pointerMoved: false))
|
||||
}
|
||||
return try moveUsingAX(hit, windowID: window.windowID, handle: handle)
|
||||
}
|
||||
return .mouseMove(route, point)
|
||||
}
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ enum NativeProtocol {
|
||||
"listActions": ["appHandle", "elementId", "snapshotId", "windowId"],
|
||||
"listApps": [],
|
||||
"listWindows": ["appHandle"],
|
||||
"mouseMove": ["allowSystemPointer", "appHandle", "elementId", "snapshotId", "windowId", "x", "y"],
|
||||
"mouseMove": ["appHandle", "elementId", "snapshotId", "useSystemPointer", "windowId", "x", "y"],
|
||||
"openPermissionSettings": ["permission", "settingsMessage"],
|
||||
"paste": ["appHandle", "format", "maxClipboardBytes", "maxDepth", "maxElements", "pasteReadTimeoutMs", "text", "windowId"],
|
||||
"performAction": ["action", "appHandle", "argument", "elementId", "snapshotId", "windowId"],
|
||||
|
||||
@@ -43,7 +43,7 @@ import Darwin
|
||||
self.onFatal = onFatal
|
||||
worker = AXWorker(cancellation: cancellation, axTimeout: configuration.axTimeoutSeconds,
|
||||
textEventInterval: configuration.textEventIntervalSeconds, maxClickHoldMs: configuration.maxClickHoldMs,
|
||||
systemPointerHover: configuration.systemPointerHover, heldKeys: scheduler.heldKeys)
|
||||
heldKeys: scheduler.heldKeys)
|
||||
input = JSONLInput(limit: configuration.maxRequestBytes, cancellation: cancellation, channel: channel)
|
||||
self.writer = writer
|
||||
}
|
||||
|
||||
@@ -21,7 +21,6 @@ struct HelperConfiguration: Sendable {
|
||||
var shutdownGraceSeconds: Double = 3
|
||||
var textEventIntervalSeconds: Double = 0.00025
|
||||
var maxClickHoldMs = 5000
|
||||
var systemPointerHover: Bool?
|
||||
var timeoutSeconds: Double = 30
|
||||
var screenshotTimeoutSeconds: Double = 5
|
||||
init(arguments: [String]) throws {
|
||||
@@ -65,7 +64,7 @@ struct HelperConfiguration: Sendable {
|
||||
guard let fields = decoded.object, Set(fields.keys) == ["type", "protocol", "runtimeKey", "config"],
|
||||
fields["type"] == .string("hello"), fields["protocol"] == .number(1),
|
||||
fields["runtimeKey"] == .string(runtimeKey), let config = fields["config"]?.object,
|
||||
Set(config.keys).subtracting(["systemPointerHover"]) == ["maxRequestBytes", "maxResponseBytes", "axTimeoutMs", "textEventIntervalMs", "maxClickHoldMs", "screenshotTimeoutMs", "timeoutMs", "graceMs"] else {
|
||||
Set(config.keys) == ["maxRequestBytes", "maxResponseBytes", "axTimeoutMs", "textEventIntervalMs", "maxClickHoldMs", "screenshotTimeoutMs", "timeoutMs", "graceMs"] else {
|
||||
throw NativeFailure(code: "NATIVE_PROTOCOL", message: "Expected shared helper protocol 1 hello for this runtime")
|
||||
}
|
||||
func number(_ name: String) throws -> String {
|
||||
@@ -83,10 +82,6 @@ struct HelperConfiguration: Sendable {
|
||||
"--screenshot-timeout-ms", number("screenshotTimeoutMs"), "--timeout-ms", number("timeoutMs"),
|
||||
"--shutdown-grace-ms", number("graceMs"),
|
||||
])
|
||||
if let setting = config["systemPointerHover"] {
|
||||
guard case .bool(let allowed) = setting else { throw RequestParameters.invalid("systemPointerHover must be a boolean") }
|
||||
systemPointerHover = allowed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import { PermissionGuideParameters, PermissionSettingsParameters } from '../src/
|
||||
export function renderNativeProtocol(): { json: string; swift: string } {
|
||||
const { fields, enums, json } = nativeProtocol({
|
||||
sendKeyEvent: z.object({ runId: z.string().min(1) }),
|
||||
mouseMove: z.object({ allowSystemPointer: z.boolean().optional() }),
|
||||
mouseMove: z.object({ useSystemPointer: z.boolean().optional() }),
|
||||
requestPermission: PermissionGuideParameters, openPermissionSettings: PermissionSettingsParameters,
|
||||
}, [z.object({
|
||||
method: z.literal('releaseHeldKeys'), params: z.object({ runId: z.string().min(1) }).strict(),
|
||||
|
||||
@@ -489,7 +489,7 @@
|
||||
"minLength": 1,
|
||||
"description": "Opaque string defined by the platform provider, valid for at least the lifetime of one snapshot."
|
||||
},
|
||||
"allowSystemPointer": {
|
||||
"useSystemPointer": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
@@ -524,7 +524,7 @@
|
||||
"type": "number",
|
||||
"minimum": 0
|
||||
},
|
||||
"allowSystemPointer": {
|
||||
"useSystemPointer": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -37,6 +37,7 @@ class DragPad: NSView {
|
||||
}
|
||||
}
|
||||
final class AlternateHoverPad: DragPad {
|
||||
private(set) var defaultUIRequests = 0
|
||||
override func isAccessibilityElement() -> Bool { true }
|
||||
override func accessibilityRole() -> NSAccessibility.Role? { .group }
|
||||
override func accessibilityLabel() -> String? { "Alternate hover controls" }
|
||||
@@ -44,6 +45,7 @@ final class AlternateHoverPad: DragPad {
|
||||
hoverButton.isHidden = false; changed?(); return true
|
||||
}
|
||||
override func accessibilityPerformShowDefaultUI() -> Bool {
|
||||
defaultUIRequests += 1
|
||||
hoverButton.isHidden = true; changed?(); return true
|
||||
}
|
||||
}
|
||||
@@ -212,6 +214,7 @@ final class Fixture: NSObject, NSApplicationDelegate, NSTextFieldDelegate {
|
||||
"rowCount": rowCount, "primaryWindowId": window.windowNumber,
|
||||
"dragDownCount": dragPad.dragDownCount, "dragCount": dragPad.dragCount, "dragPoint": [dragPad.lastPoint.x, dragPad.lastPoint.y],
|
||||
"hoverVisible": !dragPad.hoverButton.isHidden, "hoverClicks": dragPad.hoverClicks,
|
||||
"defaultUIRequests": (dragPad as? AlternateHoverPad)?.defaultUIRequests ?? 0,
|
||||
"cursorSamples": cursorSamples, "cursorChanges": cursorChanges,
|
||||
"overlayMode": overlayMode, "overlayWindowId": inputOverlay?.windowNumber ?? 0,
|
||||
"overlayOnScreen": overlayOnScreen, "hoverHitWindow": hoverHitWindow,
|
||||
|
||||
@@ -220,7 +220,7 @@ def main():
|
||||
return result
|
||||
|
||||
def check_hover():
|
||||
if not args.alternate_hover:
|
||||
if not args.alternate_hover or args.input_overlays:
|
||||
call("activate", owner)
|
||||
current = call("getState", primary_params)
|
||||
window_element = next(element for element in current["elements"] if element["role"] == "AXWindow")
|
||||
@@ -236,7 +236,7 @@ def main():
|
||||
assert (after["cursorChanges"], after["hoverVisible"]) == (before["cursorChanges"], before["hoverVisible"]), after
|
||||
|
||||
def mouse_move(target):
|
||||
result = call("mouseMove", target if args.alternate_hover else {**target, "allowSystemPointer": True})
|
||||
result = call("mouseMove", target if args.alternate_hover else {**target, "useSystemPointer": True})
|
||||
if not args.alternate_hover:
|
||||
assert result["hoverRoute"] == "system-pointer" and result["systemPointerMoved"] is True, result
|
||||
if args.alternate_hover:
|
||||
@@ -271,11 +271,21 @@ def main():
|
||||
mouse_move(target)
|
||||
wait_state(lambda actual: actual["hoverVisible"], "Input-transparent overlay blocked hover")
|
||||
before = cycle_overlay(2)
|
||||
call("mouseMove", {**target, "allowSystemPointer": True}, "FOREGROUND_REQUIRED")
|
||||
call("mouseMove", {**target, "useSystemPointer": True}, "FOREGROUND_REQUIRED")
|
||||
after = wait_state(lambda actual: actual["cursorSamples"] >= before["cursorSamples"] + 2,
|
||||
"Pointer observation did not advance")
|
||||
assert after["cursorChanges"] == before["cursorChanges"], "Rejected hover moved the system pointer"
|
||||
if args.alternate_hover:
|
||||
assert before["hoverVisible"] and after["hoverVisible"], "Rejected physical hover dismissed AX controls"
|
||||
assert after["defaultUIRequests"] == before["defaultUIRequests"], after
|
||||
cycle_overlay(0)
|
||||
if args.alternate_hover:
|
||||
state = call("getState", primary_params)
|
||||
child = button_target(state, "Use native hover details")
|
||||
result = call("mouseMove", {**child, "windowId": primary["windowId"], "useSystemPointer": True})
|
||||
assert result["hoverRoute"] == "system-pointer" and result["systemPointerMoved"], result
|
||||
current = json.loads(state_path.read_text())
|
||||
assert current["hoverVisible"] and current["defaultUIRequests"] == before["defaultUIRequests"], current
|
||||
mouse_move({**owner, "windowId": primary["windowId"], "x": 10, "y": 40})
|
||||
wait_state(lambda actual: not actual["hoverVisible"], "Hover did not exit after removing the overlay")
|
||||
|
||||
|
||||
@@ -136,7 +136,7 @@ def main():
|
||||
call('mouseMove', {**owner, 'snapshotId': state['snapshotId'], 'elementId': trigger['elementId'], 'x': 0}, 'INVALID_ARGUMENT')
|
||||
hover_target = {**owner, 'snapshotId': state['snapshotId'], 'elementId': trigger['elementId']}
|
||||
call('mouseMove', hover_target, 'HOVER_UNAVAILABLE')
|
||||
call('mouseMove', {**hover_target, 'allowSystemPointer': True})
|
||||
call('mouseMove', {**hover_target, 'useSystemPointer': True})
|
||||
actual = wait_state(lambda actual: actual['popupOpen'], 'Hover did not reveal delayed information')
|
||||
assert actual['pointerDowns'] == baseline and actual['lastMove'] == {'buttons': 0, 'trusted': True}, actual
|
||||
state = observe()
|
||||
@@ -151,13 +151,13 @@ def main():
|
||||
target = {**owner, 'windowId': window_id,
|
||||
'x': bounds[0] + bounds[2] / 2 - window['bounds']['x'],
|
||||
'y': bounds[1] + bounds[3] / 2 - window['bounds']['y']}
|
||||
call('mouseMove', {**target, 'allowSystemPointer': True})
|
||||
call('mouseMove', {**target, 'useSystemPointer': True})
|
||||
state = observe()
|
||||
assert 'Hover information' in state['tree'], 'Moving into the popup dismissed it'
|
||||
call('click', target)
|
||||
actual = wait_state(lambda actual: actual['popupClicks'] == expected_clicks, 'Popup action did not receive its click')
|
||||
assert actual['popupOpen'] and actual['pointerDowns'] == baseline + 1 and actual['popupClickTrusted'], actual
|
||||
call('mouseMove', {**owner, 'windowId': window_id, 'x': 10, 'y': 40, 'allowSystemPointer': True})
|
||||
call('mouseMove', {**owner, 'windowId': window_id, 'x': 10, 'y': 40, 'useSystemPointer': True})
|
||||
wait_state(lambda actual: not actual['popupOpen'], 'Leaving the hover region did not close it')
|
||||
hover_sequence()
|
||||
helper.eof()
|
||||
|
||||
@@ -33,14 +33,6 @@ private func hello(overrides: [String: JSONValue] = [:]) throws -> Data {
|
||||
}
|
||||
}
|
||||
|
||||
@Test func readsSystemPointerHoverFromEachConnection() throws {
|
||||
#expect(try HelperConfiguration(hello: hello(), runtimeKey: runtimeKey).systemPointerHover == nil)
|
||||
for allowed in [true, false] {
|
||||
let configuration = try HelperConfiguration(hello: hello(overrides: ["systemPointerHover": .bool(allowed)]), runtimeKey: runtimeKey)
|
||||
#expect(configuration.systemPointerHover == allowed)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func resolvesClickHoldLimitFromHelperArguments() throws {
|
||||
#expect(try HelperConfiguration(arguments: []).maxClickHoldMs == 5000)
|
||||
for limit in [0, 7000] {
|
||||
@@ -56,7 +48,7 @@ private func hello(overrides: [String: JSONValue] = [:]) throws -> Data {
|
||||
("axTimeoutMs", .number(0)), ("textEventIntervalMs", .number(-1)),
|
||||
("screenshotTimeoutMs", .number(0)), ("timeoutMs", .string("8000")), ("graceMs", .number(-1)),
|
||||
("maxClickHoldMs", .number(-1)), ("maxClickHoldMs", .number(0.5)),
|
||||
("maxClickHoldMs", .number(2147483648)), ("unexpected", .bool(true)), ("systemPointerHover", .string("true")),
|
||||
("maxClickHoldMs", .number(2147483648)), ("unexpected", .bool(true)), ("systemPointerHover", .bool(true)),
|
||||
])
|
||||
func refusesInvalidSharedConfiguration(_ name: String, _ value: JSONValue) throws {
|
||||
#expect(throws: NativeFailure.self) { try HelperConfiguration(hello: hello(overrides: [name: value]), runtimeKey: runtimeKey) }
|
||||
|
||||
@@ -3,20 +3,6 @@ import Testing
|
||||
import CoreGraphics
|
||||
@testable import CuaNative
|
||||
|
||||
@Test func unavailableAXHoverHonorsConnectionPolicyBeforeRequestFlags() throws {
|
||||
for (connection, request): (Bool?, Bool?) in [(nil, nil), (nil, false), (false, nil), (false, true)] {
|
||||
do {
|
||||
try AXHover.requireSystemPointer(connectionSetting: connection, requestSetting: request)
|
||||
Issue.record("An unapproved request was allowed to move the pointer")
|
||||
} catch let failure as NativeFailure {
|
||||
#expect(failure.code == "HOVER_UNAVAILABLE")
|
||||
#expect(failure.message.contains("No input was sent"))
|
||||
}
|
||||
}
|
||||
try AXHover.requireSystemPointer(connectionSetting: true, requestSetting: nil)
|
||||
try AXHover.requireSystemPointer(connectionSetting: nil, requestSetting: true)
|
||||
}
|
||||
|
||||
@Test func systemHoverRequiresTheForegroundUnobscuredTarget() throws {
|
||||
let owner = ProcessIdentity(pid: 42, startedSeconds: 1, startedMicroseconds: 0)
|
||||
let bounds = WindowBounds(x: 100, y: 100, width: 400, height: 300)
|
||||
|
||||
@@ -6,11 +6,11 @@ export const PLATFORM_GUIDANCE = 'macOS: accessibility tree operations are avail
|
||||
|
||||
const methodDetails = new Map<string, string>([
|
||||
['getAccessibilityTree', 'If the tree reports maxElements or maxDepth truncation, select a narrower window or ask the user to raise the named macOS provider setting and read again. In persistent PTC, capture a large tree with const tree = await win.getAccessibilityTree({ emit: false }). Filter its lines in memory, or save it with await tools.write({ file_path: path, content: tree }) to a new workspace file. Search its contents with tools.grep({ path, pattern }) and use tools.read around a matching line. Print only the matches you need. Use element ids from the file only while that observation is current.'],
|
||||
['mouseMove', 'Default hover requires both AXShowAlternateUI and AXShowDefaultUI on an enabled element or ancestor. Without an AX route it returns HOVER_UNAVAILABLE before sending input. Prefer click on a visible control or performAction with an advertised action. For web content without an AX hover action, use browser tools. Only systemPointerHover: true permits moving the visible cursor. If this returns FOREGROUND_REQUIRED, activate the app and check that the point is unobscured.'],
|
||||
['mouseMove', 'Default hover requires both AXShowAlternateUI and AXShowDefaultUI on an enabled element or ancestor. Without that route, HOVER_UNAVAILABLE is returned before sending input. Physical movement requires an unobscured foreground target. Explain any necessary activation and observe the target again.'],
|
||||
['click', 'click is a Window method: select a window with const win = await app.getWindow(windowId), then call win.click(target). For a background control’s default action, use app.invoke(id). For a mouse click on a background app, call app.activate() first and re-read the target. Both target forms send mouse-down and mouse-up events. click(id) uses the center of the full element bounds. If that center is outside the window, take a fresh win.getScreenshot() and use click([x, y]) on a visible part of the control in screenshot pixels. A background app can ignore the events even when dispatched is true. Verify the expected effect, such as a changed selection, before typing or retrying.'],
|
||||
])
|
||||
|
||||
/** Complete model instructions for this provider. */
|
||||
/** Native operation instructions; the shared Host adds guidance for declared capabilities. */
|
||||
export const GUIDANCE = `${PLATFORM_GUIDANCE}
|
||||
|
||||
${COMMON_GUIDANCE.replace(/^- ([\w.]+)\(.*$/gm, (entry, method: string) => {
|
||||
|
||||
@@ -4,7 +4,6 @@ import { fileURLToPath } from 'node:url'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import Schema from '@deepseek-ai/schemastery'
|
||||
import { installCua, type CuaConfig, type CuaStartupCapabilities } from '@deepseek-ai/dsh-cua'
|
||||
import type { NativeChannelFactory } from '@deepseek-ai/dsh-cua/native-client'
|
||||
import { CuaConfigFields } from '@deepseek-ai/dsh-cua/config'
|
||||
import { createPermissionParameters } from './permission-guide.ts'
|
||||
import { nativeHostArchitecture, prepareNativeExecutable, type SourceSigning } from './native-build.ts'
|
||||
@@ -14,7 +13,7 @@ import { GUIDANCE } from './guidance.ts'
|
||||
export { GUIDANCE } from './guidance.ts'
|
||||
|
||||
const CAPABILITIES: CuaStartupCapabilities = {
|
||||
platform: 'macos',
|
||||
platform: 'macos', systemPointer: true,
|
||||
methods: [
|
||||
'activate', 'click', 'drag', 'findApps', 'getApp', 'getCapabilities', 'getState', 'invoke',
|
||||
'listActions', 'listApps', 'listWindows', 'mouseMove', 'paste', 'performAction', 'pressKey', 'screenshot', 'scroll',
|
||||
@@ -34,8 +33,6 @@ export const inject = ['computerUse', 'tools', 'systemPrompt', 'subprocess', 'ag
|
||||
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.
|
||||
@@ -46,7 +43,6 @@ export interface Config extends CuaConfig {
|
||||
/** Validate shared operation bounds and macOS source signing before preparing the helper. */
|
||||
export const Config = Schema.object({
|
||||
...CuaConfigFields,
|
||||
systemPointerHover: Schema.boolean().default(false),
|
||||
permissionPollIntervalMs: Schema.number().step(1).min(100).max(2 ** 31 - 1).default(1000),
|
||||
sourceSigning: Schema.union([
|
||||
Schema.const('ad-hoc'),
|
||||
@@ -65,8 +61,8 @@ export const Config = Schema.object({
|
||||
* @returns Completion after native preparation and tool registration.
|
||||
*/
|
||||
export async function apply(ctx: Context, config: Config): Promise<void> {
|
||||
const createChannel: NativeChannelFactory = (owner, native) => createSharedNativeChannel(owner, native, config.systemPointerHover)
|
||||
const backend = { createChannel, sendKeyEvent: installHeldKeys(ctx), guidance: GUIDANCE, capabilities: CAPABILITIES,
|
||||
const backend = { createChannel: createSharedNativeChannel, sendKeyEvent: installHeldKeys(ctx),
|
||||
guidance: GUIDANCE, capabilities: CAPABILITIES,
|
||||
permissionParameters: createPermissionParameters(config.permissionPollIntervalMs) }
|
||||
if (config.helperPath !== undefined) {
|
||||
if (!isAbsolute(config.helperPath)) throw new Error('dsh-cua helperPath must be absolute')
|
||||
|
||||
@@ -56,8 +56,7 @@ class SharedNativeChannel implements NativeChannel {
|
||||
private cleanupTimer: ReturnType<typeof setTimeout> | undefined
|
||||
private cleanupFailure: Error | undefined
|
||||
|
||||
constructor(private readonly owner: { subprocess: Pick<SubprocessRuntime, 'spawn'> }, private readonly config: NativeClientConfig,
|
||||
private readonly systemPointerHover: boolean) {
|
||||
constructor(private readonly owner: { subprocess: Pick<SubprocessRuntime, 'spawn'> }, private readonly config: NativeClientConfig) {
|
||||
this.stdin = new Writable({
|
||||
write: (chunk: Buffer, encoding, callback) => {
|
||||
void this.ready.promise.then((socket) => {
|
||||
@@ -216,7 +215,7 @@ class SharedNativeChannel implements NativeChannel {
|
||||
screenshotTimeoutMs, timeoutMs, graceMs } = this.config
|
||||
socket.write(JSON.stringify({ type: 'hello', protocol: 1, runtimeKey,
|
||||
config: { maxRequestBytes, maxResponseBytes, axTimeoutMs, textEventIntervalMs, maxClickHoldMs,
|
||||
screenshotTimeoutMs, timeoutMs, graceMs, systemPointerHover: this.systemPointerHover } }) + '\n')
|
||||
screenshotTimeoutMs, timeoutMs, graceMs } }) + '\n')
|
||||
})
|
||||
const receive = (chunk: Buffer): void => {
|
||||
const newline = chunk.indexOf(10)
|
||||
@@ -288,9 +287,8 @@ class SharedNativeChannel implements NativeChannel {
|
||||
* Prepare or join the native process; writes wait for identity-checked readiness.
|
||||
* @param owner - Provider owning the short-lived native startup command.
|
||||
* @param config - Native runtime identity, operation limits and connection deadlines.
|
||||
* @param systemPointerHover - Whether this connection may move the visible pointer when AX hover is unavailable.
|
||||
* @returns One independently disposable Agent connection, without operation replay.
|
||||
*/
|
||||
export function createSharedNativeChannel(owner: { subprocess: Pick<SubprocessRuntime, 'spawn'> }, config: NativeClientConfig, systemPointerHover: boolean): NativeChannel {
|
||||
return new SharedNativeChannel(owner, config, systemPointerHover)
|
||||
export function createSharedNativeChannel(owner: { subprocess: Pick<SubprocessRuntime, 'spawn'> }, config: NativeClientConfig): NativeChannel {
|
||||
return new SharedNativeChannel(owner, config)
|
||||
}
|
||||
@@ -34,12 +34,12 @@ it('states the macOS accessibility, keyboard, and mouse levels', () => {
|
||||
expect(pressKey).toBe(COMMON_GUIDANCE.split('\n').find(line => line.startsWith('- pressKey(')))
|
||||
expect(GUIDANCE).toContain('W down, D down, D up, W up')
|
||||
const hover = GUIDANCE.split('\n').find(line => line.startsWith('- mouseMove('))
|
||||
for (const term of ['AXShowAlternateUI', 'AXShowDefaultUI', 'HOVER_UNAVAILABLE', 'systemPointerHover']) expect(hover).toContain(term)
|
||||
for (const term of ['AXShowAlternateUI', 'AXShowDefaultUI', 'HOVER_UNAVAILABLE']) expect(hover).toContain(term)
|
||||
const click = GUIDANCE.split('\n').find(line => line.startsWith('- click('))
|
||||
expect(click).toContain('await app.getWindow(windowId), then call win.click(target)')
|
||||
expect(click).toContain('use app.invoke(id)')
|
||||
for (const step of ['call app.activate() first', 'outside the window', 'screenshot pixels', 'before typing or retrying']) expect(click).toContain(step)
|
||||
for (const platformDetail of ['macOS', 'AXShowAlternateUI', 'AXRaise', 'HOVER_UNAVAILABLE', 'systemPointerHover', 'mouse-down']) {
|
||||
for (const platformDetail of ['macOS', 'AXShowAlternateUI', 'AXRaise', 'HOVER_UNAVAILABLE', 'mouse-down']) {
|
||||
expect(COMMON_GUIDANCE).not.toContain(platformDetail)
|
||||
}
|
||||
})
|
||||
@@ -72,7 +72,7 @@ it('inlines discovery, restoration, screenshot, and mouse-move return records',
|
||||
for (const name of ['cua.getCapabilities', 'cua.listApps', 'cua.findApps', 'cua.getState', 'app.getWindows', 'app.show', 'window.show', 'getScreenshot', 'mouseMove']) {
|
||||
expect(lines.find(line => line.startsWith(`- ${name}(`))).toMatch(/ -> \(?\{ /)
|
||||
}
|
||||
expect(COMMON_GUIDANCE).toContain("{ platform: string, permissions: { accessibility: boolean, screenRecording: boolean }, appLevelAccessibility: boolean, keyboard: ('app' | 'window')[], sessionLocked: boolean | null, methods: string[], coordinateSpace: 'screenshot-pixels', browser: false }")
|
||||
expect(COMMON_GUIDANCE).toContain("{ platform: string, permissions: { accessibility: boolean, screenRecording: boolean }, appLevelAccessibility: boolean, systemPointer: boolean, keyboard: ('app' | 'window')[], sessionLocked: boolean | null, methods: string[], coordinateSpace: 'screenshot-pixels', browser: false }")
|
||||
expect(COMMON_GUIDANCE).toContain('{ id: string, displayName: string, isRunning: true, pid: number }[]')
|
||||
expect(COMMON_GUIDANCE).toContain('({ id: string, displayName: string, path: string } & ({ isRunning: true, pid: number } | { isRunning: false }))[]')
|
||||
expect(COMMON_GUIDANCE).toContain('bounds: { x: number, y: number, width: number, height: number }, isOnScreen: boolean, onCurrentSpace: boolean | null')
|
||||
|
||||
@@ -33,7 +33,6 @@ describe('native protocol generation', () => {
|
||||
{ method: 'drag', params: { appHandle: 'app', windowId: 1, fromX: 1, fromY: 1, toX: 2, toY: 2, steps: 1 } },
|
||||
{ method: 'paste', params: { appHandle: 'app', text: 'text', maxClipboardBytes: 1024 } },
|
||||
{ method: 'paste', params: { appHandle: 'app', text: 'text', pasteReadTimeoutMs: 1000 } },
|
||||
{ method: 'mouseMove', params: { appHandle: 'app', windowId: 1, x: 0, y: 0, allowSystemPointer: true } },
|
||||
{ method: 'findApps', params: { query: 'Editor', maxAppMatches: 1 } },
|
||||
{ method: 'releaseHeldKeys', params: { runId: 'execution' } },
|
||||
{ method: 'launchApp', params: { appHandle: 'pending' } },
|
||||
|
||||
@@ -14,10 +14,10 @@ vi.mock('@deepseek-ai/dsh-cua', async importOriginal => ({
|
||||
}))
|
||||
vi.mock('../src/shared-native.ts', () => ({ createSharedNativeChannel: vi.fn() }))
|
||||
|
||||
it.each([false, true])('forwards systemPointerHover=%s and native limits to each channel', async (systemPointerHover) => {
|
||||
it('forwards native limits to each channel', async () => {
|
||||
const ctx = new Context()
|
||||
onTestFinished(() => ctx.fiber.dispose())
|
||||
const config = Config({ helperPath: resolve('fixture-helper'), systemPointerHover })
|
||||
const config = Config({ helperPath: resolve('fixture-helper') })
|
||||
const channel: NativeChannel = {
|
||||
stdin: new PassThrough(), stdout: new PassThrough(), done: Promise.resolve(),
|
||||
close() {}, waitForClose: async () => {},
|
||||
@@ -32,5 +32,5 @@ it.each([false, true])('forwards systemPointerHover=%s and native limits to each
|
||||
const owner = { subprocess: { spawn: vi.fn() } }
|
||||
const native = { ...config, helperPath: resolve('fixture-helper'), cwd: process.cwd() }
|
||||
expect(backend.createChannel(owner, native)).toBe(channel)
|
||||
expect(createSharedNativeChannel).toHaveBeenCalledExactlyOnceWith(owner, native, systemPointerHover)
|
||||
expect(createSharedNativeChannel).toHaveBeenCalledExactlyOnceWith(owner, native)
|
||||
})
|
||||
@@ -17,11 +17,12 @@ import * as StorageDomain from '@deepseek-ai/dsh-storage-domain'
|
||||
import Hmr from '@deepseek-ai/dsh-hmr'
|
||||
import { mountAgentLoopTestDependencies, mountAgentLoopTestHarness } from '@deepseek-ai/dsh-agent-loop-testkit'
|
||||
import ComputerUseRegistry from '@deepseek-ai/dsh-computer-use'
|
||||
import { CuaConfig } from '@deepseek-ai/dsh-cua'
|
||||
import { COMMON_GUIDANCE } from '@deepseek-ai/dsh-cua/guidance'
|
||||
import { CuaConfig, installCua } from '@deepseek-ai/dsh-cua'
|
||||
import { createConfigProjector } from '@deepseek-ai/dsh-app-boot/src/config-schema/projector.ts'
|
||||
import LocalAttachmentStore from '@deepseek-ai/dsh-attachment-local'
|
||||
import FileSystem from '@deepseek-ai/dsh-fs-local'
|
||||
import UserQuestions from '@deepseek-ai/dsh-user-questions'
|
||||
import UserQuestions, { UserQuestionError } from '@deepseek-ai/dsh-user-questions'
|
||||
import NodeRuntime from '@deepseek-ai/dsh-ptc-runtime-node'
|
||||
import Sandbox from '@deepseek-ai/dsh-sandbox-local'
|
||||
import SandboxPolicy from '@deepseek-ai/dsh-sandbox-policy'
|
||||
@@ -67,7 +68,7 @@ vi.mock('@deepseek-ai/dsh-cua/native-client', async importOriginal => ({
|
||||
}))
|
||||
|
||||
const config = {
|
||||
permissionPollIntervalMs: 1000, systemPointerHover: false,
|
||||
permissionPollIntervalMs: 1000, requireSystemPointerApproval: true,
|
||||
agentPreset: false,
|
||||
helperPath: resolve('fixture-helper'), sourceSigning: 'ad-hoc', buildTimeoutMs: 300_000, timeoutMs: 10_000, connectTimeoutMs: 5000, screenshotTimeoutMs: 5000, axTimeoutMs: 3000, maxRequestBytes: 1024, maxResponseBytes: 4096, maxClipboardBytes: 8192, pasteReadTimeoutMs: 1000, maxAppMatches: 20,
|
||||
graceMs: 10, textEventIntervalMs: 8, maxClickHoldMs: 7000, maxElements: 64, maxDepth: 8, dragDurationMs: 200, dragSteps: 10,
|
||||
@@ -77,7 +78,7 @@ const { helperPath: _helperPath, ...automaticConfig } = config
|
||||
|
||||
it('defaults to ad-hoc signing and requires a keychain path for certificate signing', () => {
|
||||
expect(NativeProvider.Config({}).sourceSigning).toBe('ad-hoc')
|
||||
expect(NativeProvider.Config({}).systemPointerHover).toBe(false)
|
||||
expect(NativeProvider.Config({}).requireSystemPointerApproval).toBe(true)
|
||||
expect(NativeProvider.Config({ sourceSigning: 'ad-hoc' }).sourceSigning).toBe('ad-hoc')
|
||||
for (const sourceSigning of [{ identity: 'Apple Development: Example', keychain: '/keychains/custom.keychain-db' }, { identity: 'ABC123', keychain: '/keychains/custom.keychain-db' }]) {
|
||||
expect(NativeProvider.Config({ sourceSigning }).sourceSigning).toEqual(sourceSigning)
|
||||
@@ -288,7 +289,7 @@ it('merges startup capabilities into runtime native fields', async () => {
|
||||
native.request.mockResolvedValueOnce(runtime)
|
||||
const result = await f.execute('getCapabilities')
|
||||
expect(result.isError).toBe(false)
|
||||
expect(result.value).toEqual({ ...runtime, platform: 'macos', appLevelAccessibility: true, keyboard: ['app'], browser: false,
|
||||
expect(result.value).toEqual({ ...runtime, platform: 'macos', systemPointer: true, appLevelAccessibility: true, keyboard: ['app'], browser: false,
|
||||
coordinateSpace: 'screenshot-pixels', methods: [
|
||||
'activate', 'click', 'drag', 'findApps', 'getApp', 'getCapabilities', 'getState', 'invoke',
|
||||
'listActions', 'listApps', 'listWindows', 'mouseMove', 'paste', 'performAction', 'pressKey', 'screenshot', 'scroll',
|
||||
@@ -970,3 +971,184 @@ it('routes permission actions through the macOS provider with the Client languag
|
||||
permission: 'screenRecording', settingsMessage: 'Open System Settings > Privacy & Security > Screen Recording manually.',
|
||||
}, expect.any(AbortSignal))
|
||||
})
|
||||
|
||||
const hoverTarget = { appHandle: 'app-one', snapshotId: 'snapshot', elementId: '0' }
|
||||
const physicalHover = { ...hoverTarget, useSystemPointer: true }
|
||||
|
||||
async function hoverFixture(requireApproval = true) {
|
||||
const f = await setup()
|
||||
if (!requireApproval) {
|
||||
await f.provider.dispose()
|
||||
await f.ctx.plugin(NativeProvider, { ...config, requireSystemPointerApproval: false })
|
||||
}
|
||||
const questions = await f.ctx.plugin(UserQuestions)
|
||||
const questionId = 'cua-mouse-control'
|
||||
const approved = { answers: [{ id: questionId, selected: ['Allow for this session'] }] }
|
||||
const ask = vi.fn(async () => approved)
|
||||
f.ctx.on('user-questions/request', async ({ questions }) => questions[0]!.id === questionId ? ask()
|
||||
: { answers: [{ id: 'cua-app-access', selected: ['Allow for this session'] }] })
|
||||
let index = 0
|
||||
const call = (agent: typeof f.handle.agent, method: string, params: Record<string, unknown>) => executeToolFixture(f.ctx, {
|
||||
agent, signal: new AbortController().signal, callId: ToolCallId(`hover-${++index}`), name: 'cua', arguments: { method, params },
|
||||
})
|
||||
const select = async (agent = f.handle.agent) => {
|
||||
native.request.mockResolvedValueOnce({ appHandle: 'app-one', name: 'Fixture', running: true,
|
||||
authorization: { id: 'a'.repeat(64), name: 'Fixture', persistable: false } })
|
||||
expect((await call(agent, 'getApp', { selector: 'Fixture' })).isError).toBe(false)
|
||||
}
|
||||
await select()
|
||||
native.request.mockClear()
|
||||
return { ...f, ask, approved, call, select, questions }
|
||||
}
|
||||
|
||||
it('requires an explicit system-pointer request on every move and asks only once per session', async () => {
|
||||
const f = await hoverFixture()
|
||||
native.request.mockRejectedValueOnce(new Error('HOVER_UNAVAILABLE'))
|
||||
expect(JSON.stringify((await f.execute('mouseMove', hoverTarget)).content)).toContain('HOVER_UNAVAILABLE')
|
||||
expect(f.ask).not.toHaveBeenCalled()
|
||||
const entered: PromiseWithResolvers<void> = Promise.withResolvers()
|
||||
const answer = Promise.withResolvers<typeof f.approved>()
|
||||
f.ask.mockImplementationOnce(() => { entered.resolve(); return answer.promise })
|
||||
onTestFinished(() => { answer.resolve(f.approved) })
|
||||
const pending = f.execute('mouseMove', physicalHover)
|
||||
await entered.promise
|
||||
expect(native.request).toHaveBeenCalledTimes(1)
|
||||
expect(JSON.stringify((await f.execute('mouseMove', physicalHover)).content)).toContain('MOUSE_CONTROL_PENDING')
|
||||
answer.resolve(f.approved)
|
||||
expect((await pending).isError).toBe(false)
|
||||
for (const [params, physical] of [
|
||||
[hoverTarget, false], [{ ...hoverTarget, useSystemPointer: false }, false], [physicalHover, true],
|
||||
] as const) {
|
||||
expect((await f.execute('mouseMove', params)).isError).toBe(false)
|
||||
expect(native.request).toHaveBeenLastCalledWith('mouseMove', { ...hoverTarget, useSystemPointer: physical }, expect.any(AbortSignal), expect.any(String))
|
||||
}
|
||||
expect(native.request.mock.calls[1]?.[1]).toEqual(physicalHover)
|
||||
expect(f.ask).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('keeps refusals and grants separate and forgets a closed session on reopen', async () => {
|
||||
const f = await hoverFixture()
|
||||
expect((await f.execute('mouseMove', physicalHover)).isError).toBe(false)
|
||||
const { agent } = await f.ctx.agents.create({ sessionId: SessionId('other-mouse-session') })
|
||||
expect(JSON.stringify((await f.call(agent, 'mouseMove', physicalHover)).content)).toContain('APP_ACCESS_REQUIRED')
|
||||
await f.select(agent)
|
||||
f.ask.mockResolvedValueOnce({ answers: [{ id: 'cua-mouse-control', selected: ['Deny'] }] })
|
||||
native.request.mockClear()
|
||||
for (let i = 0; i < 2; i++) expect(JSON.stringify((await f.call(agent, 'mouseMove', physicalHover)).content)).toContain('MOUSE_CONTROL_DENIED')
|
||||
expect(native.request).not.toHaveBeenCalled()
|
||||
expect((await f.call(agent, 'mouseMove', hoverTarget)).isError).toBe(false)
|
||||
expect(native.request).toHaveBeenLastCalledWith('mouseMove', { ...hoverTarget, useSystemPointer: false }, expect.any(AbortSignal), expect.any(String))
|
||||
expect((await f.execute('mouseMove', physicalHover)).isError).toBe(false)
|
||||
expect(native.request).toHaveBeenLastCalledWith('mouseMove', physicalHover, expect.any(AbortSignal), expect.any(String))
|
||||
const sessionId = f.handle.agent.id
|
||||
await f.handle.dispose()
|
||||
const reopened = await f.ctx.agents.create({ sessionId })
|
||||
await f.select(reopened.agent)
|
||||
expect((await f.call(reopened.agent, 'mouseMove', hoverTarget)).isError).toBe(false)
|
||||
expect(native.request).toHaveBeenLastCalledWith('mouseMove', { ...hoverTarget, useSystemPointer: false }, expect.any(AbortSignal), expect.any(String))
|
||||
expect((await f.call(reopened.agent, 'mouseMove', physicalHover)).isError).toBe(false)
|
||||
expect(f.ask).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('rechecks the selection after a pending approval and does not dispatch revoked or stale input', async () => {
|
||||
const f = await hoverFixture()
|
||||
f.ask.mockImplementationOnce(async () => {
|
||||
await f.ctx.cuaAccess.revoke('a'.repeat(64) as Parameters<typeof f.ctx.cuaAccess.revoke>[0])
|
||||
return f.approved
|
||||
})
|
||||
expect((await f.execute('mouseMove', physicalHover)).isError).toBe(true)
|
||||
expect(native.request).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('revalidates window coordinates after the user answers', async () => {
|
||||
const f = await hoverFixture()
|
||||
native.request.mockResolvedValueOnce([{ windowId: 7, appName: 'Fixture', title: 'Window', bounds: { x: 0, y: 0, width: 100, height: 100 },
|
||||
isOnScreen: true, onCurrentSpace: true }])
|
||||
await f.execute('listWindows', { appHandle: 'app-one' })
|
||||
f.ask.mockImplementationOnce(async () => {
|
||||
native.request.mockResolvedValueOnce([])
|
||||
await f.execute('listWindows', { appHandle: 'app-one' })
|
||||
return f.approved
|
||||
})
|
||||
const result = await f.execute('mouseMove', { appHandle: 'app-one', windowId: 7, x: 500, y: 500, relative: true, useSystemPointer: true })
|
||||
expect(JSON.stringify(result.content)).toContain('STALE_SNAPSHOT')
|
||||
expect(native.request.mock.calls.map(([name]) => name)).toEqual(['listWindows', 'listWindows'])
|
||||
})
|
||||
|
||||
it('drops dismissal, aborted answers and missing human answerers without allowing input', async () => {
|
||||
const f = await hoverFixture()
|
||||
f.ask.mockRejectedValueOnce(new UserQuestionError('dismissed', 'ASK_CANCELLED'))
|
||||
expect(JSON.stringify((await f.execute('mouseMove', physicalHover)).content)).toContain('MOUSE_APPROVAL_CANCELLED')
|
||||
f.ask.mockRejectedValueOnce(new UserQuestionError('The answer channel disconnected', 'ASK_ABORTED'))
|
||||
expect(JSON.stringify((await f.execute('mouseMove', physicalHover)).content)).toContain('The answer channel disconnected')
|
||||
const controller = new AbortController()
|
||||
f.ask.mockImplementationOnce(async () => { controller.abort(); return f.approved })
|
||||
await Promise.allSettled([f.execute('mouseMove', physicalHover, controller.signal)])
|
||||
expect(native.request).not.toHaveBeenCalled()
|
||||
expect((await f.execute('mouseMove', physicalHover)).isError).toBe(false)
|
||||
expect(f.ask).toHaveBeenCalledTimes(4)
|
||||
await f.provider.dispose()
|
||||
await f.ctx.plugin(NativeProvider, config)
|
||||
await f.select()
|
||||
await f.questions.dispose()
|
||||
expect(JSON.stringify((await f.execute('mouseMove', physicalHover)).content)).toContain('MOUSE_APPROVAL_UNAVAILABLE')
|
||||
})
|
||||
|
||||
it('allows only the explicitly requested physical move when approval is disabled', async () => {
|
||||
const f = await hoverFixture(false)
|
||||
for (const [target, expected] of [[hoverTarget, false], [physicalHover, true], [hoverTarget, false]] as const) {
|
||||
expect((await f.execute('mouseMove', target)).isError).toBe(false)
|
||||
expect(native.request).toHaveBeenLastCalledWith('mouseMove', { ...hoverTarget, useSystemPointer: expected }, expect.any(AbortSignal), expect.any(String))
|
||||
}
|
||||
expect(f.ask).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([true, false])('rejects a delegated pointer move before asking or dispatching with approval=%s', async (approval) => {
|
||||
const f = await hoverFixture(approval)
|
||||
const { agent } = await f.ctx.agents.create({ sessionId: SessionId('delegated-mouse-session'),
|
||||
meta: { origin: 'subagent', parentSession: f.handle.agent.id } })
|
||||
// This case starts with application access already granted to the child.
|
||||
const selectAnswer = vi.spyOn(f.ctx.userQuestions, 'ask')
|
||||
.mockResolvedValueOnce({ answers: [{ id: 'cua-app-access', selected: ['Allow for this session'] }] })
|
||||
await f.select(agent)
|
||||
selectAnswer.mockClear()
|
||||
native.request.mockClear()
|
||||
const refused = await f.call(agent, 'mouseMove', physicalHover)
|
||||
expect(JSON.stringify(refused.content)).toContain('MOUSE_CONTROL_UNAVAILABLE')
|
||||
expect(selectAnswer).not.toHaveBeenCalled()
|
||||
expect(f.ask).not.toHaveBeenCalled()
|
||||
expect(native.request).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([true, false])('uses the declared systemPointer capability for a non-macOS provider (%s)', async (systemPointer) => {
|
||||
const f = await setup()
|
||||
await f.provider.dispose()
|
||||
await f.ctx.plugin({ name: 'fixture-window-provider', inject: NativeProvider.inject, apply(ctx: Context) {
|
||||
installCua(ctx, CuaConfig(config), {
|
||||
helperPath: config.helperPath, createChannel: native.connect, guidance: COMMON_GUIDANCE,
|
||||
permissionParameters: () => ({}),
|
||||
capabilities: { platform: 'fixture', methods: ['getApp', 'getCapabilities', 'mouseMove'], systemPointer,
|
||||
appLevelAccessibility: true, keyboard: ['app'], coordinateSpace: 'screenshot-pixels', browser: false },
|
||||
})
|
||||
} })
|
||||
native.request.mockResolvedValueOnce({ systemPointer: !systemPointer })
|
||||
expect((await f.execute('getCapabilities')).value).toMatchObject({ platform: 'fixture', systemPointer })
|
||||
const sections = (await f.ctx.systemPrompt.assemble({ scope: f.handle.agent })).sections
|
||||
const guidance = sections.find(section => section.name === 'computer-use:dsh-cua')!.text
|
||||
expect(guidance.includes('relative?: boolean, useSystemPointer?: boolean')).toBe(systemPointer)
|
||||
expect(guidance.includes('The question interrupts the user')).toBe(systemPointer)
|
||||
expect(JSON.stringify(f.ctx.tools.get('cua', f.handle.agent)!.parameters).includes('useSystemPointer')).toBe(systemPointer)
|
||||
await selectApplication(f)
|
||||
native.request.mockClear()
|
||||
const answer = vi.spyOn(f.ctx.userQuestions, 'ask').mockResolvedValue({
|
||||
answers: [{ id: 'cua-mouse-control', selected: ['Allow for this session'] }],
|
||||
})
|
||||
const physical = await f.execute('mouseMove', physicalHover)
|
||||
expect(physical.isError).toBe(!systemPointer)
|
||||
expect(answer).toHaveBeenCalledTimes(systemPointer ? 1 : 0)
|
||||
if (systemPointer) expect(native.request).toHaveBeenLastCalledWith('mouseMove', physicalHover, expect.any(AbortSignal), expect.any(String))
|
||||
else expect(native.request).not.toHaveBeenCalled()
|
||||
expect((await f.execute('mouseMove', hoverTarget)).isError).toBe(false)
|
||||
expect(native.request).toHaveBeenLastCalledWith('mouseMove', systemPointer ? { ...hoverTarget, useSystemPointer: false } : hoverTarget,
|
||||
expect.any(AbortSignal), expect.any(String))
|
||||
})
|
||||
@@ -133,8 +133,8 @@ async function fixture(options: {
|
||||
timeoutMs: 30_000, graceMs: options.graceMs ?? 1000, axTimeoutMs: 3000,
|
||||
maxRequestBytes: 65536, maxResponseBytes: 1048576, textEventIntervalMs: 0.5, maxClickHoldMs: 7000,
|
||||
}
|
||||
function open(systemPointerHover = false): NativeChannel {
|
||||
const channel = createSharedNativeChannel({ subprocess: { spawn } }, config, systemPointerHover)
|
||||
function open(): NativeChannel {
|
||||
const channel = createSharedNativeChannel({ subprocess: { spawn } }, config)
|
||||
channels.push(channel)
|
||||
return channel
|
||||
}
|
||||
@@ -172,7 +172,7 @@ describe.skipIf(process.platform === 'win32')('shared native Unix connection', (
|
||||
it('keeps concurrent Agent scopes independent and leaves other sockets alive when one closes', async () => {
|
||||
const f = await fixture()
|
||||
const first = f.open()
|
||||
const second = f.open(true)
|
||||
const second = f.open()
|
||||
const firstReply = nextBytes(first)
|
||||
const secondReply = nextBytes(second)
|
||||
await Promise.all([write(first, 'one\n'), write(second, 'two\n')])
|
||||
@@ -191,9 +191,7 @@ describe.skipIf(process.platform === 'win32')('shared native Unix connection', (
|
||||
maxRequestBytes: 65536, maxResponseBytes: 1048576, textEventIntervalMs: 0.5, maxClickHoldMs: 7000,
|
||||
}
|
||||
expect(f.hellos).toHaveLength(2)
|
||||
for (const systemPointerHover of [true, false]) {
|
||||
expect(f.hellos).toContainEqual(expect.objectContaining({ type: 'hello', protocol: 1, config: { ...limits, systemPointerHover } }))
|
||||
}
|
||||
for (const hello of f.hellos) expect(hello).toMatchObject({ type: 'hello', protocol: 1, config: limits })
|
||||
})
|
||||
|
||||
it('waits for both launcher outcome and managed range release before sending any bytes', async () => {
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
en: d92d5948a7941e8d
|
||||
zh: d43491ae6521b87e
|
||||
/deepseek-ai-dsh-cua/understand-the-implementation:
|
||||
en: 2ab74a1ff120459e
|
||||
zh: 39b3ef198989f133
|
||||
en: f7d47911abb5e2d8
|
||||
zh: 227645c9df979455
|
||||
/deepseek-ai-dsh-cua/further-exploration:
|
||||
en: c174fb5a00ea3681
|
||||
zh: 039340042676881d
|
||||
|
||||
@@ -68,6 +68,8 @@ The Host marker has no configurable fields; platform providers own native config
|
||||
<a id="understand-the-implementation"></a>
|
||||
## Understand the implementation
|
||||
|
||||
Providers declare `systemPointer: true` to support explicit physical pointer movement. CUA exposes the option in the tool schema and model instructions only for those providers. CUA owns the approval card, live-session decision and delegated-task restriction. A provider receives the per-call pointer mode after CUA checks application access and any required consent.
|
||||
|
||||
Permission responses identify their platform and report only the state that platform can observe. The shared page renders the controls those observations support. Before platform identification, it shows neutral observation and input rows without system actions. Native providers use the shared build lock and compiler cleanup. Standalone executables are retained by digest. The macOS provider reads its signed application receipt after compilation completes under the same lock.
|
||||
|
||||
<details>
|
||||
|
||||
@@ -68,6 +68,8 @@ Computer Use 详情页的权限卡片、组件列表和来源信息使用 l3 边
|
||||
<a id="understand-the-implementation"></a>
|
||||
## 理解实现
|
||||
|
||||
提供方通过 `systemPointer: true` 声明支持真实鼠标移动。CUA 据此在工具 schema 和模型提示词中加入该选项,负责显示授权卡、记录当前会话的决定并限制子代理。CUA 检查应用访问权限和本次移动所需的授权后,将悬停方式传给提供方。
|
||||
|
||||
权限响应标识所属平台,并且只报告该平台能够观察到的状态。共享页面显示这些观察结果支持的控件。平台尚未确认时显示通用观察和输入项,不提供系统操作。原生提供方共用编译锁和编译进程清理逻辑。独立可执行文件按内容摘要保存。macOS 提供方在锁内等待编译完成,再读取签名应用的构建回执。
|
||||
|
||||
<details>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { Buffer } from 'node:buffer'
|
||||
import type { z } from 'zod'
|
||||
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
|
||||
import { ActionNamesResult, ActionResult, AppResult, AppsResult, CapabilitiesResult, FoundAppsResult, HoverResult, Request, ScreenshotResult, ShowResult, StateResult, WindowsResult } from './protocol.ts'
|
||||
import { ActionNamesResult, ActionResult, AppResult, AppsResult, CapabilitiesResult, FoundAppsResult, HoverResult, MouseMoveTargets, Request, ScreenshotResult, ShowResult, StateResult, WindowsResult } from './protocol.ts'
|
||||
import type { CuaApi, CuaApp, CuaAppHandle, CuaCapabilities, CuaDispatch, CuaObservationOptions, CuaObservationSink, CuaOperation, CuaScreenshot, CuaSurface, CuaWindow } from './types.ts'
|
||||
export type * from './types.ts'
|
||||
|
||||
@@ -24,9 +24,15 @@ function requestIssueMessage(issues: readonly z.core.$ZodIssue[], parent: readon
|
||||
}
|
||||
|
||||
function validatedCall(dispatch: CuaDispatch) {
|
||||
return (method: CuaOperation['method'], params: Record<string, JsonValue>) => {
|
||||
return (method: CuaOperation['method'], params: Record<string, JsonValue>, options?: object) => {
|
||||
const request = Request.safeParse({ method, params })
|
||||
if (!request.success) throw new Error(`${method} ${requestIssueMessage(request.error.issues)}`)
|
||||
if (options) {
|
||||
// The facade owns the target and coordinates. The Host validates additional options against its provider schema.
|
||||
const extra = Object.entries(options).filter(([key, value]) =>
|
||||
value !== undefined && !MouseMoveTargets.some(target => Object.hasOwn(target.shape, key)))
|
||||
Object.assign(request.data.params, Object.fromEntries(extra))
|
||||
}
|
||||
return dispatch(request.data)
|
||||
}
|
||||
}
|
||||
@@ -109,8 +115,8 @@ function createCuaObjects(call: ReturnType<typeof validatedCall>, sink: CuaObser
|
||||
requireAccessibility: () => void,
|
||||
requireKeyboard: () => void,
|
||||
) {
|
||||
const scopedCall = (method: CuaOperation['method'], params: Record<string, JsonValue>) =>
|
||||
call(method, paramsFor(params))
|
||||
const scopedCall = (method: CuaOperation['method'], params: Record<string, JsonValue>, options?: object) =>
|
||||
call(method, paramsFor(params), options)
|
||||
const action = async (method: CuaOperation['method'], params: Record<string, JsonValue>) =>
|
||||
ActionResult.parse(await scopedCall(method, params))
|
||||
const elementParams = (): Record<string, JsonValue> => ({ snapshotId: currentSnapshot(target.appHandle) })
|
||||
@@ -202,10 +208,10 @@ function createCuaObjects(call: ReturnType<typeof validatedCall>, sink: CuaObser
|
||||
async mouseMove(targetPoint, options) {
|
||||
const relative = options?.relative === undefined ? {} : { relative: options.relative }
|
||||
if (typeof targetPoint === 'string') {
|
||||
return HoverResult.parse(await base.call('mouseMove', { ...base.elementParams(), elementId: targetPoint, ...relative }))
|
||||
return HoverResult.parse(await base.call('mouseMove', { ...base.elementParams(), elementId: targetPoint, ...relative }, options))
|
||||
}
|
||||
const [x, y] = targetPoint
|
||||
return HoverResult.parse(await base.call('mouseMove', { ...coordinateParams(appHandle, windowId), x, y, ...relative }))
|
||||
return HoverResult.parse(await base.call('mouseMove', { ...coordinateParams(appHandle, windowId), x, y, ...relative }, options))
|
||||
},
|
||||
async click(targetPoint, options) {
|
||||
const mouseButton = options?.mouseButton ?? 'left'
|
||||
|
||||
@@ -231,16 +231,17 @@ export class ApplicationAccess {
|
||||
* @param handle Selected application.
|
||||
* @param signal Calling tool lifetime.
|
||||
* @param dispatch One native operation, with revocation cancellation.
|
||||
* @param prepare Optional interactive preparation outside the application lock. Dispatch rechecks access after it settles.
|
||||
* @returns The native result, with no implicit retry after consent or cancellation.
|
||||
*/
|
||||
async run<T>(client: NativeClient, handle: CuaAppHandle, signal: AbortSignal,
|
||||
dispatch: (signal: AbortSignal) => Promise<T>): Promise<T> {
|
||||
dispatch: (signal: AbortSignal) => Promise<T>, prepare?: (signal: AbortSignal) => Promise<void>): Promise<T> {
|
||||
const state = this.clients.get(client)
|
||||
const selected = state?.handles.get(handle)
|
||||
if (!state || !selected) throw new NativeError('APP_ACCESS_REQUIRED', 'Select the application with getApp and obtain user consent before reading or controlling it')
|
||||
if (selected.lifetime.signal.aborted) throw new NativeError('APP_ACCESS_REVOKED', 'Application access was revoked; stop using this application')
|
||||
const lifetime = AbortSignal.any([signal, this.lifetime.signal, selected.lifetime.signal])
|
||||
return this.options.store.withApplication(selected.identity.id, lifetime, async () => {
|
||||
const requireAccess = async () => {
|
||||
const permitted = await this.options.store.withState(lifetime, ({ state: permissions }) =>
|
||||
this.permitted(selected, permissions.applications[selected.identity.id] ?? this.empty()))
|
||||
if (!permitted) {
|
||||
@@ -248,6 +249,14 @@ export class ApplicationAccess {
|
||||
throw new NativeError('APP_ACCESS_REVOKED', 'Application access was revoked; stop using this application')
|
||||
}
|
||||
if (client.disposed) throw new NativeError('NATIVE_CLOSED', 'Helper is closed; acquire fresh application handles')
|
||||
}
|
||||
if (prepare) {
|
||||
await requireAccess()
|
||||
await prepare(lifetime)
|
||||
lifetime.throwIfAborted()
|
||||
}
|
||||
return this.options.store.withApplication(selected.identity.id, lifetime, async () => {
|
||||
await requireAccess()
|
||||
return this.withOperation(state.agent, client, selected.identity, lifetime, dispatch)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -5,18 +5,18 @@ import type {} from '@deepseek-ai/dsh-client-ui-user-questions/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-approval/client'
|
||||
|
||||
/**
|
||||
* Select only the provider's fixed application question; other interactions keep their renderer.
|
||||
* Select application access and mouse-control questions; other interactions keep their renderer.
|
||||
* @param pending The session's currently elected interaction.
|
||||
* @returns Its application decision, or null for another interaction.
|
||||
* @returns Its Computer Use decision, or null for another interaction.
|
||||
*/
|
||||
export function selectApplicationApproval(pending: ComposerChainProps['pendingInteraction']) {
|
||||
if (pending?.kind !== 'question' || pending.questions.length !== 1) return null
|
||||
const item = pending.questions[0] as (typeof pending.questions)[number]
|
||||
if (item.id !== 'cua-app-access' || item.intent?.kind !== 'approval') return null
|
||||
if ((item.id !== 'cua-app-access' && item.id !== 'cua-mouse-control') || item.intent?.kind !== 'approval') return null
|
||||
return { pending, item, intent: item.intent }
|
||||
}
|
||||
|
||||
/** Composer bindings for an application decision and caller-owned localized copy. */
|
||||
/** Composer bindings for a Computer Use decision and caller-owned localized copy. */
|
||||
export type ApplicationApprovalProps = PropsRuntime<'conversation.composer'> & PropsRenderFactories & PropsLocale<'cuaPermissions'> & {
|
||||
matched: NonNullable<ReturnType<typeof selectApplicationApproval>>
|
||||
resolveText: (text: { en: string; [locale: string]: string }) => string
|
||||
@@ -32,7 +32,7 @@ export function ApplicationApproval({ matched: { pending, item, intent }, resolv
|
||||
const answer = (label: string) => pending.answer({ answers: [{ id: item.id, selected: [label] }] })
|
||||
return renderFactorySlot('conversation.approval.card', {
|
||||
requestKey: pending.key,
|
||||
warning: { label: t('approvalRisks'), title: t('approvalRiskTitle'), detail: t('approvalRiskNotice') },
|
||||
...(item.id === 'cua-app-access' ? { warning: { label: t('approvalRisks'), title: t('approvalRiskTitle'), detail: t('approvalRiskNotice') } } : {}),
|
||||
title: intent.display === undefined ? item.question : resolveText(intent.display.question),
|
||||
...(item.detail === undefined ? {} : {
|
||||
description: intent.display?.detail === undefined ? item.detail : resolveText(intent.display.detail),
|
||||
|
||||
@@ -3,6 +3,7 @@ import Schema from '@deepseek-ai/schemastery'
|
||||
|
||||
/** Shared configuration fields extended by native platform providers. */
|
||||
export const CuaConfigFields = {
|
||||
requireSystemPointerApproval: Schema.boolean().default(true),
|
||||
agentPreset: Schema.union([Schema.string().min(1), Schema.const(false)]).default('ptc'),
|
||||
allowedApps: Schema.array(Schema.object({
|
||||
id: Schema.string().pattern(/^[a-f0-9]{64}$/).required(),
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
/** Shared model instructions for native application and window operations. */
|
||||
const hoverSignature = 'mouseMove(target: string | [number, number], options?: { relative?: boolean })'
|
||||
|
||||
/** Async method catalog with consent and observation rules, grouped by observation, mouse, and keyboard. */
|
||||
export const COMMON_GUIDANCE = `Use cua to observe and operate native applications. Always use cua.getCapabilities() to confirm whether a specific API is available on the current platform; its reported capabilities take precedence over availability statements in these instructions. All methods are async; observation options default to { emit: true }, and emit: false suppresses sink output only, leaving returned values unchanged. Actions report dispatch evidence, not verified completion. If sessionLocked is true or an operation reports SESSION_LOCKED, stop UI actions and ask the user to unlock the desktop session; null or unknown means verify a usable unlocked desktop session first. cua.getApp requests user consent; if access is denied or revoked, stop using that application until the user explicitly permits another request, and never bypass a refusal or switch tools to avoid it. Never replay uncertain input without a fresh observation.
|
||||
|
||||
@@ -7,7 +9,7 @@ In persistent PTC, use the host-provided bridge initialization once and retain t
|
||||
Signatures show resolved return values after await. ActionResult = { dispatched: boolean, foregroundChanged?: boolean | null }; foregroundChanged is omitted when not sampled and null when the sample is unavailable. JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }; numbers must be finite. App is the application object with readonly app.appHandle: string (opaque) and the methods listed below for app level. Window is the window object with readonly window.windowId: number and the methods listed below for window level. Neither object carries pid.
|
||||
|
||||
Setup and discovery:
|
||||
- cua.getCapabilities(options?: { emit?: boolean }) -> { platform: string, permissions: { accessibility: boolean, screenRecording: boolean }, appLevelAccessibility: boolean, keyboard: ('app' | 'window')[], sessionLocked: boolean | null, methods: string[], coordinateSpace: 'screenshot-pixels', browser: false } — inspect reported permissions, sessionLocked, methods, and keyboard scopes without requesting permissions.
|
||||
- cua.getCapabilities(options?: { emit?: boolean }) -> { platform: string, permissions: { accessibility: boolean, screenRecording: boolean }, appLevelAccessibility: boolean, systemPointer: boolean, keyboard: ('app' | 'window')[], sessionLocked: boolean | null, methods: string[], coordinateSpace: 'screenshot-pixels', browser: false } — inspect reported permissions, sessionLocked, methods, and keyboard scopes without requesting permissions.
|
||||
- cua.listApps(options?: { emit?: boolean }) -> { id: string, displayName: string, isRunning: true, pid: number }[] — enumerate running applications.
|
||||
- cua.findApps(query: string, options?: { emit?: boolean }) -> ({ id: string, displayName: string, path: string } & ({ isRunning: true, pid: number } | { isRunning: false }))[] — search installed applications, including stopped matches; only running matches carry pid.
|
||||
- cua.getState(options?: { emit?: boolean }) -> { apps: { id: string, displayName: string, isRunning: true, pid: number }[], browsers: [] } — summarize running applications; this is not an accessibility observation.
|
||||
@@ -32,7 +34,7 @@ Element ids are opaque strings from the latest successful tree; the object facad
|
||||
## Simulated mouse
|
||||
Use an explicit window for screenshots and pointer input.
|
||||
- getScreenshot(options?: { emit?: boolean }) -> { bytes: Uint8Array, mimeType: 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif', pixelWidth: number, pixelHeight: number, windowId: number } — capture the selected window as one encoded image; bytes is a JavaScript byte array, and mimeType identifies its encoding, so do not assume PNG.
|
||||
- mouseMove(target: string | [number, number], options?: { relative?: boolean }) -> { dispatched: boolean, foregroundChanged?: boolean | null, hoverRoute: 'accessibility' | 'system-pointer', systemPointerMoved: boolean } — request a mouse move to the target without pressing a button; inspect hoverRoute and systemPointerMoved, and expect no automatic pointer restoration.
|
||||
- ${hoverSignature} -> { dispatched: boolean, foregroundChanged?: boolean | null, hoverRoute: 'accessibility' | 'system-pointer', systemPointerMoved: boolean } — request a mouse move to the target without pressing a button; inspect hoverRoute and systemPointerMoved, and expect no automatic pointer restoration.
|
||||
- click(target: string | [number, number], options?: { mouseButton?: 'left' | 'right' | 'middle', clickCount?: 1 | 2, modifiers?: ('command' | 'control' | 'option' | 'shift' | 'fn')[], holdMs?: number, relative?: boolean }) -> ActionResult — click an element id or point, or perform a left-button double-click; holdMs controls the press duration of each press.
|
||||
- scroll(options: { x: number, y: number, deltaLinesX: number, deltaLinesY: number, relative?: boolean }) -> ActionResult — scroll at a point; deltas are integers from -10 to 10 with at least one nonzero, and positive values reveal content above or left.
|
||||
- drag(from: [number, number], to: [number, number], options?: { mouseButton?: 'left' | 'right' | 'middle', modifiers?: ('command' | 'control' | 'option' | 'shift' | 'fn')[], relative?: boolean }) -> ActionResult — drag between two points of one window.
|
||||
@@ -46,3 +48,15 @@ Available at app or window level depending on the platform.
|
||||
- pressKey(key: string, modifiers?: string[]) -> ActionResult — press and release one complete keystroke. For example pressKey('Tab', ['shift']) or pressKey('+'). Key and modifier names are platform-defined strings. Pass key and modifiers separately; do not pack them into a chord string.
|
||||
|
||||
Raw tools use { method, params }: include appHandle for app calls and windowId for window calls, and use snapshotId with elementId for element routes. The emit option is facade-only: emit is local output control, so raw callers must follow the raw operation schema for admitted params. getAccessibilityTree and getWindow read through raw getState; getWindows maps to listWindows; getScreenshot maps to screenshot; cua.getState maps to listApps; every other named operation uses the same method name.`
|
||||
|
||||
/**
|
||||
* Describe the system-pointer option for a provider that declares the capability.
|
||||
* @param guidance Provider instructions including the shared operation catalog.
|
||||
* @returns The capability-specific signature and permission instructions.
|
||||
*/
|
||||
export function withSystemPointerGuidance(guidance: string): string {
|
||||
const signature = 'mouseMove(target: string | [number, number], options?: { relative?: boolean, useSystemPointer?: boolean })'
|
||||
return `${guidance.replace(hoverSignature, signature)}
|
||||
|
||||
Only if the task cannot be completed without moving the user’s pointer, call await win.mouseMove(target, { useSystemPointer: true }). Prefer an advertised accessibility action, a click on a visible control, or browser tools when possible. The question interrupts the user. When approval is disabled an explicit move can act immediately. Pass true on every physical move, even after approval. Omitting the option or passing false uses the default hover route without prompting. Approval or refusal lasts until this session closes or DSH restarts. A refusal sends no input. If the user dismisses the request, ask again only if they request it. Wait for an outstanding request to finish. Subagents cannot request physical movement.`
|
||||
}
|
||||
@@ -13,11 +13,13 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
|
||||
import { z } from 'zod'
|
||||
import { NativeClient, NativeError, type NativeClientConfig, type NativeChannelFactory } from './native-client.ts'
|
||||
import { MouseControl } from './mouse-control.ts'
|
||||
import { withSystemPointerGuidance } from './guidance.ts'
|
||||
import { CuaPermissionStore } from './permission-store.ts'
|
||||
import { ApplicationAuthorizationResult, installCuaAccess } from './access.ts'
|
||||
import type { CuaApplicationGrant, CuaApplicationId, CuaSystemPermission } from './access-types.ts'
|
||||
import type { CuaAppHandle, CuaOperation, CuaStartupCapabilities } from './types.ts'
|
||||
import { ApplicationIcon, AppResult, NativeScreenshotResult, Request, ScreenshotResult, StateResult, WindowsResult } from './protocol.ts'
|
||||
import { ApplicationIcon, AppResult, NativeScreenshotResult, Request, SystemPointerRequest, ScreenshotResult, StateResult, WindowsResult } from './protocol.ts'
|
||||
import { CuaConfigFields } from './config.ts'
|
||||
import { CuaCoordinates } from './coordinates.ts'
|
||||
import { createScope, type Scope } from '@deepseek-ai/dsh-scope'
|
||||
@@ -34,6 +36,8 @@ export type { CuaStartupCapabilities } from './types.ts'
|
||||
|
||||
/** Native executable and per-operation resource bounds. */
|
||||
export interface CuaConfig {
|
||||
/** Ask once per live Session before an explicitly requested system-pointer move. */
|
||||
requireSystemPointerApproval: boolean
|
||||
/** Register tools for Agents using this preset; defaults to ptc. False enables global registration. */
|
||||
agentPreset: string | false
|
||||
/** Legacy grants imported once into profile-scoped storage; later edits are ignored. */
|
||||
@@ -147,7 +151,11 @@ export interface CuaBackend {
|
||||
*/
|
||||
export function installCua(ctx: Context, config: CuaConfig, backend: CuaBackend): void {
|
||||
const { helperPath } = backend
|
||||
const guidance = `${backend.guidance}\n\nMaximum click holdMs: ${config.maxClickHoldMs} ms.`
|
||||
const systemPointer = backend.capabilities.systemPointer === true
|
||||
const mouseControl = systemPointer ? new MouseControl(ctx, config.requireSystemPointerApproval) : undefined
|
||||
const requestSchema = systemPointer ? SystemPointerRequest : Request
|
||||
const instructions = systemPointer ? withSystemPointerGuidance(backend.guidance) : backend.guidance
|
||||
const guidance = `${instructions}\n\nMaximum click holdMs: ${config.maxClickHoldMs} ms.`
|
||||
const native: NativeClientConfig = {
|
||||
helperPath, cwd: process.cwd(), timeoutMs: config.timeoutMs, axTimeoutMs: config.axTimeoutMs,
|
||||
connectTimeoutMs: config.connectTimeoutMs, screenshotTimeoutMs: config.screenshotTimeoutMs,
|
||||
@@ -207,13 +215,14 @@ export function installCua(ctx: Context, config: CuaConfig, backend: CuaBackend)
|
||||
for (const result of [...await permissions, ...results, ...releases]) if (result.status === 'rejected') errors.push(result.reason)
|
||||
if (errors.length > 0) throw new AggregateError(errors, 'dsh-cua helper cleanup failed')
|
||||
}
|
||||
const callNative = async (request: CuaOperation, execution: Readonly<CuaExecution>) => {
|
||||
const callNative = async (request: CuaOperation, execution: Readonly<CuaExecution>, useSystemPointer?: boolean) => {
|
||||
const { method, params: parsedParams } = request
|
||||
const params = execution.coordinates.parameters(request)
|
||||
const agent = execution.agent
|
||||
const signal = execution.signal
|
||||
signal.throwIfAborted()
|
||||
const client = execution.nativeClient
|
||||
if (useSystemPointer !== undefined) params.useSystemPointer = useSystemPointer
|
||||
if (method === 'sendKeyEvent' && backend.sendKeyEvent) return backend.sendKeyEvent(client, params, execution)
|
||||
const nativeParams = method === 'findApps'
|
||||
? { ...params, maxAppMatches: config.maxAppMatches }
|
||||
@@ -277,7 +286,7 @@ export function installCua(ctx: Context, config: CuaConfig, backend: CuaBackend)
|
||||
const capabilities = backend.capabilities
|
||||
return {
|
||||
...runtime,
|
||||
platform: capabilities.platform,
|
||||
platform: capabilities.platform, systemPointer,
|
||||
methods: [...capabilities.methods],
|
||||
appLevelAccessibility: capabilities.appLevelAccessibility,
|
||||
keyboard: [...capabilities.keyboard],
|
||||
@@ -299,10 +308,10 @@ export function installCua(ctx: Context, config: CuaConfig, backend: CuaBackend)
|
||||
const tool: ToolDefinition = {
|
||||
name: 'cua',
|
||||
description: 'Inspect desktop access, select an application and window, read AX state, capture screenshots or dispatch input. Use appHandle from getApp; verify actions by reading fresh state.',
|
||||
parameters: { ...z.record(z.string(), z.json()).parse(z.toJSONSchema(Request)), type: 'object' },
|
||||
parameters: { ...z.record(z.string(), z.json()).parse(z.toJSONSchema(requestSchema)), type: 'object' },
|
||||
output: { schema: {}, render: renderResult },
|
||||
async execute(args, execution) {
|
||||
const request = Request.parse(args)
|
||||
const request = requestSchema.parse(args)
|
||||
const agent = executionAgent(execution)
|
||||
execution.signal.throwIfAborted()
|
||||
lifetime.signal.throwIfAborted()
|
||||
@@ -335,14 +344,18 @@ export function installCua(ctx: Context, config: CuaConfig, backend: CuaBackend)
|
||||
owner.coordinates = new CuaCoordinates()
|
||||
}
|
||||
const nativeClient = client
|
||||
const route = request.method === 'mouseMove'
|
||||
? mouseControl?.resolve(agent, 'useSystemPointer' in request.params && request.params.useSystemPointer === true)
|
||||
: undefined
|
||||
if (route?.prepare) owner.coordinates.parameters(request)
|
||||
const dispatch = async (signal: AbortSignal) => {
|
||||
const scoped = { ...execution, agent, signal, nativeClient, coordinates: owner.coordinates }
|
||||
const result = await callNative(Request.parse(request), scoped)
|
||||
const result = await callNative(request, scoped, route?.useSystemPointer)
|
||||
signal.throwIfAborted()
|
||||
return result
|
||||
}
|
||||
return 'appHandle' in request.params
|
||||
? access.run(nativeClient, request.params.appHandle as CuaAppHandle, signal, dispatch)
|
||||
? access.run(nativeClient, request.params.appHandle as CuaAppHandle, signal, dispatch, route?.prepare)
|
||||
: dispatch(signal)
|
||||
})()
|
||||
pending.set(operation, agent)
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/** Scope and interruption shown before the user grants mouse control. */
|
||||
export const mouseControlPrompt = {
|
||||
question: {
|
||||
en: 'Allow the agent to move your mouse pointer?',
|
||||
zh: '允许智能体移动你的鼠标指针吗?',
|
||||
ja: 'エージェントがマウスポインタを動かすことを許可しますか?',
|
||||
es: '¿Permitir que el agente mueva el puntero del ratón?',
|
||||
fr: 'Autoriser l’agent à déplacer le pointeur de la souris ?',
|
||||
de: 'Darf der Agent den Mauszeiger bewegen?',
|
||||
ru: 'Разрешить агенту перемещать указатель мыши?',
|
||||
pt: 'Permitir que o agente mova o ponteiro do mouse?',
|
||||
},
|
||||
detail: {
|
||||
en: 'Allowing moves your pointer to this target now. Later hovers move it only when the agent explicitly requests pointer movement. Your choice is remembered until you close the session or restart DSH.',
|
||||
zh: '允许后,指针会立即移到目标位置。之后只有智能体明确请求移动鼠标时才会移动指针。会话会记住你的选择,直到关闭会话或重启 DSH。',
|
||||
ja: '許可するとポインタが今すぐ移動します。以後は、エージェントが明示的に移動を要求した場合だけポインタが動きます。この選択はセッションを閉じるか DSH を再起動するまで有効です。',
|
||||
es: 'Si aceptas, el puntero se moverá ahora al destino. Después solo se moverá cuando el agente lo solicite expresamente. Tu decisión se recuerda hasta que cierres la sesión o reinicies DSH.',
|
||||
fr: 'Si vous acceptez, le pointeur se déplace immédiatement sur la cible. Il ne se déplacera ensuite que si l’agent le demande explicitement. Votre choix est mémorisé jusqu’à la fermeture de la session ou au redémarrage de DSH.',
|
||||
de: 'Bei Zustimmung bewegt sich der Zeiger jetzt zum Ziel. Danach wird er nur bewegt, wenn der Agent dies ausdrücklich anfordert. Ihre Entscheidung gilt, bis Sie die Sitzung schließen oder DSH neu starten.',
|
||||
ru: 'При разрешении указатель сразу переместится на цель. При следующих наведениях он будет перемещаться только по явному запросу агента. Выбор сохраняется до закрытия сеанса или перезапуска DSH.',
|
||||
pt: 'Se permitir, o ponteiro irá para o alvo agora. Depois, só se moverá quando o agente solicitar isso explicitamente. Sua escolha vale até encerrar a sessão ou reiniciar o DSH.',
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/** Session approval for explicit physical pointer moves, shared by capable CUA providers. */
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { UserQuestionError } from '@deepseek-ai/dsh-user-questions'
|
||||
import { NativeError } from './native-client.ts'
|
||||
import { applicationAccessDisplay } from './locales.ts'
|
||||
import { mouseControlPrompt } from './mouse-control-locales.ts'
|
||||
|
||||
/** Access is scoped to the live Session and never grants application access. */
|
||||
export class MouseControl {
|
||||
private readonly decisions = new WeakMap<Agent, boolean | 'asking'>()
|
||||
|
||||
constructor(private readonly ctx: Context, private readonly requireApproval: boolean) {}
|
||||
|
||||
/**
|
||||
* Select the per-call route and any approval required before dispatch.
|
||||
* @param agent Agent owning the live Session.
|
||||
* @param requested Whether the caller explicitly requested system-pointer movement.
|
||||
* @returns Native pointer mode and an optional preparation that must succeed before input.
|
||||
*/
|
||||
resolve(agent: Agent, requested: boolean): { useSystemPointer: boolean; prepare?: (signal: AbortSignal) => Promise<void> } {
|
||||
if (!requested) return { useSystemPointer: false }
|
||||
if (agent.session.header.origin === 'subagent') {
|
||||
throw new NativeError('MOUSE_CONTROL_UNAVAILABLE', "Delegated tasks cannot move the user's pointer. Use AX hover, click, or performAction. If the task requires a physical hover, report that step in your final result for the main task.")
|
||||
}
|
||||
if (!this.requireApproval || this.decisions.get(agent) === true) return { useSystemPointer: true }
|
||||
return { useSystemPointer: true, prepare: signal => this.approve(agent, signal) }
|
||||
}
|
||||
|
||||
private async approve(agent: Agent, signal: AbortSignal): Promise<void> {
|
||||
signal.throwIfAborted()
|
||||
const decision = this.decisions.get(agent)
|
||||
if (decision === 'asking') throw new NativeError('MOUSE_CONTROL_PENDING', 'Wait for the outstanding mouse control request')
|
||||
let granted = decision
|
||||
if (granted === undefined) {
|
||||
const questions = this.ctx.get('userQuestions')
|
||||
if (!questions) throw new NativeError('MOUSE_APPROVAL_UNAVAILABLE', 'No human answerer is available for mouse control')
|
||||
const id = 'cua-mouse-control'
|
||||
const display = { ...mouseControlPrompt, approve: applicationAccessDisplay.approve }
|
||||
const approve = display.approve.en
|
||||
const reject = 'Deny'
|
||||
this.decisions.set(agent, 'asking')
|
||||
try {
|
||||
const result = await questions.ask({ agent, signal, questions: [{
|
||||
id, question: display.question.en, detail: display.detail.en, options: [{ label: approve }, { label: reject }],
|
||||
intent: { kind: 'approval', approve, reject, display },
|
||||
}] })
|
||||
signal.throwIfAborted()
|
||||
const [answer] = result.answers
|
||||
granted = result.answers.length === 1 && answer?.id === id && !answer.custom?.trim()
|
||||
&& answer.selected.length === 1 && answer.selected[0] === approve
|
||||
this.decisions.set(agent, granted)
|
||||
} catch (error) {
|
||||
this.decisions.delete(agent)
|
||||
if (error instanceof UserQuestionError && error.code === 'ASK_CANCELLED') {
|
||||
throw new NativeError('MOUSE_APPROVAL_CANCELLED', 'The mouse control request was dismissed. Continue without mouse control unless the user asks to reconsider', { cause: error })
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
if (!granted) throw new NativeError('MOUSE_CONTROL_DENIED', 'The user declined system-pointer movement for this session. No input was sent. Use pointer-free actions.')
|
||||
}
|
||||
}
|
||||
@@ -34,50 +34,62 @@ const imageBase64 = z.string().min(1).refine(value => Buffer.from(value, 'base64
|
||||
message: 'Expected canonical base64 image data',
|
||||
})
|
||||
|
||||
/** Model-submittable operation parameters; Host resource fields and private continuations are separate. */
|
||||
export const Request = z.discriminatedUnion('method', [
|
||||
operation('getCapabilities', {}), operation('listApps', {}),
|
||||
operation('findApps', { query: z.string().min(1, 'Expected a non-empty search string').regex(/\S/, 'Expected a non-empty search string') }),
|
||||
operation('getApp', { selector: z.string().min(1) }),
|
||||
operation('activate', app),
|
||||
operation('show', { ...app, windowId: id.optional() }),
|
||||
operation('listWindows', app),
|
||||
operation('getState', { ...app, windowId: id.optional(),
|
||||
includeMenus: z.boolean().optional().describe('Expand menu contents; omitted or false shows menu-bar entries only.'),
|
||||
details: z.boolean().optional().describe('Include identifiers, global native bounds and complete action names.'),
|
||||
}),
|
||||
operation('listActions', snapshot),
|
||||
operation('performAction', { ...snapshot, action: z.string().min(1), argument: jsonValue.optional() }),
|
||||
operation('invoke', snapshot),
|
||||
z.object({ method: z.literal('mouseMove'), params: z.union([
|
||||
z.object(snapshot).strict(), z.object(absoluteCoordinate).strict(), z.object(relativeCoordinate).strict(),
|
||||
]) }).strict(),
|
||||
z.object({ method: z.literal('click'), params: z.union([
|
||||
z.object({ ...snapshot, ...click }).strict(),
|
||||
z.object({ ...absoluteCoordinate, ...click }).strict(),
|
||||
z.object({ ...relativeCoordinate, ...click }).strict(),
|
||||
]) }).strict(),
|
||||
operation('setValue', { ...snapshot, value: jsonValue }),
|
||||
operation('selectText', { ...snapshot, text: z.string().min(1), prefix: z.string().optional(), suffix: z.string().optional(),
|
||||
selectionType: z.enum(['text', 'cursor_before', 'cursor_after']).optional() }),
|
||||
operation('typeText', { ...app, windowId: id.optional(), text: z.string() }),
|
||||
operation('paste', { ...app, windowId: id.optional(), text: z.string(), format: z.enum(['text', 'html']).optional() }),
|
||||
operation('pressKey', { ...app, windowId: id.optional(), key: z.string().min(1), modifiers: z.array(z.string()).optional() }),
|
||||
operation('sendKeyEvent', { ...app, windowId: id.optional(), key: z.string().min(1), direction: z.enum(['down', 'up']) }),
|
||||
z.object({ method: z.literal('scroll'), params: z.union([
|
||||
z.object({ ...absoluteCoordinate,
|
||||
deltaLinesX: z.number().int().min(-10).max(10), deltaLinesY: z.number().int().min(-10).max(10) }).strict(),
|
||||
z.object({ ...relativeCoordinate,
|
||||
deltaLinesX: z.number().int().min(-10).max(10), deltaLinesY: z.number().int().min(-10).max(10) }).strict(),
|
||||
]) }).strict(),
|
||||
z.object({ method: z.literal('drag'), params: z.union([
|
||||
z.object({ ...window, ...pointer, snapshotId: z.string().min(1).optional(), relative: z.literal(false).optional(),
|
||||
fromX: point, fromY: point, toX: point, toY: point }).strict(),
|
||||
z.object({ ...window, ...pointer, snapshotId: z.string().min(1).optional(), relative: relativeCoordinate.relative,
|
||||
fromX: relativePoint, fromY: relativePoint, toX: relativePoint, toY: relativePoint }).strict(),
|
||||
]) }).strict(),
|
||||
operation('screenshot', window),
|
||||
])
|
||||
/** Shared hover target variants. Providers may add options without replacing a target or its coordinate mode. */
|
||||
export const MouseMoveTargets = [
|
||||
z.object(snapshot).strict(), z.object(absoluteCoordinate).strict(), z.object(relativeCoordinate).strict(),
|
||||
] as const
|
||||
|
||||
function requestSchema<T extends z.ZodType>(hover: T) {
|
||||
return z.discriminatedUnion('method', [
|
||||
operation('getCapabilities', {}), operation('listApps', {}),
|
||||
operation('findApps', { query: z.string().min(1, 'Expected a non-empty search string').regex(/\S/, 'Expected a non-empty search string') }),
|
||||
operation('getApp', { selector: z.string().min(1) }),
|
||||
operation('activate', app),
|
||||
operation('show', { ...app, windowId: id.optional() }),
|
||||
operation('listWindows', app),
|
||||
operation('getState', { ...app, windowId: id.optional(),
|
||||
includeMenus: z.boolean().optional().describe('Expand menu contents; omitted or false shows menu-bar entries only.'),
|
||||
details: z.boolean().optional().describe('Include identifiers, global native bounds and complete action names.'),
|
||||
}),
|
||||
operation('listActions', snapshot),
|
||||
operation('performAction', { ...snapshot, action: z.string().min(1), argument: jsonValue.optional() }),
|
||||
operation('invoke', snapshot),
|
||||
z.object({ method: z.literal('mouseMove'), params: hover }).strict(),
|
||||
z.object({ method: z.literal('click'), params: z.union([
|
||||
z.object({ ...snapshot, ...click }).strict(),
|
||||
z.object({ ...absoluteCoordinate, ...click }).strict(),
|
||||
z.object({ ...relativeCoordinate, ...click }).strict(),
|
||||
]) }).strict(),
|
||||
operation('setValue', { ...snapshot, value: jsonValue }),
|
||||
operation('selectText', { ...snapshot, text: z.string().min(1), prefix: z.string().optional(), suffix: z.string().optional(),
|
||||
selectionType: z.enum(['text', 'cursor_before', 'cursor_after']).optional() }),
|
||||
operation('typeText', { ...app, windowId: id.optional(), text: z.string() }),
|
||||
operation('paste', { ...app, windowId: id.optional(), text: z.string(), format: z.enum(['text', 'html']).optional() }),
|
||||
operation('pressKey', { ...app, windowId: id.optional(), key: z.string().min(1), modifiers: z.array(z.string()).optional() }),
|
||||
operation('sendKeyEvent', { ...app, windowId: id.optional(), key: z.string().min(1), direction: z.enum(['down', 'up']) }),
|
||||
z.object({ method: z.literal('scroll'), params: z.union([
|
||||
z.object({ ...absoluteCoordinate,
|
||||
deltaLinesX: z.number().int().min(-10).max(10), deltaLinesY: z.number().int().min(-10).max(10) }).strict(),
|
||||
z.object({ ...relativeCoordinate,
|
||||
deltaLinesX: z.number().int().min(-10).max(10), deltaLinesY: z.number().int().min(-10).max(10) }).strict(),
|
||||
]) }).strict(),
|
||||
z.object({ method: z.literal('drag'), params: z.union([
|
||||
z.object({ ...window, ...pointer, snapshotId: z.string().min(1).optional(), relative: z.literal(false).optional(),
|
||||
fromX: point, fromY: point, toX: point, toY: point }).strict(),
|
||||
z.object({ ...window, ...pointer, snapshotId: z.string().min(1).optional(), relative: relativeCoordinate.relative,
|
||||
fromX: relativePoint, fromY: relativePoint, toX: relativePoint, toY: relativePoint }).strict(),
|
||||
]) }).strict(),
|
||||
operation('screenshot', window),
|
||||
])
|
||||
}
|
||||
|
||||
/** Portable model operations, including standard hover without a system-pointer option. */
|
||||
export const Request = requestSchema(z.union(MouseMoveTargets))
|
||||
|
||||
/** Model operations for providers that can move the user's system pointer. */
|
||||
export const SystemPointerRequest = requestSchema(z.union(MouseMoveTargets.map(target => target.extend({
|
||||
useSystemPointer: z.boolean().optional().describe('Request use of the system pointer. The Host checks permission before dispatch.'),
|
||||
}))))
|
||||
|
||||
/** Resource parameters supplied by the Host, never accepted from model tool arguments. */
|
||||
export const NativeResourceParameters = {
|
||||
@@ -136,6 +148,8 @@ export const CapabilitiesResult = z.object({
|
||||
platform: z.string(), permissions: z.object({ accessibility: z.boolean(), screenRecording: z.boolean() }),
|
||||
/** Whether the provider supports application-level accessibility observations. */
|
||||
appLevelAccessibility: z.boolean(),
|
||||
/** Whether mouseMove accepts an explicit request to move the system pointer. */
|
||||
systemPointer: z.boolean().default(false),
|
||||
/** Scopes that support keyboard input. */
|
||||
keyboard: z.array(z.enum(['app', 'window'])),
|
||||
sessionLocked: z.boolean().nullable(),
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
|
||||
import type { z } from 'zod'
|
||||
import type { ActionResult, CapabilitiesResult, FoundAppsResult, HoverResult, Request, ScreenshotResult, ShowResult, WindowsResult } from './protocol.ts'
|
||||
import type { ActionResult, CapabilitiesResult, FoundAppsResult, HoverResult, SystemPointerRequest, ScreenshotResult, ShowResult, WindowsResult } from './protocol.ts'
|
||||
|
||||
/** Helper-issued application selection; public app objects hold only this opaque handle. */
|
||||
export type CuaAppHandle = Branded<'CuaAppHandle'>
|
||||
@@ -11,7 +11,7 @@ export type CuaSnapshotId = Branded<'CuaSnapshotId'>
|
||||
/** Opaque string defined by the platform provider, valid for at least the lifetime of one snapshot. */
|
||||
export type CuaElementId = Branded<'CuaElementId'>
|
||||
/** One operation sent through an authorized and logged host dispatcher. */
|
||||
export type CuaOperation = z.output<typeof Request>
|
||||
export type CuaOperation = z.output<typeof SystemPointerRequest>
|
||||
type CuaParameters<M extends CuaOperation['method']> = Extract<CuaOperation, { method: M }>['params']
|
||||
/** Dispatch exactly once; a failed action must not be retried implicitly. */
|
||||
export type CuaDispatch = (operation: CuaOperation) => Promise<JsonValue>
|
||||
@@ -49,7 +49,7 @@ export type CuaCapabilities = z.output<typeof CapabilitiesResult>
|
||||
export type CuaStartupCapabilities = Pick<
|
||||
CuaCapabilities,
|
||||
'platform' | 'methods' | 'appLevelAccessibility' | 'keyboard' | 'browser' | 'coordinateSpace'
|
||||
>
|
||||
> & { systemPointer?: boolean }
|
||||
/** App discovery result accepted by getApp. */
|
||||
export type CuaAppInfo = Pick<Extract<CuaFoundAppInfo, { isRunning: true }>, 'id' | 'displayName' | 'isRunning' | 'pid'>
|
||||
/** An installed application match; running matches include a process identifier. */
|
||||
@@ -196,10 +196,12 @@ export interface CuaWindow extends CuaSurface {
|
||||
* Returning and subsequent observations do not restore the previous pointer position.
|
||||
* Other input and application changes can interrupt the interaction; observe before continuing.
|
||||
* @param target - current element id or screenshot-local [x,y].
|
||||
* @param options - coordinate mode; applies only to [x,y].
|
||||
* @param options - Coordinate mode for points. Only providers reporting systemPointer accept useSystemPointer.
|
||||
* True requests physical movement for this call only.
|
||||
* @returns dispatch evidence and the selected route; observe the resulting UI after any UI delay.
|
||||
*/
|
||||
mouseMove(target: CuaElementId | readonly [number, number], options?: CuaCoordinateOptions): Promise<CuaHoverResult>
|
||||
mouseMove(target: CuaElementId | readonly [number, number],
|
||||
options?: CuaCoordinateOptions & { useSystemPointer?: boolean }): Promise<CuaHoverResult>
|
||||
/**
|
||||
* Click a current element id or screenshot-local point.
|
||||
* Cancellation or timeout releases the held button; received input cannot be undone. The operation deadline includes holdMs.
|
||||
|
||||
@@ -4,7 +4,7 @@ import { describe, expect, expectTypeOf, it, vi } from 'vitest'
|
||||
import { brandString } from '@deepseek-ai/dsh-brand'
|
||||
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
|
||||
import { createCuaApi } from '../src/api.ts'
|
||||
import type { CuaApp, CuaCapabilities, CuaElementId, CuaOperation, CuaScreenshot, CuaWindow } from '../src/types.ts'
|
||||
import type { CuaApp, CuaCapabilities, CuaCoordinateOptions, CuaElementId, CuaOperation, CuaScreenshot, CuaWindow } from '../src/types.ts'
|
||||
|
||||
/** Element ids are opaque strings; these fixtures use the macOS decimal form. */
|
||||
const element = (id: string): CuaElementId => brandString<CuaElementId>(id)
|
||||
@@ -13,7 +13,7 @@ const PNG = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAACXBIWXMAAAPoAAAD6AG
|
||||
const bounds = { x: -640, y: 100, width: 640, height: 480 }
|
||||
const selected = { appHandle: 'app-17', pid: 17, name: 'Editor', bundleId: 'test.editor', running: true }
|
||||
const delivered = { dispatched: true }
|
||||
const capabilities: CuaCapabilities = { platform: 'macos', sessionLocked: false, permissions: { accessibility: true, screenRecording: false }, appLevelAccessibility: true, keyboard: ['app'], methods: ['getState', 'click'], coordinateSpace: 'screenshot-pixels', browser: false }
|
||||
const capabilities: CuaCapabilities = { platform: 'macos', systemPointer: true, sessionLocked: false, permissions: { accessibility: true, screenRecording: false }, appLevelAccessibility: true, keyboard: ['app'], methods: ['getState', 'click'], coordinateSpace: 'screenshot-pixels', browser: false }
|
||||
|
||||
function windowInfo(windowId: number) {
|
||||
return { windowId, appName: 'Editor', title: `Document ${windowId}`, bounds, isOnScreen: true, onCurrentSpace: null }
|
||||
@@ -890,3 +890,25 @@ it('expands menu contents and details only for the requested observation', async
|
||||
await app.getAccessibilityTree({ includeMenus: false, details: false })
|
||||
expect(f.dispatch).toHaveBeenLastCalledWith({ method: 'getState', params: { appHandle: selected.appHandle, includeMenus: false, details: false } })
|
||||
})
|
||||
|
||||
it('forwards provider options while retaining the selected target and coordinate mode', async () => {
|
||||
const f = fixture()
|
||||
const win = await (await f.api.getApp('Editor')).getWindow(23)
|
||||
const defaultCoordinates: CuaCoordinateOptions = {}
|
||||
const elementOptions = { ...defaultCoordinates, trackingMode: 'precise', appHandle: 'another-app', windowId: 99, unused: undefined }
|
||||
await win.mouseMove(element('0'), elementOptions)
|
||||
expect(f.dispatch).toHaveBeenLastCalledWith({ method: 'mouseMove', params: {
|
||||
appHandle: 'app-17', windowId: 23, snapshotId: 'snapshot-2', elementId: '0', trackingMode: 'precise',
|
||||
} })
|
||||
await win.getScreenshot({ emit: false })
|
||||
const pixelOptions = { relative: false, useSystemPointer: true, trackingMode: 'precise', x: 222 }
|
||||
await win.mouseMove([30, 40], pixelOptions)
|
||||
expect(f.dispatch).toHaveBeenLastCalledWith({ method: 'mouseMove', params: {
|
||||
appHandle: 'app-17', snapshotId: 'snapshot-2', windowId: 23, x: 30, y: 40, relative: false, useSystemPointer: true, trackingMode: 'precise',
|
||||
} })
|
||||
const relativeOptions = { relative: true, trackingMode: 'coarse' }
|
||||
await win.mouseMove([250, 750], relativeOptions)
|
||||
expect(f.dispatch).toHaveBeenLastCalledWith({ method: 'mouseMove', params: {
|
||||
appHandle: 'app-17', snapshotId: 'snapshot-2', windowId: 23, x: 250, y: 750, relative: true, trackingMode: 'coarse',
|
||||
} })
|
||||
})
|
||||
@@ -84,3 +84,20 @@ it('uses the original question cancellation and refuses late approval', async ()
|
||||
expect(card.canAnswer()).toBe(false)
|
||||
await expect(card.onAllow()).rejects.toThrow('no channel accepts an answer yet')
|
||||
})
|
||||
|
||||
it('shows the mouse interruption and expiry with only session approval and refusal', () => {
|
||||
const display = {
|
||||
question: { en: 'Move pointer?', zh: '允许移动鼠标指针吗?' },
|
||||
detail: { en: 'Until restart', zh: '重启后失效' },
|
||||
approve: { en: 'Allow for this session', zh: '本会话允许' },
|
||||
}
|
||||
const pending = new PendingQuestion('s1' as SessionId, [{ id: 'cua-mouse-control', question: display.question.en,
|
||||
detail: display.detail.en, intent: { kind: 'approval', approve: display.approve.en, reject: 'Deny', display } }])
|
||||
const factory = vi.fn((_name: string, _input: ApprovalCardInput) => null)
|
||||
render(<ApplicationApproval {...{ matched: selectApplicationApproval(pending)!, renderFactorySlot: factory,
|
||||
t: makeTranslate(zh), resolveText: text => text.zh } as ApplicationApprovalProps} />)
|
||||
const card = factory.mock.calls[0]![1]
|
||||
expect(card).toMatchObject({ title: display.question.zh, description: display.detail.zh, allowLabel: display.approve.zh })
|
||||
expect(card.onAlways).toBeUndefined()
|
||||
expect(card.warning).toBeUndefined()
|
||||
})
|
||||
@@ -12,7 +12,7 @@ const element = (id: string): CuaElementId => brandString<CuaElementId>(id)
|
||||
|
||||
const PNG = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAADElEQVQImWNgZGIGAAAOAAeCcsnOAAAAAElFTkSuQmCC'
|
||||
const capabilities: CuaCapabilities = {
|
||||
platform: 'macos', permissions: { accessibility: true, screenRecording: true }, appLevelAccessibility: true,
|
||||
platform: 'macos', systemPointer: true, permissions: { accessibility: true, screenRecording: true }, appLevelAccessibility: true,
|
||||
keyboard: ['app'], sessionLocked: false, methods: ['getCapabilities', 'getApp', 'getState', 'screenshot'],
|
||||
coordinateSpace: 'screenshot-pixels', browser: false,
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** Model-authored operation JSON cannot supply host configuration or bypass retained identities. */
|
||||
import { expect, it } from 'vitest'
|
||||
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
|
||||
import { CapabilitiesResult, Request, ScreenshotResult } from '../src/protocol.ts'
|
||||
import { CapabilitiesResult, Request, ScreenshotResult, SystemPointerRequest } from '../src/protocol.ts'
|
||||
|
||||
const rejectedRequests: Array<{ reason: string; request: JsonValue }> = [
|
||||
{ reason: 'raw process ID instead of a selected app handle', request: { method: 'typeText', params: { pid: 17, text: 'hello' } } },
|
||||
@@ -179,7 +179,7 @@ it.each([true, false])('requires the reported application-level accessibility ca
|
||||
platform: 'macos', permissions: { accessibility: true, screenRecording: false }, appLevelAccessibility,
|
||||
keyboard: ['app'], sessionLocked: false, methods: [], coordinateSpace: 'screenshot-pixels', browser: false,
|
||||
}
|
||||
expect(CapabilitiesResult.parse(capabilities)).toEqual(capabilities)
|
||||
expect(CapabilitiesResult.parse(capabilities)).toEqual({ ...capabilities, systemPointer: false })
|
||||
const { appLevelAccessibility: _omitted, ...missing } = capabilities
|
||||
expect(CapabilitiesResult.safeParse(missing).success).toBe(false)
|
||||
})
|
||||
@@ -195,7 +195,7 @@ it.each([
|
||||
platform: 'windows', permissions: { accessibility: true, screenRecording: true }, appLevelAccessibility: false,
|
||||
keyboard, sessionLocked: false, methods: [], coordinateSpace: 'screenshot-pixels', browser: false,
|
||||
}
|
||||
expect(CapabilitiesResult.parse(capabilities)).toEqual(capabilities)
|
||||
expect(CapabilitiesResult.parse(capabilities)).toEqual({ ...capabilities, systemPointer: false })
|
||||
})
|
||||
|
||||
it.each([
|
||||
@@ -217,3 +217,12 @@ it('requires keyboard scopes', () => {
|
||||
}
|
||||
expect(CapabilitiesResult.safeParse(capabilities).success).toBe(false)
|
||||
})
|
||||
|
||||
it('admits the pointer option only in the system-pointer model schema and only as a boolean', () => {
|
||||
const target = { appHandle: 'app', windowId: 1, x: 0, y: 0 }
|
||||
const request = { method: 'mouseMove', params: { ...target, useSystemPointer: true } }
|
||||
expect(Request.safeParse(request).success).toBe(false)
|
||||
expect(SystemPointerRequest.safeParse(request).success).toBe(true)
|
||||
expect(SystemPointerRequest.safeParse({ ...request, params: { ...target, useSystemPointer: 'true' } }).success).toBe(false)
|
||||
expect(SystemPointerRequest.safeParse({ ...request, params: { ...target, arbitraryField: true } }).success).toBe(false)
|
||||
})
|
||||
@@ -15,8 +15,8 @@
|
||||
en: d152484eb41ac6b4
|
||||
zh: 2f8a621b991260a1
|
||||
/deepseek-ai-dsh-experimental-forge-github/use-this-package:
|
||||
en: 0f8041e0380036c9
|
||||
zh: 63cbdc82c9f438d4
|
||||
en: 937728d83a92e0f0
|
||||
zh: ea0805e75f2f4da0
|
||||
/deepseek-ai-dsh-experimental-forge-github/understand-the-implementation:
|
||||
en: f98e77171cba809f
|
||||
zh: 1b330cb342d69efe
|
||||
@@ -30,8 +30,8 @@
|
||||
en: 26b946af4b742e17
|
||||
zh: 00b34f0721ac72f6
|
||||
/deepseek-ai-dsh-experimental-forge-github/known-limitations-and-deferred-work:
|
||||
en: 9f13ae6d928e4ef7
|
||||
zh: 09a549b1655f4de3
|
||||
en: 8290820d0ea4122e
|
||||
zh: f400491b8cb02453
|
||||
/deepseek-ai-dsh-experimental-forge-github/known-limitations-and-deferred-work/dev-note:
|
||||
en: e67f633dc6fe7773
|
||||
zh: 3bb0c20c93f16f98
|
||||
@@ -46,7 +46,7 @@ Unfinished check runs and completed runs with stale, missing, or unrecognized co
|
||||
|
||||
A pull request is `mergeReady` when it is open, not a draft, GitHub reports its merge state as `CLEAN`, `HAS_HOOKS`, or `UNSTABLE`, and the signed-in user has write, maintain, or admin permission. `mergeMethods` lists the squash, merge-commit, and rebase methods the repository allows, the user's default merge method first. Marking ready runs `gh pr ready <number> --repo <host>/<owner>/<name>`. Merging calls `PUT repos/<owner>/<name>/pulls/<number>/merge` through `gh api` with the method and the expected head SHA, so GitHub refuses once new commits arrived, which is reported as `stale`. Unlike `gh pr merge`, it never enables auto-merge, never joins a merge queue, and never deletes the head branch. A refused action reports GitHub's message, and a diagnostic that mentions authentication becomes `unauthenticated`.
|
||||
|
||||
`diff` reads `headRefOid` and `baseRefOid` in one GraphQL query and returns `stale` when the head is not the requested commit, which must be a full 40- or 64-digit hexadecimal commit. Otherwise it reads `repos/<owner>/<name>/compare/<baseRefOid>...<headSha>` through `gh api --allow-escape-sequences` with the diff media type, the patch from the merge base of both commits, so neither end follows a moving branch. For every open and merged pull request measured, this patch is byte-identical to `gh pr diff`, which takes no commit argument. Unlike the pull request diff, the compare endpoint returns diffs over 300 files, so only output over `maxOutputBytes` or GitHub's own refusal makes such a diff `too-large`. `--allow-escape-sequences` keeps terminal escape sequences that file contents hold, which `gh` 2.97 and newer otherwise refuse to print; an older `gh` rejects the option with `unknown flag` before sending a request and prints such sequences unchanged, so that rejection repeats the read without it. `fileText` reads `repos/<owner>/<name>/contents/<path>?ref=<commit>` through `gh api` with the raw media type and accepts only a full 40- or 64-digit hexadecimal commit. `fileUrl` returns `https://<host>/<owner>/<name>/blob/<commit>/<path>`. Both paths percent-encode each segment. `commits` reads a pull request's commits through GraphQL, 100 per page with the owner, name, number, and cursor as variables, until `maxCommits`; every page must report the head and commit count of the first one and a cursor no earlier page returned, else the listing is `unavailable`. `compare` reads the patch from the merge base of two full commits to the second through the same compare request as `diff`. A refused read becomes `not-found` for HTTP 404, `unauthenticated` for a diagnostic that mentions authentication, `too-large` for GitHub's HTTP 406 refusal or `too_large` error, which a diff or file over its size limit draws, and `unavailable` otherwise, with GitHub's message. A read whose output exceeds `maxOutputBytes` becomes `too-large`, and one that outlives `timeoutMs` becomes `unavailable`.
|
||||
`diff` reads `headRefOid` and `baseRefOid` in one GraphQL query and returns `stale` when the head is not the requested commit, which must be a full 40- or 64-digit hexadecimal commit. Otherwise it reads `repos/<owner>/<name>/compare/<baseRefOid>...<headSha>` through `gh api --allow-escape-sequences` with the diff media type, the patch from the merge base of both commits, so neither end follows a moving branch. Each status snapshot carries `baseRefOid` as `baseSha`, and leaves it out when GitHub reports no full commit there; given it, `diff` starts this comparison while the GraphQL query confirms the head, and a stale head or another base commit cancels the early comparison and waits for it to end before `diff` returns. For every open and merged pull request measured, this patch is byte-identical to `gh pr diff`, which takes no commit argument. Unlike the pull request diff, the compare endpoint returns diffs over 300 files, so only output over `maxOutputBytes` or GitHub's own refusal makes such a diff `too-large`. `--allow-escape-sequences` keeps terminal escape sequences that file contents hold, which `gh` 2.97 and newer otherwise refuse to print; an older `gh` rejects the option with `unknown flag` before sending a request and prints such sequences unchanged, so that rejection repeats the read without it. `fileText` reads `repos/<owner>/<name>/contents/<path>?ref=<commit>` through `gh api` with the raw media type and accepts only a full 40- or 64-digit hexadecimal commit. `fileUrl` returns `https://<host>/<owner>/<name>/blob/<commit>/<path>`. Both paths percent-encode each segment. `commits` reads a pull request's commits through GraphQL, 100 per page with the owner, name, number, and cursor as variables, until `maxCommits`; every page must report the head and commit count of the first one and a cursor no earlier page returned, else the listing is `unavailable`. `compare` reads the patch from the merge base of two full commits to the second through the same compare request as `diff`. A refused read becomes `not-found` for HTTP 404, `unauthenticated` for a diagnostic that mentions authentication, `too-large` for GitHub's HTTP 406 refusal or `too_large` error, which a diff or file over its size limit draws, and `unavailable` otherwise, with GitHub's message. A read whose output exceeds `maxOutputBytes` becomes `too-large`, and one that outlives `timeoutMs` becomes `unavailable`.
|
||||
|
||||
Review submission checks the current pull request head, then posts a single inline comment or one completed review through the REST API with an explicit `commit_id`. Multiline comments carry their side and inclusive start and end lines. Review decisions are Comment, Approve, and Request changes. JSON request bodies travel on stdin. The provider never retries a mutation: a timeout, unexpected response, or server error after dispatch returns an unknown outcome that the caller must reconcile on GitHub. A commit arriving between the check and the write can make the explicitly addressed review outdated; GitHub does not offer an atomic head precondition on these endpoints.
|
||||
|
||||
@@ -100,7 +100,7 @@ None. Provider output reaches only host-side watchers.
|
||||
- **Batch deadline** — continuation pages of a pull request with many review threads lengthen its host's polling batch; a batch that outlives `ctx.forge`'s `fetchTimeoutMs` reports `unavailable` for every pull request in it, not only for the one with many threads.
|
||||
- **Rewritten base branch** — the diff starts from the merge base of the head and the base commit GitHub recorded at the pull request's last update. After the base branch is force-pushed, that merge base can differ from the one GitHub's pull request view uses, so the patch can include or omit commits until the pull request is updated.
|
||||
- **No fork discovery** — head lookups exclude pull requests whose head is in another repository.
|
||||
- **Rate limits** — every poll costs one GraphQL query per host, plus one query per further page of 100 review threads, and a complete check read one more query per further page of 100 check records; every diff read costs one GraphQL query and one REST request. All count against the signed-in account’s rate limit.
|
||||
- **Rate limits** — every poll costs one GraphQL query per host, plus one query per further page of 100 review threads, and a complete check read one more query per further page of 100 check records; every diff read costs one GraphQL query and one REST request, and a second REST request when the base commit changed after the latest status. All count against the signed-in account’s rate limit.
|
||||
- **No merge queue** — a base branch that requires a merge queue refuses a direct merge, and the provider never queues one.
|
||||
|
||||
<a id="dev-note"></a>
|
||||
|
||||
@@ -46,7 +46,7 @@ kind: "package-reference"
|
||||
|
||||
pull request 处于打开状态、不是草稿、GitHub 报告其合并状态为 `CLEAN`、`HAS_HOOKS` 或 `UNSTABLE`,且已登录用户具有 write、maintain 或 admin 权限时,`mergeReady` 为真。`mergeMethods` 列出仓库允许的压缩合并、合并提交与变基合并方式,用户默认的合并方式排在最前。标记为可评审运行 `gh pr ready <number> --repo <host>/<owner>/<name>`。合并经 `gh api` 调用 `PUT repos/<owner>/<name>/pulls/<number>/merge`,带上合并方式与预期的 head SHA,因此有新提交到达后 GitHub 会拒绝,报告为 `stale`。与 `gh pr merge` 不同,它从不启用自动合并、从不加入合并队列,也从不删除 head 分支。被拒绝的操作报告 GitHub 的原因,提到身份验证的诊断转为 `unauthenticated`。
|
||||
|
||||
`diff` 在一次 GraphQL 查询中读取 `headRefOid` 与 `baseRefOid`,head 不是请求的提交时返回 `stale`;请求的提交必须是完整的 40 位或 64 位十六进制提交。否则经 `gh api --allow-escape-sequences` 以 diff 媒体类型读取 `repos/<owner>/<name>/compare/<baseRefOid>...<headSha>`,即两个提交的合并基准到 head 的补丁,两端都不跟随移动的分支。实测的所有 open 与已合并 pull request 上,该补丁与不接受提交参数的 `gh pr diff` 逐字节一致。与 pull request diff 不同,compare 接口会返回超过 300 个文件的 diff,因此这类 diff 只有在输出超过 `maxOutputBytes` 或 GitHub 自身拒绝时才为 `too-large`。`--allow-escape-sequences` 保留文件内容中的终端转义序列;否则 `gh` 2.97 及以上版本拒绝输出。更早的 `gh` 在发出请求前就以 `unknown flag` 拒绝该选项,且原样输出这类序列,因此遇到这一拒绝时去掉该选项重读一次。`fileText` 经 `gh api` 以 raw 媒体类型读取 `repos/<owner>/<name>/contents/<path>?ref=<commit>`,只接受完整的 40 位或 64 位十六进制提交。`fileUrl` 返回 `https://<host>/<owner>/<name>/blob/<commit>/<path>`。两者的路径都逐段做百分号编码。`commits` 经 GraphQL 读取拉取请求的提交,每页 100 个,owner、name、number 与游标作为变量传入,直到 `maxCommits`;每一页报告的 head 与提交总数必须与第一页相同,游标也不能与之前任何一页重复,否则返回 `unavailable`。`compare` 用与 `diff` 相同的 compare 请求读取两个完整提交的合并基准到第二个提交的补丁。被拒绝的读取在 HTTP 404 时为 `not-found`,诊断提到身份验证时为 `unauthenticated`,GitHub 以 HTTP 406 拒绝或报告 `too_large` 错误时(diff 或文件超过其大小上限即会如此)为 `too-large`,其余为 `unavailable`,并带有 GitHub 的原因。输出超过 `maxOutputBytes` 的读取为 `too-large`,超过 `timeoutMs` 的为 `unavailable`。
|
||||
`diff` 在一次 GraphQL 查询中读取 `headRefOid` 与 `baseRefOid`,head 不是请求的提交时返回 `stale`;请求的提交必须是完整的 40 位或 64 位十六进制提交。否则经 `gh api --allow-escape-sequences` 以 diff 媒体类型读取 `repos/<owner>/<name>/compare/<baseRefOid>...<headSha>`,即两个提交的合并基准到 head 的补丁,两端都不跟随移动的分支。每个状态快照把 `baseRefOid` 作为 `baseSha` 携带(GitHub 返回的不是完整提交时省略);拿到它时,`diff` 在 GraphQL 查询确认 head 的同时就开始这次比较,head 已过时或基准提交不同时会取消提前开始的比较并等它结束,然后才返回。实测的所有 open 与已合并 pull request 上,该补丁与不接受提交参数的 `gh pr diff` 逐字节一致。与 pull request diff 不同,compare 接口会返回超过 300 个文件的 diff,因此这类 diff 只有在输出超过 `maxOutputBytes` 或 GitHub 自身拒绝时才为 `too-large`。`--allow-escape-sequences` 保留文件内容中的终端转义序列;否则 `gh` 2.97 及以上版本拒绝输出。更早的 `gh` 在发出请求前就以 `unknown flag` 拒绝该选项,且原样输出这类序列,因此遇到这一拒绝时去掉该选项重读一次。`fileText` 经 `gh api` 以 raw 媒体类型读取 `repos/<owner>/<name>/contents/<path>?ref=<commit>`,只接受完整的 40 位或 64 位十六进制提交。`fileUrl` 返回 `https://<host>/<owner>/<name>/blob/<commit>/<path>`。两者的路径都逐段做百分号编码。`commits` 经 GraphQL 读取拉取请求的提交,每页 100 个,owner、name、number 与游标作为变量传入,直到 `maxCommits`;每一页报告的 head 与提交总数必须与第一页相同,游标也不能与之前任何一页重复,否则返回 `unavailable`。`compare` 用与 `diff` 相同的 compare 请求读取两个完整提交的合并基准到第二个提交的补丁。被拒绝的读取在 HTTP 404 时为 `not-found`,诊断提到身份验证时为 `unauthenticated`,GitHub 以 HTTP 406 拒绝或报告 `too_large` 错误时(diff 或文件超过其大小上限即会如此)为 `too-large`,其余为 `unavailable`,并带有 GitHub 的原因。输出超过 `maxOutputBytes` 的读取为 `too-large`,超过 `timeoutMs` 的为 `unavailable`。
|
||||
|
||||
提交评审时先检查 PR 当前的 head,再通过 REST API 发布单条行内评论或一次完整评审,并显式指定 `commit_id`。多行评论携带所属侧及包含端点的起止行号。评审结论为“评论”“批准”或“请求更改”。JSON 请求正文通过标准输入传递。provider 不会重试写操作;发送后的超时、异常响应或服务端错误会返回结果不明,由调用方到 GitHub 核对。检查与写入之间到来的新提交可能让已明确定位到旧提交的评审过时;GitHub 的这些接口不提供原子的 head 前置条件。
|
||||
|
||||
@@ -100,7 +100,7 @@ pull request 处于打开状态、不是草稿、GitHub 报告其合并状态为
|
||||
- **批次时限**——评审讨论很多的 pull request 的续页会拉长其主机的轮询批次;批次超过 `ctx.forge` 的 `fetchTimeoutMs` 时,批次内每个 pull request 都报告 `unavailable`,而不只是讨论很多的那个。
|
||||
- **基准分支被改写**——diff 从 head 与 GitHub 在 pull request 最后一次更新时记录的基准提交的合并基准开始。基准分支被强制推送后,该合并基准可能不同于 GitHub pull request 页面使用的合并基准,因此在 pull request 更新前,补丁可能多出或缺少提交。
|
||||
- **不发现 fork**——按 head 分支查找时,不包含 head 位于其他仓库的 pull request。
|
||||
- **速率限制**——每次轮询为每个主机消耗一次 GraphQL 查询,评审讨论每多一页(100 个)再消耗一次;完整检查读取时检查记录每多一页(100 条)再消耗一次;每次 diff 读取消耗一次 GraphQL 查询与一次 REST 请求。这些都计入已登录账号的速率配额。
|
||||
- **速率限制**——每次轮询为每个主机消耗一次 GraphQL 查询,评审讨论每多一页(100 个)再消耗一次;完整检查读取时检查记录每多一页(100 条)再消耗一次;每次 diff 读取消耗一次 GraphQL 查询与一次 REST 请求;最新状态之后基准提交有变化时再多一次 REST 请求。这些都计入已登录账号的速率配额。
|
||||
- **不支持合并队列**——要求合并队列的基准分支会拒绝直接合并,提供方也不会把变更请求加入队列。
|
||||
|
||||
<a id="dev-note"></a>
|
||||
|
||||
@@ -32,7 +32,7 @@ const THREAD_FIELDS = 'pageInfo { hasNextPage endCursor } nodes { isResolved }'
|
||||
function pullRequestFields(threads: number): string {
|
||||
return `id number url title state isDraft updatedAt closedAt
|
||||
author { login }
|
||||
headRefName headRefOid baseRefName additions deletions mergeable mergeStateStatus reviewDecision
|
||||
headRefName headRefOid baseRefName baseRefOid additions deletions mergeable mergeStateStatus reviewDecision
|
||||
reviewThreads(first: ${threads}) { ${THREAD_FIELDS} }
|
||||
commits(last: 1) { nodes { commit { statusCheckRollup { id state contexts(first: ${PAGE_SIZE}) {
|
||||
${CHECK_FIELDS}
|
||||
@@ -132,6 +132,8 @@ const pullRequestSchema = z.object({
|
||||
headRefName: z.string(),
|
||||
headRefOid: z.string(),
|
||||
baseRefName: z.string(),
|
||||
// Only the diff read's head start uses the base commit, so a missing or malformed one leaves the rest of the status intact.
|
||||
baseRefOid: z.unknown().transform(value => typeof value === 'string' && isCommit(value) ? value : undefined),
|
||||
additions: z.number().int().nonnegative(),
|
||||
deletions: z.number().int().nonnegative(),
|
||||
mergeable: z.string(),
|
||||
@@ -466,6 +468,7 @@ function toSnapshot(
|
||||
headBranch: node.headRefName,
|
||||
headSha: node.headRefOid,
|
||||
baseBranch: node.baseRefName,
|
||||
...node.baseRefOid === undefined ? {} : { baseSha: node.baseRefOid },
|
||||
additions: node.additions,
|
||||
deletions: node.deletions,
|
||||
mergeability: mergeability(node.mergeable),
|
||||
|
||||
@@ -159,24 +159,44 @@ export class GitHubProvider implements ForgeProvider {
|
||||
* current head and the base commit GitHub recorded with it; a head other
|
||||
* than `headSha` returns `stale`. Otherwise the compare endpoint reads the
|
||||
* patch from the merge base of that base commit and `headSha`, as the pull
|
||||
* request's own diff does, with both ends fixed.
|
||||
* request's own diff does, with both ends fixed. Given the `base` the latest
|
||||
* status recorded with `headSha`, the comparison from it runs while the
|
||||
* GraphQL read confirms the head; a stale head or another base commit
|
||||
* cancels that comparison and waits for it to end before the read returns.
|
||||
*/
|
||||
async diff(ref: ChangeRequestRef, headSha: string, signal: AbortSignal): Promise<string | ForgeFailure> {
|
||||
async diff(ref: ChangeRequestRef, headSha: string, signal: AbortSignal, base?: string): Promise<string | ForgeFailure> {
|
||||
const { host, owner, name } = checkedRepository(ref)
|
||||
const failure = this.hostFailure(host)
|
||||
if (failure !== undefined) return failure
|
||||
if (!isCommit(headSha)) throw new Error(`forge-github: invalid head commit ${JSON.stringify(headSha)}`)
|
||||
const output = await this.read([
|
||||
'api', 'graphql', '--hostname', host,
|
||||
'-f', `query=${PULL_REQUEST_COMMITS_QUERY}`, '-f', `owner=${owner}`, '-f', `name=${name}`, '-F', `number=${ref.number}`,
|
||||
], signal)
|
||||
if (typeof output !== 'string') return output
|
||||
const commits = mapPullRequestCommitsResponse(parseJson(output))
|
||||
if (commits === undefined) return { code: 'unavailable', message: 'unexpected GitHub response to the pull request commit read' }
|
||||
if (commits.head.toLowerCase() !== headSha.toLowerCase()) {
|
||||
return { code: 'stale', message: `the pull request head moved from ${headSha} to ${commits.head}` }
|
||||
if (base !== undefined && !isCommit(base)) throw new Error(`forge-github: invalid base commit ${JSON.stringify(base)}`)
|
||||
const compare = async (from: string, operation: AbortSignal): Promise<string | ForgeFailure> =>
|
||||
await readComparison(async (args, inner) => await this.read(args, inner), ref.repository, from, headSha, operation)
|
||||
const speculation = new AbortController()
|
||||
const early = base === undefined ? undefined : { base, patch: compare(base, AbortSignal.any([signal, speculation.signal])) }
|
||||
// The early comparison is observed here, so a cancelled or unused one never rejects unhandled.
|
||||
const settled = early?.patch.then(() => undefined, () => undefined)
|
||||
const discard = async (): Promise<void> => {
|
||||
speculation.abort()
|
||||
await settled
|
||||
}
|
||||
try {
|
||||
const output = await this.read([
|
||||
'api', 'graphql', '--hostname', host,
|
||||
'-f', `query=${PULL_REQUEST_COMMITS_QUERY}`, '-f', `owner=${owner}`, '-f', `name=${name}`, '-F', `number=${ref.number}`,
|
||||
], signal)
|
||||
if (typeof output !== 'string') return output
|
||||
const commits = mapPullRequestCommitsResponse(parseJson(output))
|
||||
if (commits === undefined) return { code: 'unavailable', message: 'unexpected GitHub response to the pull request commit read' }
|
||||
if (commits.head.toLowerCase() !== headSha.toLowerCase()) {
|
||||
return { code: 'stale', message: `the pull request head moved from ${headSha} to ${commits.head}` }
|
||||
}
|
||||
if (early?.base.toLowerCase() === commits.base.toLowerCase()) return await early.patch
|
||||
await discard()
|
||||
return await compare(commits.base, signal)
|
||||
} finally {
|
||||
await discard()
|
||||
}
|
||||
return await readComparison(async (args, operation) => await this.read(args, operation), ref.repository, commits.base, headSha, signal)
|
||||
}
|
||||
|
||||
/** Read one file at a commit through the contents API in its raw form. */
|
||||
|
||||
@@ -54,6 +54,7 @@ export function pullRequestNode(number: number, overrides: Record<string, unknow
|
||||
headRefName: `topic-${number}`,
|
||||
headRefOid: 'f'.repeat(40),
|
||||
baseRefName: 'master',
|
||||
baseRefOid: 'b'.repeat(40),
|
||||
additions: 128,
|
||||
deletions: 14,
|
||||
mergeable: 'MERGEABLE',
|
||||
|
||||
@@ -45,6 +45,13 @@ describe('snapshot query', () => {
|
||||
})
|
||||
|
||||
describe('snapshot mapping', () => {
|
||||
it.each([['missing', undefined], ['null', null], ['not a full commit', 'HEAD~1']])(
|
||||
'keeps the status and leaves out the base commit when GitHub reports it %s', async (_case, baseRefOid) => {
|
||||
const snapshot = await single(pullRequestNode(1, { baseRefOid }))
|
||||
expect(snapshot).not.toHaveProperty('baseSha')
|
||||
expect(snapshot.headSha).toBe('f'.repeat(40))
|
||||
})
|
||||
|
||||
it('maps the pull request fields into a provider-neutral snapshot', async () => {
|
||||
expect((await single(pullRequestNode(1, {}, [checkRun('lint', 'COMPLETED', 'SUCCESS')])))).toEqual({
|
||||
ref: pull(1),
|
||||
@@ -56,6 +63,7 @@ describe('snapshot mapping', () => {
|
||||
headBranch: 'topic-1',
|
||||
headSha: 'f'.repeat(40),
|
||||
baseBranch: 'master',
|
||||
baseSha: 'b'.repeat(40),
|
||||
additions: 128,
|
||||
deletions: 14,
|
||||
mergeability: 'mergeable',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { changeRequestKey } from '@deepseek-ai/dsh-experimental-forge'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { GhOutputLimitError, GhTimeoutError, GhUnavailableError } from '../src/gh.ts'
|
||||
import type { GhOutput, GhRunner } from '../src/gh.ts'
|
||||
import { PULL_REQUEST_COMMITS_QUERY, THREADS_QUERY } from '../src/graphql.ts'
|
||||
@@ -446,6 +446,55 @@ describe('GitHub provider reads', () => {
|
||||
expect(gh.calls[1]?.at(-1)).toBe(`repos/octo/demo/compare/${base}...${head.toUpperCase()}`)
|
||||
})
|
||||
|
||||
it('starts the comparison from the base the status recorded while it confirms the head', async () => {
|
||||
const confirmed = Promise.withResolvers<GhOutput>()
|
||||
const gh = fakeGh(args => args.includes(`query=${PULL_REQUEST_COMMITS_QUERY}`)
|
||||
? confirmed.promise : output('diff --git a/x b/x\n'))
|
||||
|
||||
const read = provider(gh.run).diff(pull(7), head, signal, base)
|
||||
await vi.waitFor(() => { expect(gh.calls).toHaveLength(2) })
|
||||
expect(gh.calls.map(call => call.at(-1))).toContain(`repos/octo/demo/compare/${base}...${head}`)
|
||||
confirmed.resolve(output({ data: { repository: { pullRequest: { headRefOid: head, baseRefOid: base.toUpperCase() } } } }))
|
||||
|
||||
await expect(read).resolves.toBe('diff --git a/x b/x\n')
|
||||
expect(gh.calls).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('cancels the early comparison and waits for it when the head moved, the base differs, or the head read fails', async () => {
|
||||
const moved = 'e'.repeat(40)
|
||||
for (const [answer, expected, compares] of [
|
||||
[output({ data: { repository: { pullRequest: { headRefOid: pushed, baseRefOid: base } } } }),
|
||||
{ code: 'stale', message: `the pull request head moved from ${head} to ${pushed}` }, [base]],
|
||||
[output({ data: { repository: { pullRequest: { headRefOid: head, baseRefOid: moved } } } }), 'diff --git a/y b/y\n', [base, moved]],
|
||||
[output('', 1, 'gh: Something went wrong (HTTP 502)'), { code: 'unavailable', message: 'gh: Something went wrong (HTTP 502)' }, [base]],
|
||||
] as const) {
|
||||
const calls: string[][] = []
|
||||
const cancelled: string[] = []
|
||||
const run: GhRunner = async (args, operation) => {
|
||||
calls.push([...args])
|
||||
if (args.includes(`query=${PULL_REQUEST_COMMITS_QUERY}`)) return answer
|
||||
const range = String(args.at(-1))
|
||||
if (range.includes(`${moved}...`)) return output('diff --git a/y b/y\n')
|
||||
// The early comparison runs until the provider cancels it.
|
||||
await new Promise((resolve) => { operation.addEventListener('abort', resolve, { once: true }) })
|
||||
cancelled.push(range)
|
||||
throw operation.reason
|
||||
}
|
||||
|
||||
await expect(provider(run).diff(pull(7), head, signal, base)).resolves.toEqual(expected)
|
||||
expect(calls.map(call => String(call.at(-1))).filter(target => target.includes('/compare/'))
|
||||
.map(target => target.split('/compare/')[1]?.split('...')[0])).toEqual(compares)
|
||||
expect(cancelled).toEqual([`repos/octo/demo/compare/${base}...${head}`])
|
||||
}
|
||||
})
|
||||
|
||||
it('refuses a base that is not a full commit object name before starting gh', async () => {
|
||||
const gh = pinned()
|
||||
|
||||
await expect(provider(gh.run).diff(pull(7), head, signal, 'HEAD~1')).rejects.toThrow('forge-github: invalid base commit "HEAD~1"')
|
||||
expect(gh.calls).toEqual([])
|
||||
})
|
||||
|
||||
it('reports a head that moved past the requested commit as stale without reading the diff', async () => {
|
||||
const gh = pinned({ head: pushed, base })
|
||||
|
||||
|
||||
Loaded 100 of 126 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user