Merge remote-tracking branch 'origin/master' into feat/forge-review-viewed-lenses
# Conflicts: # apps/web/tests/forge-panel.e2e.ts # docs/config-catalog.md # docs/config-catalog.zh.md # docs/subsystems/forge.i18n.yaml # docs/subsystems/forge.md # docs/subsystems/forge.zh.md # packages/experimental/client-ui-forge/README.i18n.yaml # packages/experimental/client-ui-forge/README.md # packages/experimental/client-ui-forge/README.zh.md # packages/experimental/client-ui-forge/src/client/ChangesTab.tsx # packages/experimental/client-ui-forge/src/index.ts # packages/experimental/client-ui-forge/tests/controller.host.spec.ts # packages/experimental/forge-github/README.i18n.yaml # packages/experimental/forge-github/README.md # packages/experimental/forge-github/README.zh.md # packages/experimental/forge-github/src/index.ts # packages/experimental/forge-github/src/provider.ts # packages/experimental/forge-github/tests/plugin.spec.ts # packages/experimental/forge-github/tests/provider.spec.ts # packages/experimental/forge-github/tests/review.spec.ts # packages/experimental/forge/README.i18n.yaml # packages/extensions/tool-cordis/src/api-catalog.ts
This commit is contained in:
commit
fb637c2bfd
288 files changed
+9511
-1345
No files matched your search
@@ -9,8 +9,8 @@
|
||||
en: cdc04139c8d69415
|
||||
zh: a3fe3e78ada08d95
|
||||
/agent-note-continuous-cua-hover-without-cross-call-ownership/decision:
|
||||
en: d1ea8fdc05927896
|
||||
zh: d3039bbb82bc949c
|
||||
en: 125c860c1c568960
|
||||
zh: 8a2ece1101358200
|
||||
/agent-note-continuous-cua-hover-without-cross-call-ownership/alternatives-considered:
|
||||
en: 18d917ee2e909b93
|
||||
zh: 5b5ad0530f138cda
|
||||
|
||||
@@ -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. 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 falling back to 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; 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.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ Status: implemented
|
||||
|
||||
## 决策
|
||||
|
||||
显式窗口提供 `mouseMove`,接受窗口坐标或已观察元素的字符串 ID,结果用 `hoverRoute` 和 `systemPointerMoved` 报告执行路径。悬停不按下鼠标按钮,禁用控件可使用系统鼠标移动路径。调用结束、AX 读取和截图不恢复先前的鼠标位置。进入浮层前可连续调用并经过中间位置。点击只在目标位置发送按键事件,不先在窗口外点击。 [输入路径决定](2026-10-13-cua-hover-routes-and-coordinates.zh.md)优先选择 AX 替代界面,再回退到经过前台和鼠标命中检查的系统移动,不隐式激活或抬起应用。其他输入或窗口变化仍可能与检查交错。
|
||||
显式窗口提供 `mouseMove`,接受窗口坐标或已观察元素的字符串 ID,结果用 `hoverRoute` 和 `systemPointerMoved` 报告执行路径。悬停不按下鼠标按钮,启用 `systemPointerHover` 后可通过系统鼠标悬停禁用控件。调用结束、AX 读取和截图不恢复先前的鼠标位置。进入浮层前可连续调用并经过中间位置。点击只在目标位置发送按键事件,不先在窗口外点击。 [输入路径决定](2026-10-13-cua-hover-routes-and-coordinates.zh.md)优先选择 AX 替代界面,只有启用该配置时才回退到经过前台和鼠标命中检查的系统移动,不隐式激活或抬起应用。其他输入或窗口变化仍可能与检查交错。
|
||||
|
||||
[共享 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: 6ac0b77bea25fcf7
|
||||
zh: e50fec9049926fdc
|
||||
en: bdf4d3386521ce00
|
||||
zh: 96db93177ad96a76
|
||||
/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 falls back to the real system pointer. 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).
|
||||
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).
|
||||
|
||||
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` 的元素或祖先。进入其展开区域的子控件时保持父控件展开,离开后恢复。没有可用路径时回退到真实系统鼠标。AX 动作失败直接返回,因为已产生的效果可能不确定。结果记录实际路径及是否移动系统鼠标。系统移动要求所选应用处于前台,并使用 AppKit 的鼠标命中查询排除不接收输入的覆盖窗口。所有路径都遵守[跨调用不分配所有权](2026-10-13-cua-continuous-hover.zh.md)的决定。
|
||||
原生悬停优先寻找同一窗口内同时声明 `AXShowAlternateUI` 和 `AXShowDefaultUI` 的元素或祖先。进入其展开区域的子控件时保持父控件展开,离开后恢复。没有可用路径时默认返回 `HOVER_UNAVAILABLE`。启用 `systemPointerHover` 后才允许移动系统光标。AX 动作失败直接返回,因为已产生的效果可能不确定。结果记录实际路径及是否移动系统鼠标。系统移动要求所选应用处于前台,并使用 AppKit 的鼠标命中查询排除不接收输入的覆盖窗口。所有路径都遵守[跨调用不分配所有权](2026-10-13-cua-continuous-hover.zh.md)的决定。
|
||||
|
||||
面向模型的坐标默认使用返回截图中的像素。`click`、`mouseMove`、`scroll` 和 `drag` 传入 `relative: true` 时接受 0~999 的千分比整数,仅用于坐标目标。Host 负责换算原生窗口 point。应用级观察刷新窗口清单,单窗口观察保留其他窗口的图片映射。窗口尺寸变化使旧图片失效,移动窗口保留局部坐标。相对坐标的端点保持在窗口内,不随截图分辨率改变。
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# Bilingual-pair consistency record for 2026-11-06-chat-history-consumption-details.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/proposed/feature/2026-11-06-chat-history-consumption-details.md
|
||||
/agent-note-chat-history-consumption-classes-and-interactions:
|
||||
en: 69ce0866b2608208
|
||||
zh: 55fdeded5d7a4779
|
||||
/agent-note-chat-history-consumption-classes-and-interactions/problem:
|
||||
en: edaa7ca0e55a9c33
|
||||
zh: 33d2b5ac07760988
|
||||
/agent-note-chat-history-consumption-classes-and-interactions/proposal:
|
||||
en: 5e5553981c48d0e2
|
||||
zh: 1195ac8d64180451
|
||||
/agent-note-chat-history-consumption-classes-and-interactions/proposal/owners-and-interfaces:
|
||||
en: efbe7ccc25cf1f42
|
||||
zh: 3bb8f803677824bd
|
||||
/agent-note-chat-history-consumption-classes-and-interactions/proposal/data-lifetimes:
|
||||
en: ffdbe0e796463624
|
||||
zh: f10bd2ced5748017
|
||||
/agent-note-chat-history-consumption-classes-and-interactions/proposal/consumption-intent-and-wakeups:
|
||||
en: db8763250020e58c
|
||||
zh: 4d9a17f1b209d4f2
|
||||
/agent-note-chat-history-consumption-classes-and-interactions/proposal/shared-anchors-and-layout-reads:
|
||||
en: 21df3db6bc83e702
|
||||
zh: 43b49ceb7407a422
|
||||
/agent-note-chat-history-consumption-classes-and-interactions/alternatives-considered:
|
||||
en: 7b3b9b0c7b8f293b
|
||||
zh: e20045fc3080418f
|
||||
/agent-note-chat-history-consumption-classes-and-interactions/acceptance-criteria:
|
||||
en: 1b22cf3616302960
|
||||
zh: 418ead8421a91bee
|
||||
/agent-note-chat-history-consumption-classes-and-interactions/risks:
|
||||
en: 9a61970e311751ac
|
||||
zh: b24d0eac7b12af62
|
||||
@@ -0,0 +1,104 @@
|
||||
# Agent Note: Chat history consumption classes and interactions
|
||||
|
||||
Status: proposed
|
||||
|
||||
English | [中文](2026-11-06-chat-history-consumption-details.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Session owns a history page's network lifetime, while Chat owns whether that page can safely enter the current view. Two-phase loading must reuse existing publication and scrolling capabilities without copying business data, consumption intent, and DOM preservation state into one owner.
|
||||
|
||||
## Proposal
|
||||
|
||||
[History prefetch and safe consumption](2026-11-06-chat-history-consumption.md) defines user-visible behavior and trade-offs. This note defines class interactions and lifetimes without adding an independent scroll animation or React commit manager.
|
||||
|
||||
### Owners and interfaces
|
||||
|
||||
| Owner | Data and operations |
|
||||
|---|---|
|
||||
| [Session](../../../../packages/api/session-controller/src/client/sessions/session.ts) | `loadOlder({ deferConsumption?: boolean })` prepares and optionally consumes a page; `consumeOlder()` synchronously publishes a valid staged page; `historyLoadStatus` is idle/loading/pendingConsumption/error, and `loadingOlder` derives from loading, including Turn jumps |
|
||||
| Session request record | Holds generation, starting cursor, page entries, hasMore, Promise, and consumption/invalidation flags; staging does not advance the visible window cursor |
|
||||
| [ChatNavigation](../../../../packages/client/ui-chat/src/client/chat/use-chat-navigation.ts) | Owns none/auto/manual consumption intent and request exclusion; automatic consumption waits or falls back by blocker, while manual consumption captures an anchor and consumes without blocker-wait subscriptions |
|
||||
| [ChatViewport](../../../../packages/client/ui-chat/src/client/chat/use-chat-viewport.ts) | Reads internal input/motion state and scrollTop, returns a blocker or native/preserve/prepend, and performs distance measurement and existing anchor algorithms |
|
||||
| [useChatScroll](../../../../packages/client/ui-chat/src/client/chat/use-chat-scroll.ts) | Connects existing reading, scroll, content, and size notifications to consumption checks and settles a pending one-shot correction during commit handling |
|
||||
| [ChatView](../../../../packages/client/ui-chat/src/client/chat/ChatView.tsx) | Requests and consumes through injected callbacks and reads state through useSession; renders a fixed-height history control without retaining Session history data or accumulating historyWasPartial |
|
||||
|
||||
```text
|
||||
ChatNavigation.checkHistory
|
||||
├─ pendingConsumption + intent=none → retain page until clicked
|
||||
├─ pendingConsumption + intent=manual → beginManualPaging → consumeOlder
|
||||
├─ pendingConsumption + intent=auto → viewport.historyConsumption
|
||||
│ ├─ touch/inner-scroll → retain automatic intent before checking motion
|
||||
│ ├─ motion/pointer/unavailable → return to button
|
||||
│ ├─ native → consumeOlder
|
||||
│ ├─ prepend → capture paging position → consumeOlder → correct once after commit
|
||||
│ └─ preserve → beginPreserving → consumeOlder
|
||||
├─ loading → no duplicate request; a click records manual intent
|
||||
├─ error → wait for explicit retry
|
||||
└─ idle + hasMore + (manual intent or near top without following) → request a page
|
||||
|
||||
Session.loadOlder
|
||||
→ prepareOlder reuses or creates the request
|
||||
→ await the captured request Promise
|
||||
→ consumeOlder when request identity remains valid and consumption is not deferred
|
||||
|
||||
Session.prepareOlder
|
||||
→ network prepend → capture the page without publishing eventSource
|
||||
→ validate progress → pendingConsumption or error
|
||||
|
||||
Session.consumeOlder
|
||||
→ check generation/cursor/consuming
|
||||
→ synchronous eventSource.prepend → existing Conversation merge and flush
|
||||
→ clear the request record and publish final status
|
||||
```
|
||||
|
||||
### Data lifetimes
|
||||
|
||||
`isOpen` means only that Session `openState === 'open'`; it is independent of whether a page is `pendingConsumption`. `historyWasPartial` is monotonic for one Client Session instance: once its visible window has `hasMore=true`, the fact remains until the instance ends, across component remounts and reconnects, without entering the persistent log.
|
||||
|
||||
`prepareOlder()` returns an existing request or creates one, and `loadOlder()` rechecks identity after awaiting that captured request Promise. An obsolete Promise cannot consume a newer page. A default call consumes the prepared page; `deferConsumption` retains a valid nonempty page, while an empty terminal page can immediately confirm exhaustion. Consumption retains the request record and sets `consuming` to prevent synchronous subscriber reentry without borrowing the network loading state.
|
||||
|
||||
An ordinary request captures the transport page first. It can be consumed only after the request succeeds and its cursor advances, or an empty page explicitly confirms exhaustion. Failures and empty pages that still claim more history enter error without automatic retry. Window replacement, reconnect, and disposal invalidate old pages; when an in-flight request may have advanced the transport cursor, the existing stream restart restores a baseline before another page is allowed.
|
||||
|
||||
`loadThrough(seq)` is explicit navigation: it first adopts a staged page and then uses existing Turn-aligned paging. An ordinary in-flight request remains exclusive, and the caller continues after it settles. A jump publishes its successful prefix once at completion or a later failure, without delaying live appends. `loadingOlder` always equals `historyLoadStatus === 'loading'`.
|
||||
|
||||
### Consumption intent and wakeups
|
||||
|
||||
An automatic request records auto intent; a click records manual intent. Successful consumption, failure, exhaustion, or navigation cancellation clears the intent. A cached page with none intent is neither measured nor consumed by ordinary events; its button records manual intent without requesting it again. Touch and actual inner-scroll retain auto before motion is evaluated; after release the current state is checked again. Motion, pointer, or unavailable positioning otherwise returns auto to none. Manual intent bypasses these automatic blockers once a page is available: `beginManualPaging()` uses `beginPaging()` at `scrollTop ≤ 0.5`; elsewhere it calls `beginPreserving()` with `beginPaging()` as fallback. Consumption proceeds even if no anchor is produced. Loading and processing clicks join the current request rather than duplicating it.
|
||||
|
||||
Prefetch reuses the existing 500ms reading sample, scrollend, content commits, and size notifications, coalescing them into one rAF check. It uses no IntersectionObserver or additional sample timer and measures nothing in the raw scroll event. Only a ResizeObserver entry for the scroller itself refreshes cached clientHeight during the callback's read phase; column growth does not reread viewport height. Once internal state permits an automatic request, it reads the history control's distance from the viewport and compares it with two cached viewport heights.
|
||||
|
||||
`historyConsumption()` does not query DOM nodes or measure sizes. `FlowMotion.active()` exposes existing running, batchScheduled, batch, and settling state as a read-only query; passive listeners maintain input state. Size observation removes detached scrolling targets. Finger release, inner scroll completion, transition completion/cancellation, and existing notifications can wake retained automatic intent. Manual consumption has no fold-completion waiting subscription. Without consumption intent, these notifications do not revive an abandoned automatic page.
|
||||
|
||||
### Shared anchors and layout reads
|
||||
|
||||
For automatic consumption at `scrollTop ≤ 0.5`, selection uses `beginPaging`'s first paging-anchor rule without a reading-position hit test. `measurePaging()` only captures a `PagingPosition`; `retainPaging()` separately owns persistent preservation and inner-size observation. Stationary preservation and one-shot correction share remount resolution in `resolvePaging()` and inner/outer calculations in `applyPaging()`.
|
||||
|
||||
Persistent preservation and one-shot correction use the same `applyPaging()` formula to restore the captured screen position, compensating the inner scrollport before the outer one. They do not add changes in scrollTop since capture: native anchoring may have caused those changes. Their lifetimes differ: one-shot correction neither enters paging state nor subscribes to inner-size changes, and releases after one application. `finishHistoryPrepend()` returns the actual landing, and commit handling immediately saves it through `reading.preservePosition()` rather than waiting for a later scroll event. A committed transition from `pendingConsumption` to `idle` settles the one-shot record even when the visible Node order is unchanged; a closed view or `error` discards the record without applying it. Geometry invalidation remains conditional on the existing layout-change signal, and no new page token or React publication mechanism is introduced. The actual conversation scrollport uses `overscroll-behavior-y: none`, without creating smooth scrolling or a new momentum animation.
|
||||
|
||||
Distance confirmation still needs current geometry, and one-shot correction after prepend still needs the new position. These reads serve correctness, but placement inside rAF, ResizeObserver, or commit handling alone does not prove they add no layout. Read frequency, read/write ordering, and actual Layout/Paint costs require separate verification; position stability is not performance evidence.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Pause publication globally in Conversation.** That also blocks live tail output and expands the cross-view impact.
|
||||
|
||||
**Retain a complete window snapshot in Chat.** That copies business data and can overwrite messages received while waiting.
|
||||
|
||||
**Store separate coordinates and continuously track the first row for one-shot correction.** That duplicates existing paging anchors and can expand scroll ownership; share the position record and calculations while keeping lifetimes separate.
|
||||
|
||||
**Add captured-to-current scroll deltas through `preserveMovement`.** The deltas cannot distinguish reader input from native anchoring for the same prepend, so adding them can compensate twice.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- Default single-stage calls, deferred consumption, and Turn jumps coexist; an old request cannot consume or overwrite a new one.
|
||||
- Ordinary events do not revive automatic fallback; touch/inner-scroll continuation, manual completion, and unmount cleanup have focused verification.
|
||||
- One-shot correction uses the existing screen-restoration formula without persistent observation or duplicate native compensation; persistent and inner preservation do not regress.
|
||||
- Distance checks use cached viewport height; raw scroll listeners and consumption admission perform no layout reads.
|
||||
|
||||
## Risks
|
||||
|
||||
Screen restoration uses the measured residual displacement after native anchoring rather than adding scroll-position deltas. It does not preserve arbitrary input movement between capture and application or guarantee an uninterrupted momentum trajectory. Near-zero outer positions, native inner anchoring, and input arriving around the prepend require current-build browser verification.
|
||||
|
||||
Explicit navigation and multiple views share a Session, so Chat's automatic waiting policy must not move into the global event source. Manual consumption establishes persistent preservation at consumption time; an already active touch can then enter the paging scroll branch instead of reader sampling, constraining movement until a new input releases the anchor. This [manual paging limitation](../../../../packages/client/ui-chat/README.md#known-limitations-and-deferred-work) remains unresolved. Manual consumption proceeds without preservation if no anchor can be captured, so that structure has no position-stability guarantee. Cancellation, reconnect, and removal of the selected anchor remain distinct lifecycle cases; a missing anchor cannot provide position preservation. One-shot geometry measurement, native inner anchoring, and asynchronous content growth need browser evidence from the current build.
|
||||
|
||||
Reports must identify source/artifact versions, real or synthetic inputs, missing conditions, and cleanup results. V8 function or range hits are not TypeScript line/branch percentages; earlier iteration recordings, Chrome/Safari manual feedback, or a successful build do not establish a complete matrix pass for this proposal.
|
||||
@@ -0,0 +1,104 @@
|
||||
# Agent Note: Chat 历史消费的类与交互
|
||||
|
||||
Status: proposed
|
||||
|
||||
[English](2026-11-06-chat-history-consumption-details.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
历史页的网络生命周期属于 Session,能否安全进入当前页面属于 Chat。两阶段加载需要复用现有事件发布与滚动能力,不能把业务数据、消费意图和 DOM 保位状态复制到同一个所有者。
|
||||
|
||||
## Proposal
|
||||
|
||||
用户可见行为与取舍由[历史预取与安全消费](2026-11-06-chat-history-consumption.zh.md)定义。本文规定类之间的调用与生命周期,不增加独立的滚动动画或 React 提交管理。
|
||||
|
||||
### 所有者与接口
|
||||
|
||||
| 所有者 | 数据与操作 |
|
||||
|---|---|
|
||||
| [Session](../../../../packages/api/session-controller/src/client/sessions/session.ts) | `loadOlder({ deferConsumption?: boolean })` 准备并按选项消费一页;`consumeOlder()` 同步发布有效暂存页;`historyLoadStatus` 表达 idle/loading/pendingConsumption/error,`loadingOlder` 从 loading 派生并包含 Turn 跳转 |
|
||||
| Session 请求记录 | 保存 generation、请求前游标、页内容、hasMore、Promise 与消费/失效标记;缓存页不提前改变可见窗口游标 |
|
||||
| [ChatNavigation](../../../../packages/client/ui-chat/src/client/chat/use-chat-navigation.ts) | 保存 none/auto/manual 消费意图和请求互斥;自动按阻塞原因等待或退回按钮,手动采集锚点后消费,不注册阻塞结束等待 |
|
||||
| [ChatViewport](../../../../packages/client/ui-chat/src/client/chat/use-chat-viewport.ts) | 读取内部输入/动画状态与 scrollTop,报告阻塞原因或 native/preserve/prepend;执行距离测量和已有锚点算法 |
|
||||
| [useChatScroll](../../../../packages/client/ui-chat/src/client/chat/use-chat-scroll.ts) | 把既有阅读、滚动、内容及尺寸通知接到消费检查;在提交阶段结算待执行的一次补偿 |
|
||||
| [ChatView](../../../../packages/client/ui-chat/src/client/chat/ChatView.tsx) | 通过注入回调请求/消费,通过 useSession 读取状态;固定高度显示历史控件,不保存 Session 历史数据或累计 historyWasPartial |
|
||||
|
||||
```text
|
||||
ChatNavigation.checkHistory
|
||||
├─ pendingConsumption + intent=none → retain page until clicked
|
||||
├─ pendingConsumption + intent=manual → beginManualPaging → consumeOlder
|
||||
├─ pendingConsumption + intent=auto → viewport.historyConsumption
|
||||
│ ├─ touch/inner-scroll → retain automatic intent before checking motion
|
||||
│ ├─ motion/pointer/unavailable → return to button
|
||||
│ ├─ native → consumeOlder
|
||||
│ ├─ prepend → capture paging position → consumeOlder → correct once after commit
|
||||
│ └─ preserve → beginPreserving → consumeOlder
|
||||
├─ loading → no duplicate request; a click records manual intent
|
||||
├─ error → wait for explicit retry
|
||||
└─ idle + hasMore + (manual intent or near top without following) → request a page
|
||||
|
||||
Session.loadOlder
|
||||
→ prepareOlder reuses or creates the request
|
||||
→ await the captured request Promise
|
||||
→ consumeOlder when request identity remains valid and consumption is not deferred
|
||||
|
||||
Session.prepareOlder
|
||||
→ network prepend → capture the page without publishing eventSource
|
||||
→ validate progress → pendingConsumption or error
|
||||
|
||||
Session.consumeOlder
|
||||
→ check generation/cursor/consuming
|
||||
→ synchronous eventSource.prepend → existing Conversation merge and flush
|
||||
→ clear the request record and publish final status
|
||||
```
|
||||
|
||||
### 数据生命周期
|
||||
|
||||
`isOpen` 只表达会话 `openState === 'open'`,与某页是否 `pendingConsumption` 无关。`historyWasPartial` 是客户端 Session 实例的单调事实:可见窗口曾有 `hasMore=true` 后保留到实例结束,不随组件重挂载或重连清除,不写持久化日志。
|
||||
|
||||
`prepareOlder()` 返回已有请求或创建一个请求,`loadOlder()` 等待捕获的请求 Promise 后再核对身份。旧 Promise 完成不能消费随后创建的新页。默认调用消费准备好的页;`deferConsumption` 保留有效非空页,终态空页可以直接确认耗尽。消费期间保持请求记录并设置 `consuming`,阻止同步订阅者重入,不借用网络 loading 状态。
|
||||
|
||||
普通请求先捕获传输返回页,只有请求成功且游标前进,或空页明确表示耗尽时才能消费。失败或空页仍声称有更多则进入 error,不自动重试。窗口替换、重连和销毁使旧页失效;如果在途请求可能已推进传输游标,现有流重启先恢复基线,再允许下一页。
|
||||
|
||||
`loadThrough(seq)` 是显式导航:先采用已有待消费页,再沿既有 Turn 对齐规则加载;普通请求在途时保持互斥,由调用方在其结算后继续。跳转的成功前缀在完成或后续失败时一次发布,实时追加不等待它。`loadingOlder` 始终等价于 `historyLoadStatus === 'loading'`。
|
||||
|
||||
### 消费意图与唤醒
|
||||
|
||||
自动请求开始时登记 auto,点击登记 manual。消费成功、失败、耗尽或取消导航时清除意图。缓存存在但意图为 none 时,普通事件不测量或消费该缓存;按钮重新登记 manual,不重复取页。touch 与实际 inner-scroll 优先于 motion 判断保留 auto,释放后重新检查当前状态;其余 motion、pointer 或 unavailable 使 auto 退回 none。manual 在页面可用时绕过这些自动阻塞:`beginManualPaging()` 在 `scrollTop ≤ 0.5` 时调用 `beginPaging()`;其他位置调用 `beginPreserving()`,并以 `beginPaging()` 兜底。没有产生锚点时仍继续消费。加载和处理状态的点击加入当前请求,不重复请求。
|
||||
|
||||
历史预取复用已有 500ms 阅读采样、scrollend、内容提交和尺寸通知,并合并为一次 rAF 检查。不使用 IntersectionObserver,不新增采样计时器,不在原始 scroll 事件中测量。仅 scroller 自身的 ResizeObserver 条目在回调读阶段更新 clientHeight 缓存,column 增长不重新读取视口高度。内部状态允许自动请求时,读取历史控件相对视口的位置,与缓存的两屏高度比较。
|
||||
|
||||
`historyConsumption()` 不查询 DOM 或测量尺寸。`FlowMotion.active()` 从已有 running、batchScheduled、batch、settling 状态提供只读查询;输入状态来自被动事件监听。滚动目标失效的清理在尺寸观察处进行。手指释放、内层滚动结束、过渡结束/取消和既有通知可以唤醒保留的自动意图;手动消费不注册折叠结束等待。没有消费意图时这些通知不恢复已经放弃的自动页。
|
||||
|
||||
### 锚点复用与布局读取
|
||||
|
||||
自动消费在顶部 `scrollTop ≤ 0.5` 时使用 `beginPaging` 的首条分页锚点规则,不先执行阅读位置命中检测。`measurePaging()` 只采集 `PagingPosition`;`retainPaging()` 单独拥有持续保位和内层尺寸观察。普通静止保位与一次补偿共用 `resolvePaging()` 的重挂载查找及 `applyPaging()` 的内外层计算。
|
||||
|
||||
持续保位和一次补偿用相同的 `applyPaging()` 公式恢复采集时的屏幕位置,先补内层、再补外层。计算不叠加采集后 scrollTop 的变化,因为这些变化可能已经来自原生锚定。两者只区分生命周期:一次补偿不进入 paging 状态、不注册内层尺寸观察,应用一次后释放。`finishHistoryPrepend()` 返回实际落点,提交处理立即通过 `reading.preservePosition()` 保存阅读位置,不依赖后续 scroll 事件。已提交的 `pendingConsumption` 到 `idle` 状态转换,即使可见 Node 顺序未变化,也会结算一次补偿记录;视图关闭或进入 `error` 时丢弃记录,不应用补偿。几何失效仍只由已有布局变化信号触发,不增加页 token 或 React 发布机制。会话实际滚动容器设置 `overscroll-behavior-y: none`,不创建平滑滚动或新的惯性动画。
|
||||
|
||||
距离检查仍需要当前几何;前插后的一次补偿仍需要读取新位置。这些读数有正确性用途,但不能仅凭位于 rAF、ResizeObserver 或提交回调中,就认定不会增加布局。读取频率、读写顺序及实际 Layout/Paint 成本分别验收;位置不漂移不能替代性能证明。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**在 Conversation 全局暂停发布。** 会同时阻止尾部实时输出,扩大跨视图影响。
|
||||
|
||||
**在 Chat 保存完整窗口快照。** 复制业务数据,消费时可能覆盖等待期间的新消息。
|
||||
|
||||
**为一次补偿另存坐标并持续追踪首行。** 与既有分页锚点重复,并可能扩大滚动控制;共享位置记录和计算,只区分生命周期。
|
||||
|
||||
**通过 `preserveMovement` 叠加采集后滚动增量。** 该增量无法区分读者输入与同一次前插的原生锚定,叠加后可能重复补偿。
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- 默认单阶段调用、延迟消费及 Turn 跳转共存,旧请求不能消费或覆盖新请求。
|
||||
- auto 回退不会被普通事件恢复;触摸与内层滚动续接、manual 完成承诺和卸载清理都有定向验证。
|
||||
- 一次补偿使用既有屏幕位置恢复公式,不注册持续观察、不重复原生补偿;持续保位和内层补偿不退化。
|
||||
- 距离读取使用缓存视口高度;原始滚动监听与消费准入不读取布局。
|
||||
|
||||
## Risks
|
||||
|
||||
屏幕位置恢复使用原生锚定后测得的剩余位移,而不是叠加滚动位置增量。它不保留采集到应用之间任意输入造成的移动,也不保证惯性轨迹连续。外层近零位置、内层原生锚定以及前插附近到达的输入,都需要当前构建的浏览器验证。
|
||||
|
||||
显式导航与多个视图共享 Session,不能把 Chat 的自动等待条件放进全局事件源。手动消费在消费时建立持续保位;已经进行的触摸随后可能进入 paging 滚动分支而非读者采样,移动受限直到新输入释放锚点。这项[手动分页限制](../../../../packages/client/ui-chat/README.zh.md#known-limitations-and-deferred-work)仍未解决。手动消费在无法采集锚点时仍继续,因此该结构不保证位置稳定。取消、重连和所选锚点被移除属于不同生命周期情况;锚点缺失时无法提供位置保留。一次补偿的布局测量、内层浏览器锚定及异步内容增长需要当前构建的浏览器证据。
|
||||
|
||||
验收报告应列明源码/产物版本、真实或合成输入、未覆盖条件以及清理结果。V8 函数或区间命中不等于 TypeScript 行/分支百分比;早期迭代的录像、Chrome/Safari 手工体验和单个构建通过不能证明本提案的完整矩阵已通过。
|
||||
@@ -0,0 +1,31 @@
|
||||
# Bilingual-pair consistency record for 2026-11-06-chat-history-consumption.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/proposed/feature/2026-11-06-chat-history-consumption.md
|
||||
/agent-note-chat-history-prefetch-and-safe-consumption:
|
||||
en: b0bb85f8780edc41
|
||||
zh: f3576c234a2f1a99
|
||||
/agent-note-chat-history-prefetch-and-safe-consumption/problem:
|
||||
en: fbdf714be9ea6c90
|
||||
zh: d9b6c07347812fc1
|
||||
/agent-note-chat-history-prefetch-and-safe-consumption/proposal:
|
||||
en: bda6c4b3dfff143e
|
||||
zh: 4316f6dec6adaa50
|
||||
/agent-note-chat-history-prefetch-and-safe-consumption/proposal/manual-and-automatic-loading:
|
||||
en: 9f3707f7d6c3d98e
|
||||
zh: f8903e2d1d652535
|
||||
/agent-note-chat-history-prefetch-and-safe-consumption/proposal/position-and-status-presentation:
|
||||
en: 33f2466b802f3957
|
||||
zh: 095227215a22b403
|
||||
/agent-note-chat-history-prefetch-and-safe-consumption/proposal/scope-and-retained-decisions:
|
||||
en: dd4eeadd2fccab4b
|
||||
zh: e05d53e63cc44ddf
|
||||
/agent-note-chat-history-prefetch-and-safe-consumption/alternatives-considered:
|
||||
en: 7c6d9d470c5b5c93
|
||||
zh: dc12846ee0f56aae
|
||||
/agent-note-chat-history-prefetch-and-safe-consumption/acceptance-criteria:
|
||||
en: f9efd25288474b8a
|
||||
zh: 9271fde45be71de6
|
||||
/agent-note-chat-history-prefetch-and-safe-consumption/risks:
|
||||
en: ff3b2da49f5fb541
|
||||
zh: b11afa7556f3f313
|
||||
@@ -0,0 +1,74 @@
|
||||
# Agent Note: Chat history prefetch and safe consumption
|
||||
|
||||
Status: proposed
|
||||
|
||||
English | [中文](2026-11-06-chat-history-consumption.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
With only a Load earlier button, readers must reach the beginning of loaded history before clicking. Prefetch can reduce interruption, but a request that starts in a suitable state can return during touch dragging, a navigation change, or a fold animation. History prepending must not change the existing fold driver or simulate momentum.
|
||||
|
||||
## Proposal
|
||||
|
||||
Separate requesting history from consuming it. The Client Session retains at most one unconsumed page, and Chat decides whether and when to publish it. It retains the new page rather than an old window snapshot, so waiting does not block streaming at the tail. Class ownership and calls are described in the [technical details](2026-11-06-chat-history-consumption-details.md).
|
||||
|
||||
### Manual and automatic loading
|
||||
|
||||
A manual click retains one loading intent: consume an existing page, join an in-flight request, or request a page. Once data is available, the click bypasses automatic motion and input blockers: it uses the first paging anchor at the top, or captures the current reading anchor elsewhere with the paging anchor as fallback, and consumes through existing preservation without waiting for an animation callback. If neither anchor is available, consumption still proceeds without a position guarantee. The operation ends with consumption, confirmed exhaustion, or an explicit failure. A newer Turn jump, return to bottom, view closure, or other navigation or lifecycle change can cancel the earlier reading intent.
|
||||
|
||||
Automatic loading requests history after the reader leaves tail following and comes within two viewport heights of the history control. It reuses the existing 500ms reading sample, scroll completion, content commits, and size notifications, without another polling loop or IntersectionObserver. Detection can therefore wait until the next existing sample.
|
||||
|
||||
A cached page and an intent to continue automatically are separate facts. Automatic loading falls back to a button on occasional conflicts instead of waiting for every state to recover. Touch and actual inner scrolling retain continuation so mobile readers need not click after every finger release.
|
||||
|
||||
| State when data is ready | Automatic intent | Manual intent |
|
||||
|---|---|---|
|
||||
| Ordinary outer momentum, with other conditions satisfied | Consume with native browser anchoring | Consume through current-anchor preservation |
|
||||
| Stationary reading away from the top | Consume with existing reading preservation | Consume with existing reading preservation |
|
||||
| `scrollTop ≤ 0.5`, with other conditions satisfied | Reuse the paging anchor and correct once after prepend | Consume through current-anchor preservation |
|
||||
| Folding, revealing, or an existing follow animation | Keep the page and return to the button | Consume the available page through existing preservation |
|
||||
| A finger held down or actual inner scrolling | Retain intent before testing motion blockers; recheck afterward | Consume the available page through existing preservation |
|
||||
| A held mouse pointer, scrollbar dragging, or temporarily unavailable positioning | Keep the page and return to the button | Consume the available page; no anchor means no position guarantee |
|
||||
| Tail following resumes after automatic prefetch | Keep the page and cancel automatic consumption | The click has paused tail following |
|
||||
| Streaming continues at the tail | Does not itself block consumption | Does not itself block consumption |
|
||||
|
||||
After fallback, scrolling, animation completion, and content notifications cannot reactivate that cached page; only a click or explicit navigation can adopt it. Requests and unconsumed pages remain mutually exclusive: no duplicate request for a cached page and no timeout that forces insertion.
|
||||
|
||||
### Position and status presentation
|
||||
|
||||
The actual conversation scrollport uses `overscroll-behavior-y: none` to suppress vertical boundary bounce and scroll chaining, without implementing a different momentum animation. An automatic prepend at the top reuses the existing paging-position record and correction calculation, applies it once, and releases it; manual consumption and stationary preservation retain their existing persistent lifetime. Both modes restore the screen position captured immediately before consumption with the same formula; they differ only in whether preservation remains active afterward. The one-shot path does not reconstruct intervening reader movement or restart momentum. Consumption completion also settles and clears the one-shot record when the visible Node order does not change; view closure or history failure discards it without applying it.
|
||||
|
||||
The history control keeps the same height across states. Loading and Processing remain clickable to join the current request or consume the cached page; only confirmed exhaustion disables the button. A network request shows Loading earlier; an active consumption intent without a held input shows Processing; automatic fallback or a held input shows Load earlier; failure shows Retry loading; consuming the final page shows Start of conversation. The control is excluded from native anchoring. Session `historyWasPartial` records that this instance has displayed incomplete history and survives component remounts and reconnects; a short Session that opens complete has no history row.
|
||||
|
||||
### Scope and retained decisions
|
||||
|
||||
Do not add `flushSync` or change Conversation publication cadence, fold-batch targets, or ScrollFollow policy. Consumption admission reads only internal state and `scrollTop`; DOM geometry reads belong to distance confirmation and position correction. Do not add an independent persistent head-anchor controller, `batchShift`, or a per-frame compensator.
|
||||
|
||||
[One fold motion](../../implemented/architecture/2026-10-13-chat-single-fold-motion.md) and [group-tree exchanges](../../implemented/architecture/2026-10-11-chat-group-tree-exchanges.md) retain ownership of folding and grouping decisions; this proposal does not supersede them.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Prepend every response immediately and compensate continuously.** That makes pagination take over existing motion; this design corrects once at the top and waits or falls back on conflicts.
|
||||
|
||||
**Automatically wait for every unsafe state to recover.** That adds coordination obligations for occasional animations, navigation, and automatic continuation; retain touch continuation and manual completion, but return other automatic conflicts to the button.
|
||||
|
||||
**Remove staging entirely or discard conflicting pages.** Conflicts can start during a request, and the transport history cursor may already have advanced; retaining one page is simpler than rolling back that cursor.
|
||||
|
||||
**Disable loading whenever a Turn is running.** Readers still need history during long replies, so execution state cannot replace the current presentation conditions.
|
||||
|
||||
**Separate IO prefetch wakeups and React synchronous-commit control.** Reuse existing sampling and publication, accepting sample latency; do not add commit scheduling without evidence of a concrete failure.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- Requests, staging, and consumption never duplicate a page request; tail streaming continues.
|
||||
- Ordinary events do not revive automatic fallback; touch, inner scrolling, and manual clicks retain their specified continuation.
|
||||
- Stationary and top-edge prepends preserve old content position; ordinary automatic momentum consumption adds no outer JS scroll write.
|
||||
- Failure, no progress, exhaustion, reconnect, and explicit navigation have defined outcomes without request loops or stale-page consumption.
|
||||
- Control states have equal height; existing fold-driver behavior is retained, and browser evidence identifies the current build, simulated inputs, and missing cases.
|
||||
|
||||
## Risks
|
||||
|
||||
One-shot correction restores a captured screen position rather than estimating reader velocity. Input or browser movement between capture and application can therefore affect the resulting motion; validation must cover the actual browser and input sequence. The shared formula does not add scroll-position deltas that could already include native anchoring. See the [technical risks](2026-11-06-chat-history-consumption-details.md#risks).
|
||||
|
||||
CSS feature support alone does not establish that native anchoring can act. Automatic prefetch waits for the first size observation, and the 500ms sample can let fast scrolling reach the top first. The synchronous geometry read after insertion is a cost of one-shot correction; its call site alone does not prove that it adds no layout. Manual consumption without a usable anchor can move visible content. It can also establish persistent preservation during an already active touch gesture, constraining that gesture or its momentum until a new reading input releases the anchor; see the [manual paging limitation](../../../../packages/client/ui-chat/README.md#known-limitations-and-deferred-work).
|
||||
|
||||
Audit and measurement are ongoing, so this record remains proposed. Earlier Chrome/Safari manual feedback and headless results from different iterations do not establish a complete matrix pass for the final source; actual scrollbar dragging, same-document reconnect, cross-platform input, and exceptional cleanup paths require separately attributed evidence.
|
||||
@@ -0,0 +1,74 @@
|
||||
# Agent Note: Chat 历史预取与安全消费
|
||||
|
||||
Status: proposed
|
||||
|
||||
[English](2026-11-06-chat-history-consumption.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
只提供“加载更早”按钮时,读者必须到达已加载历史的开头再点击。提前请求可以减少中断,但请求发出时适合加载,不代表返回时适合前插:读者可能开始触摸拖动、切换导航目标,或者遇到折叠动画。分页不应为了前插历史改变已有折叠驱动或模拟惯性。
|
||||
|
||||
## Proposal
|
||||
|
||||
将历史请求与消费分开。客户端 Session 最多保留一页未消费的数据,Chat 决定是否以及何时发布它。数据保存为新页而不是旧窗口快照,所以等待不阻止尾部流式输出。类与调用关系见[技术细节](2026-11-06-chat-history-consumption-details.zh.md)。
|
||||
|
||||
### 手动与自动加载
|
||||
|
||||
手动点击保留一次加载意图:已有缓存就消费缓存,有在途请求就等待该请求,否则请求一页。数据可用后,点击绕过自动路径的动画和输入阻塞:在顶部使用首条分页锚点,其他位置采集当前阅读锚点并以分页锚点兜底,再通过既有保位路径消费,不等待动画回调。两个锚点都不存在时仍消费,但不保证位置稳定。该操作以消费、确认到头或明确失败结束。显式 Turn 跳转、返回底部、会话关闭等新的导航或生命周期变化可以取消原阅读意图。
|
||||
|
||||
自动加载在读者离开底部跟随、进入距历史控件两屏以内时请求。它复用已有 500ms 阅读采样及滚动结束、内容提交、尺寸通知,不新增轮询或 IntersectionObserver。代价是发现进入预取范围可能延后至下一次已有采样。
|
||||
|
||||
缓存是否存在与是否需要自动续接是两个事实。自动操作在低频冲突下退回按钮,不承诺等所有状态恢复;触摸与实际内层滚动仍保留续接,避免要求手机用户每次松手后再点击。
|
||||
|
||||
| 数据就绪时的状态 | 自动意图 | 手动意图 |
|
||||
|---|---|---|
|
||||
| 普通外层惯性,其他条件允许 | 浏览器原生锚定,消费 | 通过当前锚点保位消费 |
|
||||
| 非顶部静止阅读 | 既有阅读保位,消费 | 既有阅读保位,消费 |
|
||||
| `scrollTop ≤ 0.5`,其他条件允许 | 复用分页锚点,前插后补偿一次 | 通过当前锚点保位消费 |
|
||||
| 折叠、展开或已有跟随动画 | 保留缓存,退回按钮 | 数据可用即通过既有保位消费 |
|
||||
| 手指按住或实际内层滚动 | 优先于动画判断保留意图,结束后重查 | 数据可用即通过既有保位消费 |
|
||||
| 鼠标按住、滚动条拖动或定位能力暂不可用 | 保留缓存,退回按钮 | 消费可用页;没有锚点时不保证位置稳定 |
|
||||
| 自动预取后恢复底部跟随 | 保留缓存,取消自动消费 | 点击已暂停底部跟随 |
|
||||
| 底部继续流式输出 | 本身不阻止消费 | 本身不阻止消费 |
|
||||
|
||||
退回按钮后,滚动、动画结束和内容通知都不能重新激活该缓存页;只有点击或显式导航可以采用它。请求和待消费页仍然互斥,不为缓存页重复请求,也不超时强行插入。
|
||||
|
||||
### 位置与状态展示
|
||||
|
||||
会话实际滚动容器设置 `overscroll-behavior-y: none`,关闭纵向边界回弹和滚动传递,不实现另一套惯性动画。自动顶部前插复用既有分页位置记录和补偿计算,只应用一次并释放;手动消费和静止保位保持原有持续保位生命周期。两种模式用相同公式恢复消费前刚采集的屏幕位置,只区别于应用后是否继续保位。一次补偿不重建采集期间的读者移动,也不重新启动惯性。即使可见 Node 顺序未变化,消费完成也会结算并清除一次补偿记录;视图关闭或历史失败时直接丢弃,不再应用。
|
||||
|
||||
历史控件各状态保持同高。“正在加载”和“正在处理”仍可点击,以加入当前请求或消费缓存;只有确认到头才禁用按钮。网络请求显示“正在加载”;仍有消费意图且未按住输入时显示“正在处理”;自动回退或输入按住时显示“加载更早”;失败显示“重新加载”;最后一页消费完成后显示“已到会话开头”。控件不参与原生锚定。Session 的 `historyWasPartial` 记录本实例曾显示不完整历史,组件重挂载和重连保留此事实;首次打开即完整的短会话不添加历史行。
|
||||
|
||||
### 范围与既有决策
|
||||
|
||||
不新增 `flushSync`,不改变 Conversation 发布节奏、折叠批次目标或 ScrollFollow 的跟随策略。消费准入只读内部状态和 `scrollTop`;DOM 几何读取留在距离确认和位置补偿处。不新增独立的持续开头锚点控制器、`batchShift` 或每帧补偿器。
|
||||
|
||||
[单一折叠运动](../../implemented/architecture/2026-10-13-chat-single-fold-motion.zh.md)与[分组树交互段](../../implemented/architecture/2026-10-11-chat-group-tree-exchanges.zh.md)仍拥有折叠和分组决策,本提案不取代它们。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**任意状态下数据到达即前插,再持续补偿。** 需要分页接管已有运动;本方案只在顶部补偿一次,冲突时等待或退回按钮。
|
||||
|
||||
**所有不安全状态都自动等待恢复。** 增加低频动画、导航与自动续接的协调义务;保留触摸续接和手动完成,其他自动冲突退回按钮。
|
||||
|
||||
**完全取消暂存或直接丢弃冲突页。** 请求途中仍可能发生冲突,而且传输历史游标可能已经前移;保留一页比回滚游标更直接。
|
||||
|
||||
**运行中的 Turn 禁止加载。** 长回复期间仍需阅读历史,运行状态不能代替当前界面的消费条件。
|
||||
|
||||
**独立 IO 预取唤醒与 React 同步提交控制。** 复用已有采样和发布路径,接受采样延迟;没有具体失败证据时不增加提交调度机制。
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- 请求、暂存与消费期间不重复取页;尾部流式继续更新。
|
||||
- 自动退回按钮后不会被普通事件恢复;触摸、内层滚动和手动点击保留约定的续接。
|
||||
- 静止和顶部前插保持旧内容位置;普通自动惯性消费不新增 JS 外层滚动写入。
|
||||
- 页面失败、无进展、到头、重连和显式导航均有明确结果,不循环请求或消费过期页。
|
||||
- 控件各状态同高;既有折叠驱动保持行为,真实浏览器验收区分当前构建、输入模拟和未覆盖情况。
|
||||
|
||||
## Risks
|
||||
|
||||
一次补偿恢复采集的屏幕位置,不估算读者速度。采集到应用之间的输入或浏览器移动可能影响最终运动,因此需要验证实际浏览器和输入序列。共享公式不叠加可能已包含原生锚定的滚动位置增量。见[技术风险](2026-11-06-chat-history-consumption-details.zh.md#risks)。
|
||||
|
||||
原生锚定适用性不能只由 CSS 支持检查保证。首次尺寸观察前不会自动预取;500ms 采样可能使高速滚动先到顶部。前插后的同步几何读取是一次补偿的成本,不能仅凭调用位置保证没有额外布局。没有可用锚点的手动消费可能移动可见内容。手动消费也可能在已有触摸手势进行中建立持续保位,约束该手势或其惯性,直到新的阅读输入释放锚点;见[手动分页限制](../../../../packages/client/ui-chat/README.zh.md#known-limitations-and-deferred-work)。
|
||||
|
||||
审计与测量尚在进行,本记录保持 proposed。此前 Chrome/Safari 手工反馈和不同迭代的 headless 结果不等同于最终源码的全矩阵通过;真实滚动条拖动、同页面重连、多平台输入和异常清理分支应按实际证据单独报告。
|
||||
@@ -6,7 +6,7 @@ import { access, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import type { Browser, Page, Route } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import type { StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
@@ -367,13 +367,71 @@ async function flingTranscript(page: Page, deltaY: number): Promise<void> {
|
||||
await nextPaint(page)
|
||||
}
|
||||
|
||||
async function wheelToHistoryStart(page: Page): Promise<void> {
|
||||
for (let attempt = 0; attempt < 12; attempt += 1) {
|
||||
if ((await scrollGeometry(page)).scrollTop <= 1) break
|
||||
await wheelTranscript(page, -2_400)
|
||||
/**
|
||||
* Hold paging responses so auto-prefetch cannot publish a page mid-gesture.
|
||||
* @param page - Chat page under test.
|
||||
* @returns an idempotent release that resumes held responses and awaits them.
|
||||
*/
|
||||
async function holdHistoryPages(page: Page): Promise<() => Promise<void>> {
|
||||
const gate = Promise.withResolvers<undefined>()
|
||||
const pending = new Set<Promise<void>>()
|
||||
const hold = (route: Route): Promise<void> => {
|
||||
const work = gate.promise.then(() => route.continue())
|
||||
pending.add(work)
|
||||
return work.finally(() => { pending.delete(work) })
|
||||
}
|
||||
await expect.poll(async () => (await scrollGeometry(page)).scrollTop, { timeout: 10_000 })
|
||||
.toBeLessThanOrEqual(1)
|
||||
await page.route('**/api/session/page', hold)
|
||||
let released = false
|
||||
return async () => {
|
||||
if (released) return
|
||||
released = true
|
||||
gate.resolve(undefined)
|
||||
// Resume held requests before removing the handler: unregistering with a
|
||||
// hold still pending lets Playwright handle that route itself, after which
|
||||
// the delayed continue() fails with "Route is already handled".
|
||||
while (pending.size > 0) await Promise.all([...pending])
|
||||
await page.unroute('**/api/session/page', hold)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait until a released page has landed and the history control stopped changing.
|
||||
* @param page - Chat page under test.
|
||||
* @returns the settled rendered row count.
|
||||
*/
|
||||
async function settleHistoryPaging(page: Page): Promise<number> {
|
||||
let rows = -1
|
||||
await expect.poll(async () => {
|
||||
if (await page.locator('[data-chat-history]').getAttribute('data-chat-history') === 'loading') return false
|
||||
const current = await loadedFlowRows(page)
|
||||
const stable = current === rows
|
||||
rows = current
|
||||
return stable
|
||||
}, { timeout: 30_000 }).toBe(true)
|
||||
await nextPaint(page)
|
||||
return rows
|
||||
}
|
||||
|
||||
/**
|
||||
* Wheel back to the start of the loaded window.
|
||||
* @param page - Chat page under test.
|
||||
* @param options - holdPaging keeps auto-prefetch from publishing a page that re-anchors the viewport mid-scroll.
|
||||
*/
|
||||
async function wheelToHistoryStart(page: Page, options: { holdPaging?: boolean } = {}): Promise<void> {
|
||||
const release = options.holdPaging === true ? await holdHistoryPages(page) : undefined
|
||||
try {
|
||||
for (let attempt = 0; attempt < 12; attempt += 1) {
|
||||
if ((await scrollGeometry(page)).scrollTop <= 1) break
|
||||
await wheelTranscript(page, -2_400)
|
||||
}
|
||||
await expect.poll(async () => (await scrollGeometry(page)).scrollTop, { timeout: 10_000 })
|
||||
.toBeLessThanOrEqual(1)
|
||||
} finally {
|
||||
await release?.()
|
||||
}
|
||||
// A held page is released at the history edge, where the client publishes it
|
||||
// as soon as it arrives; settle that prepend before the caller measures.
|
||||
if (options.holdPaging === true) await settleHistoryPaging(page)
|
||||
}
|
||||
|
||||
async function wheelUntilMounted(page: Page, selector: string, deltaY: number): Promise<void> {
|
||||
@@ -476,23 +534,32 @@ async function expectMarkerAboveComposer(page: Page, marker: string): Promise<vo
|
||||
expect(geometry.rowBottom).toBeLessThanOrEqual(geometry.composerTop + GEOMETRY_TOLERANCE)
|
||||
}
|
||||
|
||||
async function loadEarlierWithAnchor(page: Page): Promise<void> {
|
||||
await wheelToHistoryStart(page)
|
||||
const older = page.getByRole('button', { name: 'Load earlier', exact: true })
|
||||
const loading = page.getByRole('button', { name: 'Loading…', exact: true })
|
||||
await older.waitFor({ timeout: 10_000 })
|
||||
const anchor = await visibleFlowAnchor(page)
|
||||
const before = await loadedFlowRows(page)
|
||||
await older.click()
|
||||
await expect.poll(async () => (
|
||||
await loadedFlowRows(page) > before && await loading.count() === 0
|
||||
), { timeout: 30_000 }).toBe(true)
|
||||
await nextPaint(page)
|
||||
if (await page.getByRole('button', { name: 'Load earlier', exact: true }).count() === 0) {
|
||||
expect(await page.locator('[data-turn-process][aria-expanded="false"]').count()).toBeGreaterThan(0)
|
||||
return
|
||||
async function loadEarlierWithAnchor(page: Page): Promise<boolean> {
|
||||
// Auto-prefetch may start while scrolling to the button. Hold its response until the reading anchor is captured.
|
||||
const release = await holdHistoryPages(page)
|
||||
try {
|
||||
await wheelToHistoryStart(page)
|
||||
const history = page.locator('[data-chat-history]')
|
||||
const older = history.getByRole('button')
|
||||
await older.waitFor({ timeout: 10_000 })
|
||||
if (await older.isDisabled()) return false
|
||||
const anchor = await visibleFlowAnchor(page)
|
||||
const before = await loadedFlowRows(page)
|
||||
await older.click()
|
||||
await release()
|
||||
await expect.poll(async () => (
|
||||
await loadedFlowRows(page) > before && await history.getAttribute('data-chat-history') === 'idle'
|
||||
), { timeout: 30_000 }).toBe(true)
|
||||
await nextPaint(page)
|
||||
if (await older.isDisabled()) {
|
||||
expect(await page.locator('[data-turn-process][aria-expanded="false"]').count()).toBeGreaterThan(0)
|
||||
return true
|
||||
}
|
||||
await expectSameFlowTop(page, anchor)
|
||||
return true
|
||||
} finally {
|
||||
await release()
|
||||
}
|
||||
await expectSameFlowTop(page, anchor)
|
||||
}
|
||||
|
||||
async function fileExists(path: string): Promise<boolean> {
|
||||
@@ -1300,6 +1367,8 @@ describe('web e2e: long Chat scroll contract', () => {
|
||||
|
||||
let releaseHistory = (): void => {}
|
||||
let held = false
|
||||
let historyRequests = 0
|
||||
const heldContinued = Promise.withResolvers<undefined>()
|
||||
let releaseGate: (() => void) | undefined
|
||||
const gate = new Promise<void>((resolve) => { releaseGate = resolve })
|
||||
releaseHistory = () => { releaseGate?.() }
|
||||
@@ -1308,12 +1377,17 @@ describe('web e2e: long Chat scroll contract', () => {
|
||||
method?: string
|
||||
payload?: { args?: { request?: { beforeSeq?: number } } }
|
||||
}
|
||||
if (!held && request.method === 'session/page'
|
||||
&& request.payload?.args?.request?.beforeSeq !== undefined) {
|
||||
const historyRequest = request.method === 'session/page' && request.payload?.args?.request?.beforeSeq !== undefined
|
||||
if (historyRequest) historyRequests += 1
|
||||
if (!held && historyRequest) {
|
||||
held = true
|
||||
await gate
|
||||
}
|
||||
await route.continue()
|
||||
try {
|
||||
await route.continue()
|
||||
} finally {
|
||||
heldContinued.resolve(undefined)
|
||||
}
|
||||
} else await route.continue()
|
||||
})
|
||||
|
||||
const settled = world.scaffold.whenTurnSettled(60_000)
|
||||
@@ -1325,8 +1399,12 @@ describe('web e2e: long Chat scroll contract', () => {
|
||||
await world.page.getByText(LIVE_TEXT_FIRST, { exact: false }).last().waitFor({ timeout: 15_000 })
|
||||
await wheelToHistoryStart(world.page)
|
||||
const beforeRows = await loadedFlowRows(world.page)
|
||||
await world.page.getByRole('button', { name: 'Load earlier', exact: true }).click()
|
||||
await expect.poll(() => held, { timeout: 10_000 }).toBe(true)
|
||||
const history = world.page.locator('[data-chat-history]')
|
||||
await expect.poll(() => history.getAttribute('data-chat-history')).toBe('loading')
|
||||
// The upward gesture already requested the page. A click joins that request even though its label is loading.
|
||||
await history.getByRole('button').click()
|
||||
expect(historyRequests).toBe(1)
|
||||
|
||||
await wheelTranscript(world.page, 420)
|
||||
const readerAnchor = await visibleFlowAnchor(world.page)
|
||||
@@ -1344,6 +1422,9 @@ describe('web e2e: long Chat scroll contract', () => {
|
||||
} finally {
|
||||
releaseText()
|
||||
releaseHistory()
|
||||
if (held) await heldContinued.promise
|
||||
// Release both producers before disposing the Session even if an assertion above fails.
|
||||
await settled
|
||||
}
|
||||
|
||||
await settled
|
||||
@@ -1353,10 +1434,10 @@ describe('web e2e: long Chat scroll contract', () => {
|
||||
|
||||
let additionalPages = 0
|
||||
while (additionalPages < 8) {
|
||||
await wheelToHistoryStart(world.page)
|
||||
if (await world.page.getByRole('button', { name: 'Load earlier', exact: true }).count() === 0) break
|
||||
await loadEarlierWithAnchor(world.page)
|
||||
additionalPages += 1
|
||||
const before = await loadedFlowRows(world.page)
|
||||
const consumed = await loadEarlierWithAnchor(world.page)
|
||||
if (consumed || await loadedFlowRows(world.page) > before) additionalPages += 1
|
||||
if (await world.page.locator('[data-chat-history]').getByRole('button').isDisabled()) break
|
||||
}
|
||||
expect(additionalPages).toBeGreaterThan(0)
|
||||
// The whole log is loaded: turn 1's unique marker renders in the
|
||||
@@ -1364,7 +1445,9 @@ describe('web e2e: long Chat scroll contract', () => {
|
||||
// page remains.
|
||||
expect(await world.page.locator('[data-conversation-scroll]')
|
||||
.getByText(HISTORY_FIXTURE.markers.user(1), { exact: false }).count()).toBe(1)
|
||||
expect(await world.page.getByRole('button', { name: 'Load earlier', exact: true }).count()).toBe(0)
|
||||
const history = world.page.locator('[data-chat-history]')
|
||||
expect(await history.getAttribute('data-chat-history')).toBe('idle')
|
||||
expect(await history.getByRole('button', { name: 'Start of conversation', exact: true }).isDisabled()).toBe(true)
|
||||
assertClean(world)
|
||||
})
|
||||
}, 180_000)
|
||||
@@ -1669,9 +1752,33 @@ describe('web e2e: long Chat scroll contract', () => {
|
||||
await liveRow.click()
|
||||
await expect.poll(() => liveRow.getAttribute('aria-expanded'), { timeout: 10_000 }).toBe('true')
|
||||
await expectSameFlowTop(world.page, toolAnchor)
|
||||
await wheelToHistoryStart(world.page)
|
||||
await world.page.getByRole('button', { name: 'Back to bottom', exact: true }).click()
|
||||
await expectBottom(world.page)
|
||||
// This cycle tests disclosure retention, not pagination: keep auto-prefetch from moving its scroll-away endpoint.
|
||||
const historyGate = Promise.withResolvers<undefined>()
|
||||
const pendingHistory = new Set<Promise<void>>()
|
||||
let historyHeld = false
|
||||
const holdHistory = (route: Route): Promise<void> => {
|
||||
historyHeld = true
|
||||
const work = historyGate.promise.then(() => route.continue())
|
||||
pendingHistory.add(work)
|
||||
return work.finally(() => { pendingHistory.delete(work) })
|
||||
}
|
||||
await world.page.route('**/api/session/page', holdHistory, { times: 1 })
|
||||
try {
|
||||
const beforeRows = await loadedFlowRows(world.page)
|
||||
await wheelToHistoryStart(world.page)
|
||||
await expect.poll(() => historyHeld, { timeout: 10_000 }).toBe(true)
|
||||
expect(await loadedFlowRows(world.page)).toBe(beforeRows)
|
||||
await world.page.getByRole('button', { name: 'Back to bottom', exact: true }).click()
|
||||
await expectBottom(world.page)
|
||||
} finally {
|
||||
historyGate.resolve(undefined)
|
||||
await Promise.all([...pendingHistory])
|
||||
await world.page.unroute('**/api/session/page', holdHistory)
|
||||
}
|
||||
await expect.poll(async () => {
|
||||
const state = await world.page.locator('[data-chat-history]').getAttribute('data-chat-history')
|
||||
return state === 'pendingConsumption' || state === 'idle'
|
||||
}, { timeout: 10_000 }).toBe(true)
|
||||
await wheelUntilMounted(world.page, liveRowSelector, -1_100)
|
||||
const restoredRow = world.page.locator(liveRowSelector)
|
||||
await restoredRow.waitFor({ timeout: 10_000 })
|
||||
@@ -1696,7 +1803,7 @@ describe('web e2e: long Chat scroll contract', () => {
|
||||
)
|
||||
await loadEarlierWithAnchor(world.page)
|
||||
await loadEarlierWithAnchor(world.page)
|
||||
await wheelToHistoryStart(world.page)
|
||||
await wheelToHistoryStart(world.page, { holdPaging: true })
|
||||
await wheelTranscript(world.page, 1_300)
|
||||
const sessionAnchor = await visibleFlowAnchor(world.page)
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
- text: Ongoing Goal guard rapid clear clicks
|
||||
- button "Pause goal"
|
||||
- button "Edit goal"
|
||||
- button "Clear goal"
|
||||
- button "Pause goal"
|
||||
@@ -1,4 +1,4 @@
|
||||
- text: Blocked Goal retry with the edited plan
|
||||
- button "Resume goal"
|
||||
- button "Edit goal"
|
||||
- button "Clear goal"
|
||||
- button "Resume goal"
|
||||
@@ -1,4 +1,4 @@
|
||||
- text: Inactive Goal guard rapid clear clicks
|
||||
- button "Resume goal"
|
||||
- button "Edit goal"
|
||||
- button "Clear goal"
|
||||
- button "Resume goal"
|
||||
@@ -18,7 +18,12 @@
|
||||
- button "Split" [disabled]
|
||||
- text: {{cwd}}/workspace
|
||||
- button "Reload"
|
||||
- button "Search files" [expanded]
|
||||
- complementary "Workspace files":
|
||||
- button "Search settings"
|
||||
- searchbox "Search files"
|
||||
- button "Show hidden entries"
|
||||
- paragraph
|
||||
- list:
|
||||
- listitem:
|
||||
- button "t6-refresh.txt"
|
||||
@@ -21,6 +21,11 @@
|
||||
- button "Split" [disabled]
|
||||
- text: {{cwd}}/workspace
|
||||
- button "Reload"
|
||||
- button "Search files" [expanded]
|
||||
- complementary "Workspace files":
|
||||
- button "Search settings"
|
||||
- searchbox "Search files"
|
||||
- button "Show hidden entries"
|
||||
- paragraph
|
||||
- list:
|
||||
- listitem: Empty directory
|
||||
@@ -1,7 +1,9 @@
|
||||
- text: {{cwd}}/keyboard-project
|
||||
- button "Reload"
|
||||
- button "Search files"
|
||||
- button "Open in Finder"
|
||||
- button "More ways to open"
|
||||
- complementary "Workspace files":
|
||||
- paragraph
|
||||
- list:
|
||||
- listitem: Empty directory
|
||||
@@ -35,6 +35,7 @@
|
||||
- button "Jump to turn 26"
|
||||
- button "Jump to turn 27"
|
||||
- button "Jump to turn 28"
|
||||
- button "Start of conversation" [disabled]
|
||||
- text: m1 7/25 {{clock}}
|
||||
- button "Copy"
|
||||
- status: Completed
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
* workspace and the real dock, across docked and floating panes and a
|
||||
* phone-width viewport. The package README owns the behavior asserted here.
|
||||
*/
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { chromium, type Locator, type Page } from 'playwright'
|
||||
import { expect, it, onTestFailed, onTestFinished } from 'vitest'
|
||||
import { createLaunchEnvironmentSnapshot, type LaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment'
|
||||
import { launchWebScaffold, watchConsole } from './scaffold.ts'
|
||||
import { launchWebScaffold, seedSession, watchConsole } from './scaffold.ts'
|
||||
import { connectFreshWorkspace, saveFailureShot } from './support.ts'
|
||||
|
||||
/** Press at the centre of `from`, travel in steps, and release at `to`. */
|
||||
@@ -290,11 +290,12 @@ it('browses workspace files in tabs that share one pane tree', async () => {
|
||||
await page.setViewportSize({ width: 1680, height: 1000 })
|
||||
await layout.evaluate((node) => { node.textContent = '[data-sidebar-right-panel] { width: 460px !important; }' })
|
||||
|
||||
// The workspace command shows the pane's tree, moves the keyboard into it, and keeps the file.
|
||||
// The workspace command shows the pane's tree, moves the keyboard to its search field, and keeps the file.
|
||||
await input.focus()
|
||||
await page.keyboard.press('Control+Alt+p')
|
||||
await expect.poll(() => toggle.getAttribute('aria-expanded')).toBe('true')
|
||||
await expect.poll(() => current.evaluate(node => node === document.activeElement)).toBe(true)
|
||||
await expect.poll(() => navigator.locator('[data-files-search]').evaluate(node => node === document.activeElement)).toBe(true)
|
||||
await expect.poll(() => current.textContent()).toBe('a.txt')
|
||||
await expect.poll(() => path.textContent()).toContain('a.txt')
|
||||
expect(await tabs.count()).toBe(tabCount + 1)
|
||||
await layout.evaluate((node) => { node.textContent = '[data-sidebar-right-panel] { width: 960px !important; }' })
|
||||
@@ -537,3 +538,156 @@ it('moves a file between the docked column and a floating panel from the file me
|
||||
await expect.poll(() => floatBody.evaluate(node => node.scrollTop)).toBe(600)
|
||||
expect(errors).toEqual({ warnings: [], pageErrors: [] })
|
||||
})
|
||||
|
||||
it('searches the whole workspace, respects .gitignore on request, saves exclusion rules, restores the default list, and shows hidden entries', async () => {
|
||||
const scaffold = await launchWebScaffold()
|
||||
onTestFinished(() => scaffold.close())
|
||||
// The scaffold's workspace is a private temporary directory that its close removes.
|
||||
const root = scaffold.workspaceCwd
|
||||
await mkdir(join(root, 'vendor', 'deep', 'nested'), { recursive: true })
|
||||
await writeFile(join(root, 'vendor', 'deep', 'nested', 'widget-copy.ts'), 'export const copy = 3\n')
|
||||
await mkdir(join(root, 'ignored'))
|
||||
await writeFile(join(root, 'ignored', 'widget-ignored.ts'), 'export const ignored = 1\n')
|
||||
await mkdir(join(root, '.git'), { recursive: true })
|
||||
await writeFile(join(root, '.gitignore'), 'ignored/\n')
|
||||
await writeFile(join(root, '.hidden-widget.txt'), 'hidden\n')
|
||||
await writeFile(join(root, 'README.md'), '# Readme\n')
|
||||
const seed = fileURLToPath(new URL('../../../snapshots/web/markdown-file-links/session.v3.jsonl', import.meta.url))
|
||||
await seedSession(scaffold, await readFile(seed, 'utf8'), 'file-search')
|
||||
const browser = await chromium.launch()
|
||||
onTestFinished(() => browser.close())
|
||||
const page = await browser.newPage({ locale: 'en-US', viewport: { width: 1680, height: 1000 } })
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-file-search'))
|
||||
const errors = watchConsole(page)
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
await page.locator('[role="treeitem"]').first().click()
|
||||
await page.locator('[role="treeitem"]').nth(1).click()
|
||||
await page.getByRole('button', { name: 'src/example.txt:24–30', exact: true }).waitFor()
|
||||
|
||||
await page.locator('[data-sidebar-right-expand]').click()
|
||||
const column = page.locator('[data-rightbar-col]')
|
||||
await page.addStyleTag({ content: '[data-sidebar-right-panel] { width: 960px !important; }' })
|
||||
await column.locator('[data-sidebar-right-guide-entry="files"]').click()
|
||||
const navigator = column.locator('[data-files-navigator]:visible')
|
||||
const field = navigator.locator('[data-files-search]')
|
||||
const body = navigator.locator('[data-files-body]')
|
||||
const status = navigator.locator('[data-files-search-status]')
|
||||
const eye = navigator.locator('[data-files-show-hidden]')
|
||||
const row = (relative: string): Locator => body.locator(`[data-files-path="${root}/${relative}"]`)
|
||||
await row('README.md').waitFor()
|
||||
const searchToggle = column.locator('[data-files-search-toggle]')
|
||||
await expect.poll(() => searchToggle.getAttribute('aria-expanded')).toBe('false')
|
||||
await expect.poll(() => field.count()).toBe(0)
|
||||
await searchToggle.click()
|
||||
await expect.poll(() => searchToggle.getAttribute('aria-expanded')).toBe('true')
|
||||
await expect.poll(() => field.evaluate(node => node === document.activeElement)).toBe(true)
|
||||
|
||||
// The eye starts closed: the tree leaves out entries whose name starts with `.`.
|
||||
expect(await eye.getAttribute('aria-pressed')).toBe('false')
|
||||
expect(await row('.hidden-widget.txt').count()).toBe(0)
|
||||
|
||||
// The search walks the whole workspace: a file three directories deep under one the tree never opened matches by name,
|
||||
// ignoring case, with the match highlighted; the hidden file and the one `.gitignore` ignores are left out.
|
||||
const deep = 'vendor/deep/nested/widget-copy.ts'
|
||||
await field.fill('WIDGET')
|
||||
await expect.poll(() => status.textContent()).toBe('Results: 1')
|
||||
expect(await body.getAttribute('data-files-view')).toBe('search')
|
||||
expect(await row(deep).locator('mark').textContent()).toBe('widget')
|
||||
expect(await row('vendor/deep/nested').count()).toBe(1)
|
||||
expect(await row('.hidden-widget.txt').count()).toBe(0)
|
||||
expect(await row('ignored/widget-ignored.ts').count()).toBe(0)
|
||||
expect(await row('README.md').count()).toBe(0)
|
||||
expect(await body.locator('[data-files-row="search-incomplete"]').count()).toBe(0)
|
||||
expect(await body.locator('[data-files-row="search-truncated"]').count()).toBe(0)
|
||||
await searchToggle.click()
|
||||
await expect.poll(() => searchToggle.getAttribute('aria-expanded')).toBe('false')
|
||||
await expect.poll(() => field.count()).toBe(0)
|
||||
await expect.poll(() => searchToggle.evaluate(node => node === document.activeElement)).toBe(true)
|
||||
expect(await status.textContent()).toBe('Results: 1')
|
||||
await row(deep).waitFor()
|
||||
await searchToggle.click()
|
||||
await expect.poll(() => field.inputValue()).toBe('WIDGET')
|
||||
await expect.poll(() => field.evaluate(node => node === document.activeElement)).toBe(true)
|
||||
// A query with `/` matches workspace-relative paths.
|
||||
await field.fill('nested/widget')
|
||||
await expect.poll(() => status.textContent()).toBe('Results: 1')
|
||||
await row(deep).waitFor()
|
||||
|
||||
// Clearing the `.gitignore` choice in the settings searches again at once, and the ignored file matches.
|
||||
await field.fill('widget')
|
||||
await expect.poll(() => status.textContent()).toBe('Results: 1')
|
||||
await navigator.locator('[data-files-search-settings-toggle]').click()
|
||||
const gitignore = navigator.locator('[data-files-search-settings]').getByRole('checkbox', { name: 'Respect ignore files (.gitignore, etc.)', exact: true })
|
||||
expect(await gitignore.isChecked()).toBe(true)
|
||||
await gitignore.uncheck()
|
||||
await page.keyboard.press('Escape')
|
||||
await expect.poll(() => status.textContent()).toBe('Results: 2')
|
||||
await row('ignored/widget-ignored.ts').waitFor()
|
||||
await navigator.locator('[data-files-search-settings-toggle]').click()
|
||||
await gitignore.check()
|
||||
await page.keyboard.press('Escape')
|
||||
await expect.poll(() => status.textContent()).toBe('Results: 1')
|
||||
// Clearing the text gives the listed tree back, never expanded below the root.
|
||||
await navigator.getByRole('button', { name: 'Clear search', exact: true }).click()
|
||||
await expect.poll(() => body.getAttribute('data-files-view')).toBe('tree')
|
||||
expect(await row('vendor/deep').count()).toBe(0)
|
||||
expect(await field.inputValue()).toBe('')
|
||||
expect(await status.count()).toBe(1)
|
||||
expect(await status.textContent()).toBe('')
|
||||
await row('README.md').waitFor()
|
||||
|
||||
// The magnifier opens the settings; an added path is saved when Escape closes the panel.
|
||||
const trigger = navigator.locator('[data-files-search-settings-toggle]')
|
||||
const settings = navigator.locator('[data-files-search-settings]')
|
||||
const restore = settings.locator('[data-files-settings-restore]')
|
||||
await trigger.click()
|
||||
await settings.waitFor()
|
||||
expect(await settings.getByText('Excluded files and directories', { exact: true }).count()).toBe(1)
|
||||
for (const name of ['.DS_Store', 'Thumbs.db', '$RECYCLE.BIN', 'System Volume Information']) {
|
||||
expect(await settings.locator(`[data-files-rule="${name}"]`).count()).toBe(1)
|
||||
}
|
||||
// The list is the default list, so there is nothing to restore.
|
||||
expect(await restore.count()).toBe(0)
|
||||
const add = settings.getByRole('textbox', { name: 'Add file or directory', exact: true })
|
||||
await expect.poll(() => add.evaluate(node => node === document.activeElement)).toBe(true)
|
||||
await add.fill('vendor')
|
||||
await add.press('Enter')
|
||||
await settings.locator('[data-files-rule="vendor"]').waitFor()
|
||||
await page.keyboard.press('Escape')
|
||||
await expect.poll(() => settings.count()).toBe(0)
|
||||
expect(await trigger.evaluate(node => node === document.activeElement)).toBe(true)
|
||||
const stored = (): Promise<string | null> => page.evaluate(() => localStorage.getItem('dsh.sidebar-files.workspace-search.v1'))
|
||||
expect(await stored()).toContain('"vendor"')
|
||||
// Exclusion rules narrow only the search; the tree still lists the directory.
|
||||
await field.fill('widget')
|
||||
await expect.poll(() => status.textContent()).toBe('Results: 0')
|
||||
await field.press('Escape')
|
||||
await expect.poll(() => body.getAttribute('data-files-view')).toBe('tree')
|
||||
expect(await row('vendor').count()).toBe(1)
|
||||
|
||||
// Closing with the magnifier saves an unchecked rule.
|
||||
await trigger.click()
|
||||
const excludeVendor = settings.getByRole('checkbox', { name: 'Exclude vendor', exact: true })
|
||||
await excludeVendor.uncheck()
|
||||
await trigger.click()
|
||||
await expect.poll(() => settings.count()).toBe(0)
|
||||
await trigger.click()
|
||||
expect(await excludeVendor.isChecked()).toBe(false)
|
||||
|
||||
// The restore control, shown while the list differs from the default list, resets it to that list.
|
||||
await restore.click()
|
||||
await expect.poll(() => settings.locator('[data-files-rule="vendor"]').count()).toBe(0)
|
||||
expect(await restore.count()).toBe(0)
|
||||
await page.keyboard.press('Escape')
|
||||
await expect.poll(() => settings.count()).toBe(0)
|
||||
// Back to the defaults, the workspace's stored entry goes.
|
||||
expect(await stored()).not.toContain('"vendor"')
|
||||
|
||||
// The open eye shows hidden entries in the tree.
|
||||
await eye.click()
|
||||
await expect.poll(() => eye.getAttribute('aria-pressed')).toBe('true')
|
||||
await row('.hidden-widget.txt').waitFor()
|
||||
await eye.click()
|
||||
await expect.poll(() => row('.hidden-widget.txt').count()).toBe(0)
|
||||
expect(errors).toEqual({ warnings: [], pageErrors: [] })
|
||||
}, 120_000)
|
||||
@@ -43,11 +43,14 @@ const run = promisify(execFile)
|
||||
* 7 with one failed and one passed check run, one unresolved thread, and a
|
||||
* required review, and pull request 9, an approved draft whose checks passed.
|
||||
* `gh pr ready 9` and the REST merge of pull request 9 change what later
|
||||
* queries report, through `state.json`. `gh pr diff N` prints `pr-N.diff`, or
|
||||
* fails with `pr-N.refused` on standard error when that file exists, and the raw
|
||||
* contents API serves `contents.json`, keyed by `path@commit`. The commit listing of
|
||||
* pull request 7 serves `commits.json`, oldest first, and the compare API serves
|
||||
* `compare.json`, keyed by `base...head`. Every invocation is appended to `calls.jsonl`.
|
||||
* queries report, through `state.json`, which can also move the head of pull
|
||||
* request 9. The compare API serves `compare.json`, keyed by `base...head`, and
|
||||
* otherwise, between a pull request's recorded base and its head, prints
|
||||
* `pr-N.diff`, or, when `pr-N.refused` exists, fails with that JSON error body
|
||||
* and the matching diagnostic as GitHub's HTTP 406 refusal. The raw contents API
|
||||
* serves `contents.json`, keyed by `path@commit`, and the commit listing of pull
|
||||
* request 7 serves `commits.json`, oldest first. Every invocation is appended to
|
||||
* `calls.jsonl`.
|
||||
*/
|
||||
const GH = `#!/usr/bin/env node
|
||||
import { appendFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
@@ -88,23 +91,29 @@ if (args[0] === 'api' && args[4] === 'POST' && ['repos/octo/demo/pulls/7/comment
|
||||
process.stdout.write(JSON.stringify({ html_url: 'https://github.com/octo/demo/pull/7#pullrequestreview-1' }))
|
||||
process.exit(0)
|
||||
}
|
||||
if (args[0] === 'pr' && args[1] === 'diff') {
|
||||
const refused = join(here, 'pr-' + args[2] + '.refused')
|
||||
if (existsSync(refused)) {
|
||||
process.stderr.write(readFileSync(refused, 'utf8'))
|
||||
process.exit(1)
|
||||
}
|
||||
await new Promise(resolve => process.stdout.write(readFileSync(join(here, 'pr-' + args[2] + '.diff'), 'utf8'), resolve))
|
||||
process.exit(0)
|
||||
}
|
||||
const BASE = 'e'.repeat(40)
|
||||
const heads = () => ({ 7: 'a'.repeat(40), 9: state.head ?? 'b'.repeat(40) })
|
||||
if (args[0] === 'api' && args.includes('Accept: application/vnd.github.diff')) {
|
||||
const range = args.at(-1).slice('repos/octo/demo/compare/'.length)
|
||||
const patch = existsSync(join(here, 'compare.json')) ? JSON.parse(readFileSync(join(here, 'compare.json'), 'utf8'))[range] : undefined
|
||||
if (patch === undefined) {
|
||||
const [, range] = args.at(-1).split('/compare/')
|
||||
const chosen = existsSync(join(here, 'compare.json')) ? JSON.parse(readFileSync(join(here, 'compare.json'), 'utf8'))[range] : undefined
|
||||
if (chosen !== undefined) {
|
||||
process.stdout.write(chosen)
|
||||
process.exit(0)
|
||||
}
|
||||
const [base, head] = range.split('...')
|
||||
const number = Object.entries(heads()).find(([, oid]) => oid === head)?.[0]
|
||||
if (base !== BASE || number === undefined) {
|
||||
process.stderr.write('gh: Not Found (HTTP 404)\\n')
|
||||
process.exit(1)
|
||||
}
|
||||
process.stdout.write(patch)
|
||||
const refused = join(here, 'pr-' + number + '.refused')
|
||||
if (existsSync(refused)) {
|
||||
const message = readFileSync(refused, 'utf8')
|
||||
process.stdout.write(JSON.stringify({ message, errors: [{ resource: 'PullRequest', field: 'diff', code: 'too_large' }], status: '406' }))
|
||||
process.stderr.write('gh: ' + message + ' (HTTP 406)\\n')
|
||||
process.exit(1)
|
||||
}
|
||||
await new Promise(resolve => process.stdout.write(readFileSync(join(here, 'pr-' + number + '.diff'), 'utf8'), resolve))
|
||||
process.exit(0)
|
||||
}
|
||||
if (args[0] === 'api' && args[3] === '-H' && args[4] === 'Accept: application/vnd.github.raw+json') {
|
||||
@@ -131,20 +140,21 @@ if (args[0] === 'api' && args[1] === 'graphql') {
|
||||
})
|
||||
const pulls = {
|
||||
7: {
|
||||
number: 7, url: 'https://github.com/octo/demo/pull/7', title: 'Add the pull request panel', state: 'OPEN', isDraft: false,
|
||||
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),
|
||||
baseRefName: 'main', additions: 128, deletions: 14, mergeable: 'MERGEABLE', mergeStateStatus: 'BLOCKED', reviewDecision: 'REVIEW_REQUIRED',
|
||||
reviewThreads: { nodes: [{ isResolved: false }] },
|
||||
reviewThreads: { pageInfo: { hasNextPage: false, endCursor: null }, nodes: [{ isResolved: false }] },
|
||||
commits: { nodes: [{ commit: { statusCheckRollup: { id: 'rollup-7', state: 'FAILURE', contexts: {
|
||||
pageInfo: { hasNextPage: true, endCursor: 'checks-100' },
|
||||
nodes: Array.from({ length: 100 }, (_, i) => check('lint', 'FAILURE', '2026-10-08T00:00:42Z', i + 1)),
|
||||
} } } }] },
|
||||
},
|
||||
9: {
|
||||
number: 9, url: 'https://github.com/octo/demo/pull/9', title: 'Add the merge button', state: state.mergedAt === null ? 'OPEN' : 'MERGED',
|
||||
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: state.head ?? 'b'.repeat(40), baseRefName: 'main', additions: 30, deletions: 2, mergeable: 'MERGEABLE',
|
||||
mergeStateStatus: state.ready ? 'CLEAN' : 'BLOCKED', reviewDecision: 'APPROVED', reviewThreads: { nodes: [] },
|
||||
headRefName: 'feat/merge', headRefOid: heads()[9], baseRefName: 'main', 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: {
|
||||
pageInfo: { hasNextPage: false, endCursor: null }, nodes: [check('lint', 'SUCCESS', '2026-10-09T00:00:42Z')],
|
||||
} } } }] },
|
||||
@@ -161,6 +171,11 @@ if (args[0] === 'api' && args[1] === 'graphql') {
|
||||
} } } } }))
|
||||
process.exit(0)
|
||||
}
|
||||
if (query.includes('pullRequest(number: $number)')) {
|
||||
const number = Number(args.find(arg => arg.startsWith('number=')).slice('number='.length))
|
||||
process.stdout.write(JSON.stringify({ data: { repository: { pullRequest: { headRefOid: pulls[number].headRefOid, baseRefOid: BASE } } } }))
|
||||
process.exit(0)
|
||||
}
|
||||
if (query.includes('node(id: $id)')) {
|
||||
process.stdout.write(JSON.stringify({ data: { node: { contexts: {
|
||||
pageInfo: { hasNextPage: false, endCursor: null },
|
||||
@@ -187,13 +202,14 @@ process.exit(1)
|
||||
/** Head commit of pull request 7 in the GitHub CLI double. */
|
||||
const HEAD7 = 'a'.repeat(40)
|
||||
|
||||
/** What gh 2.100 prints for the diff of pull request 9 when GitHub refuses it for having over 300 files. */
|
||||
const PR9_REFUSAL = [
|
||||
'could not find pull request diff: HTTP 406: Sorry, the diff exceeded the maximum number of files (300). Consider using \'List pull '
|
||||
+ 'requests files\' API or locally cloning the repository instead. (https://api.github.com/repos/octo/demo/pulls/9)',
|
||||
'PullRequest.diff too_large',
|
||||
'',
|
||||
].join('\n')
|
||||
/** Patch of pull request 9: one line added to each of 301 files, more than the pull request diff endpoint accepts. */
|
||||
const PR9_DIFF = Array.from({ length: 301 }, (_, index) => [
|
||||
`diff --git a/merge/m${index}.ts b/merge/m${index}.ts`, 'index 1111111..2222222 100644', `--- a/merge/m${index}.ts`, `+++ b/merge/m${index}.ts`,
|
||||
'@@ -1 +1,2 @@', ' export {}', `+export const m${index} = ${index}`, '',
|
||||
].join('\n')).join('')
|
||||
|
||||
/** Message of the double's HTTP 406 refusal, which it sends in the error body format GitHub uses for a diff over its size limit. */
|
||||
const PR9_REFUSAL = 'Sorry, the diff exceeded the size limit.'
|
||||
|
||||
/** Line `n` of `src/panel.ts` before (`old`) or after (`new`) pull request 7, which changes lines 2 and 15 of its 20. */
|
||||
function panelLine(n: number, side: 'old' | 'new'): string {
|
||||
@@ -312,7 +328,7 @@ async function launchPanel(failureShot: string, scenario: 'normal' | 'follow-up'
|
||||
await writeFile(gh, GH)
|
||||
await chmod(gh, 0o755)
|
||||
await writeFile(join(root, 'pr-7.diff'), PR7_DIFF)
|
||||
await writeFile(join(root, 'pr-9.refused'), PR9_REFUSAL)
|
||||
await writeFile(join(root, 'pr-9.diff'), PR9_DIFF)
|
||||
const panelText = `${Array.from({ length: 20 }, (_, index) => panelLine(index + 1, 'new')).join('\n')}\n`
|
||||
await writeFile(join(root, 'contents.json'), JSON.stringify({ [`src/panel.ts@${HEAD7}`]: panelText }))
|
||||
const overlay = join(root, 'forge.patch.yml')
|
||||
@@ -1074,21 +1090,43 @@ it.skipIf(MODE === 'record')('shows the local changes of the Session by scope an
|
||||
expect(await reviewDialog.getByRole('radio', { name: '评论', exact: true }).isChecked()).toBe(true)
|
||||
await page.keyboard.press('Escape')
|
||||
|
||||
// GitHub refuses the diff of pull request 9 as too large, so its view offers the web page instead of a retry.
|
||||
// Pull request 9 changes 301 files, more than the pull request diff endpoint accepts; the compare read shows every file.
|
||||
scaffold.ctx.forgeSession.track(session, DRAFT)
|
||||
await expect.poll(() => header.getAttribute('aria-pressed'), { timeout: 20_000 }).toBe('false')
|
||||
await header.click()
|
||||
const draft = page.locator(`[data-forge-change="${DRAFT}"]`)
|
||||
await draft.getByRole('button', { name: '展开 #9', exact: true }).click()
|
||||
await draft.getByRole('button', { name: '查看 #9 的变更:新增 30 行,删除 2 行', exact: true }).click({ timeout: 20_000 })
|
||||
const refused = page.locator('[data-forge-changes="change"]').filter({ hasText: 'Add the merge button' })
|
||||
await refused.getByRole('status').filter({ hasText: '变更太大,无法在这里显示' }).waitFor({ timeout: 20_000 })
|
||||
await refused.getByRole('button', { name: '在浏览器中查看', exact: true }).waitFor()
|
||||
expect(await refused.getByRole('button', { name: '重试', exact: true }).count()).toBe(0)
|
||||
const wide = page.locator('[data-forge-changes="change"]').filter({ hasText: 'Add the merge button' })
|
||||
await wide.getByRole('region', { name: '全部 301 个文件', exact: true }).waitFor({ timeout: 20_000 })
|
||||
expect(await wide.locator('[data-forge-diff-file]').count()).toBe(301)
|
||||
|
||||
// A push moves the head to a commit whose diff GitHub refuses over its size limit, so a newly opened view offers the web page
|
||||
// instead of a retry.
|
||||
await page.locator('[data-dockkit-tab]').filter({ hasText: '#9 的变更' }).locator('[data-dockkit-tab-close]').click()
|
||||
await expect.poll(() => wide.count()).toBe(0)
|
||||
await writeFile(join(root, 'state.json'), JSON.stringify({ ready: false, mergedAt: null, head: 'd'.repeat(40) }))
|
||||
await writeFile(join(root, 'pr-9.refused'), PR9_REFUSAL)
|
||||
scaffold.ctx.forge.refresh({ repository: { forge: 'github', host: 'github.com', owner: 'octo', name: 'demo' }, number: 9 })
|
||||
await expect.poll(() => scaffold.ctx.forge.latest({ repository: { forge: 'github', host: 'github.com', owner: 'octo', name: 'demo' }, number: 9 }),
|
||||
{ timeout: 20_000 }).toMatchObject({ snapshot: { headSha: 'd'.repeat(40) } })
|
||||
if (await header.getAttribute('aria-pressed') !== 'true') await header.click()
|
||||
const expand = draft.getByRole('button', { name: '展开 #9', exact: true })
|
||||
if (await expand.count() > 0) await expand.click()
|
||||
await draft.getByRole('button', { name: '查看 #9 的变更:新增 30 行,删除 2 行', exact: true }).click({ timeout: 20_000 })
|
||||
await wide.getByRole('status').filter({ hasText: '变更太大,无法在这里显示' }).waitFor({ timeout: 20_000 })
|
||||
await wide.getByRole('button', { name: '在浏览器中查看', exact: true }).waitFor()
|
||||
expect(await wide.getByRole('button', { name: '重试', exact: true }).count()).toBe(0)
|
||||
|
||||
const calls = (await readFile(join(root, 'calls.jsonl'), 'utf8')).trim().split('\n').map(line => JSON.parse(line) as string[])
|
||||
expect(calls).toContainEqual(['pr', 'diff', '7', '--repo', 'github.com/octo/demo', '--color', 'never'])
|
||||
expect(calls).toContainEqual(['pr', 'diff', '9', '--repo', 'github.com/octo/demo', '--color', 'never'])
|
||||
const compared = (head: string) => [
|
||||
'api', '--hostname', 'github.com', '--allow-escape-sequences', '-H', 'Accept: application/vnd.github.diff',
|
||||
`repos/octo/demo/compare/${'e'.repeat(40)}...${head}`,
|
||||
]
|
||||
expect(calls).toContainEqual(compared(HEAD7))
|
||||
expect(calls).toContainEqual(compared('b'.repeat(40)))
|
||||
expect(calls).toContainEqual(compared('d'.repeat(40)))
|
||||
expect(calls.some(call => call[0] === 'pr' && call[1] === 'diff')).toBe(false)
|
||||
expect(calls).toContainEqual([
|
||||
'api', '--hostname', 'github.com', '-H', 'Accept: application/vnd.github.raw+json', `repos/octo/demo/contents/src/panel.ts?ref=${HEAD7}`,
|
||||
])
|
||||
@@ -1747,7 +1785,9 @@ it.skipIf(MODE === 'record')('reviews a change request by layer, hides test file
|
||||
await expect.poll(() => shownPaths(view), { timeout: 20_000 }).toEqual(['docs/schema.json', 'locale/zh.json', 'src/panel.spec.ts', 'package.json'])
|
||||
await expect.poll(() => lens.getByRole('radio').allTextContents()).toEqual(['全部4', '接口1', '实现0', '测试3'])
|
||||
const calls = (await readFile(join(root, 'calls.jsonl'), 'utf8')).split('\n').filter(line => line.length > 0).map(line => JSON.parse(line) as string[])
|
||||
expect(calls.filter(args => args[0] === 'api' && args.includes('Accept: application/vnd.github.diff')).map(args => args.at(-1))).toEqual([
|
||||
// The whole pull request is read from its recorded base; chosen commits are read from the first one's parent.
|
||||
expect(calls.filter(args => args[0] === 'api' && args.includes('Accept: application/vnd.github.diff')).map(args => args.at(-1))
|
||||
.filter(path => path?.includes(`/compare/${'e'.repeat(40)}...`) === false)).toEqual([
|
||||
`repos/octo/demo/compare/${'1'.repeat(40)}...${'2'.repeat(40)}`, `repos/octo/demo/compare/${'1'.repeat(40)}...${HEAD7}`,
|
||||
])
|
||||
await view.getByRole('button', { name: `查看的提交:2222222…${HEAD7.slice(0, 7)}`, exact: true }).click()
|
||||
|
||||
@@ -53,9 +53,9 @@ describe('web e2e: goal bar clear convergence', () => {
|
||||
const bar = page.locator('[data-goal-bar]')
|
||||
await bar.waitFor({ timeout: 10_000 })
|
||||
const pause = bar.getByRole('button', { name: 'Pause goal' })
|
||||
await expect.poll(() => pause.count(), {
|
||||
await expect.poll(() => pause.isEnabled(), {
|
||||
timeout: 10_000,
|
||||
}).toBe(1)
|
||||
}).toBe(true)
|
||||
await pause.hover()
|
||||
const pauseTooltip = page.getByRole('tooltip', { name: 'Pause goal', exact: true })
|
||||
await pauseTooltip.waitFor()
|
||||
@@ -120,7 +120,7 @@ describe('web e2e: goal bar clear convergence', () => {
|
||||
await input.fill('/goal retry after a blocker')
|
||||
await input.press('Enter')
|
||||
const bar = page.locator('[data-goal-bar]')
|
||||
await bar.getByRole('button', { name: 'Pause goal' }).waitFor({ timeout: 10_000 })
|
||||
await expect.poll(() => bar.getByRole('button', { name: 'Pause goal' }).isEnabled(), { timeout: 10_000 }).toBe(true)
|
||||
|
||||
const agents = scaffold.ctx.agents.list()
|
||||
expect(agents).toHaveLength(1)
|
||||
@@ -140,7 +140,7 @@ describe('web e2e: goal bar clear convergence', () => {
|
||||
await compareOrRefreshGolden(BLOCKED_EXPECTED, blocked, MODE)
|
||||
|
||||
await bar.getByRole('button', { name: 'Resume goal' }).click()
|
||||
await bar.getByRole('button', { name: 'Pause goal' }).waitFor({ timeout: 10_000 })
|
||||
await expect.poll(() => bar.getByRole('button', { name: 'Pause goal' }).isEnabled(), { timeout: 10_000 }).toBe(true)
|
||||
expect(scaffold.ctx.goals.get(agent)).toMatchObject({
|
||||
phase: 'active', activation: 'armed', objective: 'retry with the edited plan',
|
||||
})
|
||||
|
||||
@@ -551,7 +551,14 @@ async function bootPreview(site: Site, manifest: ImageManifest, browser: Browser
|
||||
await catalog.getByRole('treeitem', { name: /Continue preview verification/ }).waitFor()
|
||||
await catalog.press('Escape')
|
||||
|
||||
await page.getByRole('button', { name: 'Load earlier', exact: true }).click()
|
||||
// Auto-prefetch may already have published the page, and a pointer click can
|
||||
// land on whatever moved under it; activate the control only while it still
|
||||
// offers a page, then wait for the oldest seeded row.
|
||||
const historyEdge = page.locator('[data-chat-history]')
|
||||
await historyEdge.getByRole('button').evaluate((button) => {
|
||||
if (!(button instanceof HTMLButtonElement)) throw new Error('history control is not a button')
|
||||
if (!button.disabled) button.click()
|
||||
})
|
||||
await page.getByText(SHOWCASE_OLDEST, { exact: true }).waitFor({ timeout: 15_000 })
|
||||
|
||||
await requestLazyChunk(page, site, manifest)
|
||||
|
||||
@@ -371,6 +371,11 @@ describe('web e2e: seeded history renders through cold resume', () => {
|
||||
payload: { args: { request: { maxMessages: 500, turnWindow: { minMessages: 50, minTurns: 2 } } } },
|
||||
})
|
||||
await expect.poll(() => page.getByText(PROMPT, { exact: true }).count(), { timeout: 10_000 }).toBe(1)
|
||||
const history = page.locator('[data-chat-history]')
|
||||
await expect.poll(() => history.getAttribute('data-chat-history'), { timeout: 10_000 }).toBe('idle')
|
||||
await expect.poll(() => history.getByRole('button', { name: 'Start of conversation', exact: true }).isDisabled(), {
|
||||
timeout: 10_000,
|
||||
}).toBe(true)
|
||||
await expect.poll(() => page.getByText('compact', { exact: true }).count(), { timeout: 10_000 }).toBe(1)
|
||||
await expect.poll(() => page.getByText(/^Compacted \d+ history items \(~\d+ tokens\)$/).count(), {
|
||||
timeout: 10_000,
|
||||
|
||||
@@ -120,8 +120,9 @@ it.each([
|
||||
await selectDraft()
|
||||
await deliverPrimary('KeyP')
|
||||
expect(await files.count()).toBe(1)
|
||||
expect(await panel.locator('[data-dockkit-pane]').evaluate(pane => document.activeElement === pane)).toBe(true)
|
||||
expect(await page.evaluate(() => document.getSelection()?.toString())).toBe('Desktop')
|
||||
// Once the files page is shown, the shortcut opens its search bar and the search field takes the keyboard.
|
||||
await expect.poll(() => panel.locator('[data-files-search-toggle]').getAttribute('aria-expanded')).toBe('true')
|
||||
await expect.poll(() => panel.locator('[data-files-search]').evaluate(input => document.activeElement === input)).toBe(true)
|
||||
await composer.click()
|
||||
const pane = panel.locator('[data-dockkit-pane]')
|
||||
await pane.click({ position: { x: 20, y: 2 } })
|
||||
|
||||
@@ -183,7 +183,8 @@ describe.skipIf(process.platform === 'win32')('Web sidebar shortcuts', () => {
|
||||
expect(await paneAppearance()).toEqual([plainPane])
|
||||
await page.keyboard.press(`${primary}+Shift+,`)
|
||||
expect(await panel.locator('[data-dockkit-tab]').filter({ hasText: 'Files' }).count()).toBe(1)
|
||||
expect(await panel.locator('[data-dockkit-pane]').evaluate(pane => document.activeElement === pane)).toBe(true)
|
||||
await expect.poll(() => panel.locator('[data-files-search-toggle]').getAttribute('aria-expanded')).toBe('true')
|
||||
await expect.poll(() => panel.locator('[data-files-search]').evaluate(input => document.activeElement === input)).toBe(true)
|
||||
// Once the reveal's glow has ended, the focused pane draws nothing of its own. file-browser.e2e.ts and the
|
||||
// seat spec own the glow itself, so this waits for no marker rather than requiring the transient one.
|
||||
await expect.poll(() => panel.locator('[data-dockkit-pane][data-sidebar-right-flash]').count()).toBe(0)
|
||||
|
||||
@@ -182,6 +182,10 @@ describe.skipIf(mode === 'record')('web e2e: workspace shortcuts', () => {
|
||||
await files.locator('[data-files-row="empty"]').waitFor()
|
||||
// The directory opener sits in the files page's header, above the tree.
|
||||
const opener = files.locator('[data-open-target="directory"]')
|
||||
const tools = files.locator('[data-files-reload], [data-files-search-toggle], [data-open-target="directory"] button')
|
||||
expect((await tools.evaluateAll(elements => elements.map(element => element.hasAttribute('data-files-reload')
|
||||
? 'reload' : element.hasAttribute('data-files-search-toggle') ? 'search' : 'open'))).slice(0, 3))
|
||||
.toEqual(['reload', 'search', 'open'])
|
||||
await opener.getByRole('button', { name: 'Open in VS Code', exact: true }).click()
|
||||
await expect.poll(() => launched).toEqual([
|
||||
{ app: 'vscode', path: workspace.path }, { app: 'vscode', path: workspace.path },
|
||||
|
||||
@@ -129,6 +129,11 @@ describe('web e2e: whole-session stats survive history paging', () => {
|
||||
// counts, wall times, or token groups.
|
||||
await page.getByRole('button', { name: 'Load earlier' }).click()
|
||||
await expect.poll(() => page.getByText('m1', { exact: true }).count(), { timeout: 10_000 }).toBe(1)
|
||||
const history = page.locator('[data-chat-history]')
|
||||
await expect.poll(() => history.getAttribute('data-chat-history'), { timeout: 10_000 }).toBe('idle')
|
||||
await expect.poll(() => history.getByRole('button', { name: 'Start of conversation', exact: true }).isDisabled(), {
|
||||
timeout: 10_000,
|
||||
}).toBe(true)
|
||||
expect(await strip.textContent()).toBe(stripBeforePaging)
|
||||
// With the whole log loaded, the window mounts one turn-tail footer per
|
||||
// settled turn — the loaded-window probe the scroll/perf lanes count now
|
||||
|
||||
@@ -62,6 +62,16 @@ describe.skipIf(MODE === 'record')('web e2e: compact Tool details', () => {
|
||||
expect(await row.locator('[class*="ioCard"]').count()).toBe(0)
|
||||
expect(await row.getByRole('button', { name: 'Inspect', exact: true }).count()).toBe(1)
|
||||
}
|
||||
const agentRow = page.locator('[data-tool="list_agents"]').getByRole('listitem').first()
|
||||
expect(await agentRow.getByRole('button').count()).toBe(0)
|
||||
expect(await agentRow.locator('svg').count()).toBe(0)
|
||||
// Read both positions in one browser task so scrolling cannot move the viewport between measurements.
|
||||
const agentLayout = await agentRow.evaluate((element) => {
|
||||
const description = element.querySelector('[class*="heading"] > span')!.getBoundingClientRect()
|
||||
const name = element.querySelector('[class*="subtitle"]')!.getBoundingClientRect()
|
||||
return { descriptionBottom: description.bottom, nameTop: name.top }
|
||||
})
|
||||
expect(agentLayout.descriptionBottom).toBeLessThanOrEqual(agentLayout.nameTop)
|
||||
const changedTodos = page.locator('[data-tool="todo_write"]').nth(1)
|
||||
await changedTodos.getByText('1 added · 1 updated · 1 removed', { exact: true }).waitFor()
|
||||
for (const summary of await page.locator('[data-tool] details > summary').all()) await summary.click()
|
||||
|
||||
@@ -155,7 +155,30 @@ describe('web e2e: workspace recency', () => {
|
||||
const blank = page.getByRole('treeitem').filter({ has: page.getByText('New Session', { exact: true }) })
|
||||
await expect.poll(() => blank.getAttribute('draggable')).toBe('false')
|
||||
const blankWarningStart = tripwire.warnings.length
|
||||
await page.reload({ waitUntil: 'load' })
|
||||
const selectedRowKey = await blank.getAttribute('data-row-key')
|
||||
expect(selectedRowKey).not.toBeNull()
|
||||
const restoredRowKeys = await page.locator('[role="treeitem"]:not([aria-expanded])')
|
||||
.evaluateAll(rows => rows.map(row => row.getAttribute('data-row-key')))
|
||||
expect(restoredRowKeys).toHaveLength(4)
|
||||
const workspaceRow = page.getByRole('treeitem').filter({ has: page.getByText(workspaceTitle, { exact: true }) })
|
||||
// Document load does not restore the Workspace stream or the selected blank Session. Hold the baseline to exercise that gap.
|
||||
const releaseHeldWorkspace = (): void => { releaseWorkspace?.() }
|
||||
releaseWorkspace = undefined
|
||||
holdWorkspace = true
|
||||
try {
|
||||
await page.reload({ waitUntil: 'load' })
|
||||
await expect.poll(() => releaseWorkspace !== undefined, { timeout: 30_000 }).toBe(true)
|
||||
expect(await workspaceRow.count()).toBe(0)
|
||||
} finally {
|
||||
holdWorkspace = false
|
||||
releaseHeldWorkspace()
|
||||
}
|
||||
await workspaceRow.waitFor({ state: 'visible', timeout: 30_000 })
|
||||
for (const rowKey of restoredRowKeys) {
|
||||
await page.locator(`[data-row-key="${rowKey}"]`).waitFor({ state: 'visible', timeout: 30_000 })
|
||||
}
|
||||
await page.locator(`[data-row-key="${selectedRowKey}"][aria-selected="true"]`)
|
||||
.waitFor({ state: 'visible', timeout: 30_000 })
|
||||
await expect.poll(titles).toEqual(['New Session', ...TITLES])
|
||||
acknowledgeReloadConnectionLoss(tripwire, blankWarningStart)
|
||||
await pick('Last updated')
|
||||
|
||||
+25
-4
@@ -367,10 +367,10 @@ export interface Config {
|
||||
## `@deepseek-ai/dsh-api-workspace-files`
|
||||
|
||||
- `inject`: `fs` · `sandboxPolicy` · `sessions` · `typert`
|
||||
- `source`: [`packages/api/workspace-files/src/index.ts:70`](../packages/api/workspace-files/src/index.ts)
|
||||
- `source`: [`packages/api/workspace-files/src/index.ts:74`](../packages/api/workspace-files/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Deployment caps on one page or one listing. */
|
||||
/** Deployment caps on one page, one listing, or one search. */
|
||||
export interface Config {
|
||||
/**
|
||||
* Inclusive byte cap on one page's text and on one byte window.
|
||||
@@ -386,6 +386,22 @@ export interface Config {
|
||||
readonly maxLines: number
|
||||
/** Cap on returned directory entries; the rest is dropped and reported cut. */
|
||||
readonly maxEntries: number
|
||||
/** Budgets on one search's matches, listed files, deadline and ripgrep process. */
|
||||
readonly search: SearchLimits
|
||||
}
|
||||
|
||||
/** Deployment budgets on one search's results, listed files and process. */
|
||||
export interface SearchLimits {
|
||||
/** Cap on returned matches; additional matches report truncation after the bounded scan. */
|
||||
readonly maxResults: number
|
||||
/** Cap on files considered from rg output; an extra file terminates rg and reports the search incomplete. */
|
||||
readonly maxEntries: number
|
||||
/** Search process termination grace in milliseconds. */
|
||||
readonly graceMs: number
|
||||
/** Search deadline in milliseconds; expiry rejects rather than returning partial results. */
|
||||
readonly timeoutMs: number
|
||||
/** Byte cap on retained search-process stderr diagnostics. */
|
||||
readonly stderrMaxBytes: number
|
||||
}
|
||||
```
|
||||
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-api-workspace-files -->
|
||||
@@ -1140,13 +1156,15 @@ 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:32`](../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.
|
||||
@@ -1170,7 +1188,7 @@ export type SourceSigning = 'ad-hoc' | {
|
||||
|
||||
## `@deepseek-ai/dsh-experimental-forge`
|
||||
|
||||
- `source`: [`packages/experimental/forge/src/types.ts:266`](../packages/experimental/forge/src/types.ts)
|
||||
- `source`: [`packages/experimental/forge/src/types.ts:273`](../packages/experimental/forge/src/types.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Poll cadence and fetch deadline for watched change requests. */
|
||||
@@ -1206,6 +1224,8 @@ export interface Config {
|
||||
timeoutMs?: number
|
||||
/** Maximum captured bytes per `gh` output stream and combined JSON bytes of one complete check read. */
|
||||
maxOutputBytes?: number
|
||||
/** Most review threads counted per pull request in one status read; a pull request with more reports a lower bound. */
|
||||
maxReviewThreads?: number
|
||||
/** Most commits one pull request commit listing returns; GitHub lists at most 250 commits of a pull request. */
|
||||
maxCommits?: number
|
||||
}
|
||||
@@ -5241,6 +5261,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them.
|
||||
| `@deepseek-ai/dsh-util-code-language` | — | [`packages/util/code-language/src/index.ts`](../packages/util/code-language/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-util-crypto` | — | [`packages/util/crypto/src/index.ts`](../packages/util/crypto/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-util-file-drag` | — | [`packages/util/file-drag/src/index.ts`](../packages/util/file-drag/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-util-ripgrep` | — | [`packages/util/ripgrep/src/index.ts`](../packages/util/ripgrep/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-util-time` | — | [`packages/util/time/src/index.ts`](../packages/util/time/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-util-values` | — | [`packages/util/values/src/index.ts`](../packages/util/values/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-util-workspace-path` | — | [`packages/util/workspace-path/src/index.ts`](../packages/util/workspace-path/src/index.ts) |
|
||||
|
||||
@@ -369,10 +369,10 @@ export interface Config {
|
||||
## `@deepseek-ai/dsh-api-workspace-files`
|
||||
|
||||
- `inject`: `fs` · `sandboxPolicy` · `sessions` · `typert`
|
||||
- `source`: [`packages/api/workspace-files/src/index.ts:70`](../packages/api/workspace-files/src/index.ts)
|
||||
- `source`: [`packages/api/workspace-files/src/index.ts:74`](../packages/api/workspace-files/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Deployment caps on one page or one listing. */
|
||||
/** Deployment caps on one page, one listing, or one search. */
|
||||
export interface Config {
|
||||
/**
|
||||
* Inclusive byte cap on one page's text and on one byte window.
|
||||
@@ -388,6 +388,22 @@ export interface Config {
|
||||
readonly maxLines: number
|
||||
/** Cap on returned directory entries; the rest is dropped and reported cut. */
|
||||
readonly maxEntries: number
|
||||
/** Budgets on one search's matches, listed files, deadline and ripgrep process. */
|
||||
readonly search: SearchLimits
|
||||
}
|
||||
|
||||
/** Deployment budgets on one search's results, listed files and process. */
|
||||
export interface SearchLimits {
|
||||
/** Cap on returned matches; additional matches report truncation after the bounded scan. */
|
||||
readonly maxResults: number
|
||||
/** Cap on files considered from rg output; an extra file terminates rg and reports the search incomplete. */
|
||||
readonly maxEntries: number
|
||||
/** Search process termination grace in milliseconds. */
|
||||
readonly graceMs: number
|
||||
/** Search deadline in milliseconds; expiry rejects rather than returning partial results. */
|
||||
readonly timeoutMs: number
|
||||
/** Byte cap on retained search-process stderr diagnostics. */
|
||||
readonly stderrMaxBytes: number
|
||||
}
|
||||
```
|
||||
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-api-workspace-files -->
|
||||
@@ -1142,13 +1158,15 @@ 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:32`](../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.
|
||||
@@ -1172,7 +1190,7 @@ export type SourceSigning = 'ad-hoc' | {
|
||||
|
||||
## `@deepseek-ai/dsh-experimental-forge`
|
||||
|
||||
- `source`: [`packages/experimental/forge/src/types.ts:266`](../packages/experimental/forge/src/types.ts)
|
||||
- `source`: [`packages/experimental/forge/src/types.ts:273`](../packages/experimental/forge/src/types.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Poll cadence and fetch deadline for watched change requests. */
|
||||
@@ -1208,6 +1226,8 @@ export interface Config {
|
||||
timeoutMs?: number
|
||||
/** Maximum captured bytes per `gh` output stream and combined JSON bytes of one complete check read. */
|
||||
maxOutputBytes?: number
|
||||
/** Most review threads counted per pull request in one status read; a pull request with more reports a lower bound. */
|
||||
maxReviewThreads?: number
|
||||
/** Most commits one pull request commit listing returns; GitHub lists at most 250 commits of a pull request. */
|
||||
maxCommits?: number
|
||||
}
|
||||
@@ -5243,6 +5263,7 @@ export interface Config {
|
||||
| `@deepseek-ai/dsh-util-code-language` | — | [`packages/util/code-language/src/index.ts`](../packages/util/code-language/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-util-crypto` | — | [`packages/util/crypto/src/index.ts`](../packages/util/crypto/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-util-file-drag` | — | [`packages/util/file-drag/src/index.ts`](../packages/util/file-drag/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-util-ripgrep` | — | [`packages/util/ripgrep/src/index.ts`](../packages/util/ripgrep/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-util-time` | — | [`packages/util/time/src/index.ts`](../packages/util/time/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-util-values` | — | [`packages/util/values/src/index.ts`](../packages/util/values/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-util-workspace-path` | — | [`packages/util/workspace-path/src/index.ts`](../packages/util/workspace-path/src/index.ts) |
|
||||
|
||||
@@ -27,6 +27,7 @@ flowchart TD
|
||||
pkg_util_code_language["util-code-language"]
|
||||
pkg_util_crypto["util-crypto"]
|
||||
pkg_util_file_drag["util-file-drag"]
|
||||
pkg_util_ripgrep["util-ripgrep"]
|
||||
pkg_util_time["util-time"]
|
||||
pkg_util_values["util-values"]
|
||||
pkg_util_workspace_path["util-workspace-path"]
|
||||
@@ -507,6 +508,7 @@ flowchart TD
|
||||
pkg_subprocess --> pkg_http_proxy
|
||||
pkg_remote_mock --> pkg_typert_protocol
|
||||
pkg_typert_loader --> pkg_typert_registry
|
||||
pkg_util_ripgrep --> pkg_subprocess
|
||||
pkg_system_prompt --> pkg_llm
|
||||
pkg_system_prompt --> pkg_scope
|
||||
pkg_skill --> pkg_llm
|
||||
@@ -1702,6 +1704,7 @@ flowchart TD
|
||||
| [`subprocess`](../packages/subprocess/subprocess) | `subprocess` | [`http-proxy`](../packages/util/http-proxy) |
|
||||
| [`remote-mock`](../packages/test-support/remote-mock) | `test-support` | [`typert-protocol`](../packages/typert/protocol) |
|
||||
| [`typert-loader`](../packages/typert/loader) | `typert` | [`typert-registry`](../packages/typert/registry) |
|
||||
| [`util-ripgrep`](../packages/util/ripgrep) | `util` | [`subprocess`](../packages/subprocess/subprocess) |
|
||||
| [`system-prompt`](../packages/core/system-prompt) | `core` | [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |
|
||||
| [`skill`](../packages/skill/skill) | `skill` | [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |
|
||||
| [`web`](../packages/web/web) | `web` | [`llm`](../packages/llm/llm) |
|
||||
|
||||
@@ -29,6 +29,7 @@ flowchart TD
|
||||
pkg_util_code_language["util-code-language"]
|
||||
pkg_util_crypto["util-crypto"]
|
||||
pkg_util_file_drag["util-file-drag"]
|
||||
pkg_util_ripgrep["util-ripgrep"]
|
||||
pkg_util_time["util-time"]
|
||||
pkg_util_values["util-values"]
|
||||
pkg_util_workspace_path["util-workspace-path"]
|
||||
@@ -509,6 +510,7 @@ flowchart TD
|
||||
pkg_subprocess --> pkg_http_proxy
|
||||
pkg_remote_mock --> pkg_typert_protocol
|
||||
pkg_typert_loader --> pkg_typert_registry
|
||||
pkg_util_ripgrep --> pkg_subprocess
|
||||
pkg_system_prompt --> pkg_llm
|
||||
pkg_system_prompt --> pkg_scope
|
||||
pkg_skill --> pkg_llm
|
||||
@@ -1704,6 +1706,7 @@ flowchart TD
|
||||
| [`subprocess`](../packages/subprocess/subprocess) | `subprocess` | [`http-proxy`](../packages/util/http-proxy) |
|
||||
| [`remote-mock`](../packages/test-support/remote-mock) | `test-support` | [`typert-protocol`](../packages/typert/protocol) |
|
||||
| [`typert-loader`](../packages/typert/loader) | `typert` | [`typert-registry`](../packages/typert/registry) |
|
||||
| [`util-ripgrep`](../packages/util/ripgrep) | `util` | [`subprocess`](../packages/subprocess/subprocess) |
|
||||
| [`system-prompt`](../packages/core/system-prompt) | `core` | [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |
|
||||
| [`skill`](../packages/skill/skill) | `skill` | [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |
|
||||
| [`web`](../packages/web/web) | `web` | [`llm`](../packages/llm/llm) |
|
||||
|
||||
@@ -18,5 +18,5 @@
|
||||
en: 13dfd36f2166bba0
|
||||
zh: a369434f52f04d57
|
||||
/forges/panel:
|
||||
en: 5fcac3740f59ffc0
|
||||
zh: a95a881cd06a59d8
|
||||
en: e52a5c57abc4d30e
|
||||
zh: 509a40f421cc3d33
|
||||
@@ -68,8 +68,12 @@ interface ForgeProvider {
|
||||
markReady(ref: ChangeRequestRef, signal: AbortSignal): Promise<ForgeActionFailure | undefined>
|
||||
/** Merge a change request while its head is still `request.headSha`, resolving to undefined once merged. */
|
||||
merge(ref: ChangeRequestRef, request: MergeRequest, signal: AbortSignal): Promise<ForgeActionFailure | undefined>
|
||||
/** Read the changes of a change request as the host computes them, as one unified patch with `a/` and `b/` path prefixes. */
|
||||
diff(ref: ChangeRequestRef, signal: AbortSignal): Promise<string | ForgeFailure>
|
||||
/**
|
||||
* Read the 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.
|
||||
*/
|
||||
diff(ref: ChangeRequestRef, headSha: string, signal: AbortSignal): Promise<string | ForgeFailure>
|
||||
/** Read the text of `path` at `commit` in the change request's repository. */
|
||||
fileText(ref: ChangeRequestRef, commit: string, path: string, signal: AbortSignal): Promise<string | ForgeFailure>
|
||||
/** Build the web page of `path` at `commit` in the change request's repository. */
|
||||
@@ -216,8 +220,10 @@ interface ChangeRequestSnapshot {
|
||||
readonly checksRollup: ChecksRollup
|
||||
/** Whether the host reported more checks than `checks` contains. */
|
||||
readonly checksTruncated: boolean
|
||||
/** Number of review threads that are not resolved. */
|
||||
/** Number of counted review threads that are not resolved. */
|
||||
readonly unresolvedThreads: number
|
||||
/** Whether the change request has more review threads than the provider counted, so `unresolvedThreads` is a lower bound. */
|
||||
readonly threadsTruncated: boolean
|
||||
/** ISO 8601 time of the host's last update to the change request. */
|
||||
readonly updatedAt: string
|
||||
/** ISO 8601 time the change request was merged or closed, or null while it is open. */
|
||||
@@ -231,9 +237,10 @@ interface ForgeFailure {
|
||||
/**
|
||||
* `not-found`: the host does not expose it; `unauthenticated`: credentials are missing or rejected;
|
||||
* `too-large`: the host or the provider refuses an answer over its size limit, so reading again fails
|
||||
* the same way; `unavailable`: any other failure.
|
||||
* the same way; `stale`: the head is no longer the commit the read named, so reading again at the head of
|
||||
* a later status can succeed; `unavailable`: any other failure.
|
||||
*/
|
||||
readonly code: 'not-found' | 'unauthenticated' | 'too-large' | 'unavailable'
|
||||
readonly code: 'not-found' | 'unauthenticated' | 'too-large' | 'stale' | 'unavailable'
|
||||
/** Human-readable cause. */
|
||||
readonly message: string
|
||||
}
|
||||
@@ -608,7 +615,7 @@ type ForgeChangeRequestStatesFrame =
|
||||
| { readonly type: 'changed'; readonly states: readonly ForgeChangeRequestListState[] }
|
||||
```
|
||||
|
||||
The changes view reads through the same Remote. `localDiff` reads one `ForgeLocalScope` of the Session's checkout, and `changeRequestDiff` reads a bound change request at the head of its latest status through the provider's `diff`, or the `ForgeCommitRange` it names through the provider's `compare`; both return files that the Host parsed into `ForgeDiffFile` hunks, each with its review layer and change digest. `localCommits` and `changeRequestCommits` list the commits a range can name, the latter as null when the provider does not read commits. `localText` and `changeRequestText` read the new side of one file, which supplies the unchanged lines between hunks. Each inspection also sizes the `branch` scope as `ForgePanelState.local` for the panel's local changes card, always against the automatic fork point. A `branch` scope whose `base` names a ref compares with the merge base of HEAD and that branch instead; `diffBases` lists the branches the view offers, with the automatic fork point.
|
||||
The changes view reads through the same Remote. `localDiff` reads one `ForgeLocalScope` of the Session's checkout, and `changeRequestDiff` reads a bound change request at the head of its latest status through the provider's `diff`, which returns a patch only for that head commit, or the `ForgeCommitRange` it names through the provider's `compare`; both return files that the Host parsed into `ForgeDiffFile` hunks, each with its review layer and change digest. `localCommits` and `changeRequestCommits` list the commits a range can name, the latter as null when the provider does not read commits. `localText` and `changeRequestText` read the new side of one file, which supplies the unchanged lines between hunks. Each inspection also sizes the `branch` scope as `ForgePanelState.local` for the panel's local changes card, always against the automatic fork point. A `branch` scope whose `base` names a ref compares with the merge base of HEAD and that branch instead; `diffBases` lists the branches the view offers, with the automatic fork point. When the head moved past the latest status, the provider returns `stale` and `ctx.forge.diff` polls the change request at once, once per head commit; `changeRequestDiff` rejects with `forgePanel/stale-head` and caches nothing, and the changes view keeps loading until a later status arrives, then reads again at that status's head. Review comments therefore always address the commit whose lines the view shows.
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
@@ -889,13 +896,18 @@ async merge(ref: ChangeRequestRef, request: MergeRequest, signal: AbortSignal):
|
||||
async fetchChecks(ref: ChangeRequestRef, signal: AbortSignal): Promise<ChangeRequestSnapshot | ForgeFailure>
|
||||
|
||||
/**
|
||||
* Read the changes of a change request as its host computes them.
|
||||
* Read the changes of a change request at one head commit as its host
|
||||
* 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.
|
||||
* @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.
|
||||
* @returns one unified patch with `a/` and `b/` path prefixes, or why the host did not provide it.
|
||||
* @returns one unified patch with `a/` and `b/` path prefixes, or why the host did not provide it at `headSha`.
|
||||
* @throws when no provider serves the change request's forge, or with `signal.reason` when it aborts.
|
||||
*/
|
||||
async diff(ref: ChangeRequestRef, signal: AbortSignal): Promise<string | ForgeFailure>
|
||||
async diff(ref: ChangeRequestRef, headSha: string, signal: AbortSignal): Promise<string | ForgeFailure>
|
||||
|
||||
/**
|
||||
* List the commits of a change request at its current head.
|
||||
@@ -1143,6 +1155,7 @@ Serves the forge panel. Following a Session inspects the checkout of its current
|
||||
* @returns the files, the commit they end at, each file's web page, and the commits the checkout has beyond the head.
|
||||
* @throws `forgePanel/not-bound` unless the URL is a visible binding of the Session that a provider serves;
|
||||
* `forgePanel/commits-missing` when chosen commits are not among the change request's commits in order;
|
||||
* `forgePanel/stale-head` without `commits` when the head moved past the latest status, which is then polled again;
|
||||
* `forgePanel/diff-too-large` when the host or its provider refuses the diff over a size limit;
|
||||
* `forgePanel/diff-failed` before its status was read, for a first chosen commit without a parent, and when the host
|
||||
* does not provide the commits, the diff, or an existing `.gitattributes` otherwise.
|
||||
|
||||
@@ -68,8 +68,12 @@ interface ForgeProvider {
|
||||
markReady(ref: ChangeRequestRef, signal: AbortSignal): Promise<ForgeActionFailure | undefined>
|
||||
/** Merge a change request while its head is still `request.headSha`, resolving to undefined once merged. */
|
||||
merge(ref: ChangeRequestRef, request: MergeRequest, signal: AbortSignal): Promise<ForgeActionFailure | undefined>
|
||||
/** Read the changes of a change request as the host computes them, as one unified patch with `a/` and `b/` path prefixes. */
|
||||
diff(ref: ChangeRequestRef, signal: AbortSignal): Promise<string | ForgeFailure>
|
||||
/**
|
||||
* Read the 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.
|
||||
*/
|
||||
diff(ref: ChangeRequestRef, headSha: string, signal: AbortSignal): Promise<string | ForgeFailure>
|
||||
/** Read the text of `path` at `commit` in the change request's repository. */
|
||||
fileText(ref: ChangeRequestRef, commit: string, path: string, signal: AbortSignal): Promise<string | ForgeFailure>
|
||||
/** Build the web page of `path` at `commit` in the change request's repository. */
|
||||
@@ -216,8 +220,10 @@ interface ChangeRequestSnapshot {
|
||||
readonly checksRollup: ChecksRollup
|
||||
/** Whether the host reported more checks than `checks` contains. */
|
||||
readonly checksTruncated: boolean
|
||||
/** Number of review threads that are not resolved. */
|
||||
/** Number of counted review threads that are not resolved. */
|
||||
readonly unresolvedThreads: number
|
||||
/** Whether the change request has more review threads than the provider counted, so `unresolvedThreads` is a lower bound. */
|
||||
readonly threadsTruncated: boolean
|
||||
/** ISO 8601 time of the host's last update to the change request. */
|
||||
readonly updatedAt: string
|
||||
/** ISO 8601 time the change request was merged or closed, or null while it is open. */
|
||||
@@ -231,9 +237,10 @@ interface ForgeFailure {
|
||||
/**
|
||||
* `not-found`: the host does not expose it; `unauthenticated`: credentials are missing or rejected;
|
||||
* `too-large`: the host or the provider refuses an answer over its size limit, so reading again fails
|
||||
* the same way; `unavailable`: any other failure.
|
||||
* the same way; `stale`: the head is no longer the commit the read named, so reading again at the head of
|
||||
* a later status can succeed; `unavailable`: any other failure.
|
||||
*/
|
||||
readonly code: 'not-found' | 'unauthenticated' | 'too-large' | 'unavailable'
|
||||
readonly code: 'not-found' | 'unauthenticated' | 'too-large' | 'stale' | 'unavailable'
|
||||
/** Human-readable cause. */
|
||||
readonly message: string
|
||||
}
|
||||
@@ -608,7 +615,7 @@ type ForgeChangeRequestStatesFrame =
|
||||
| { readonly type: 'changed'; readonly states: readonly ForgeChangeRequestListState[] }
|
||||
```
|
||||
|
||||
变更视图经同一个 Remote 读取。`localDiff` 读取 Session 检出的某个 `ForgeLocalScope`;`changeRequestDiff` 经提供方的 `diff` 读取已绑定变更请求在其最新状态 head 处的变更,或经提供方的 `compare` 读取它指定的 `ForgeCommitRange`;两者返回的文件都已在宿主上解析为 `ForgeDiffFile` 的 hunk,并带有分层与改动摘要。`localCommits` 与 `changeRequestCommits` 列出范围可以指定的提交,提供方不读取提交时后者返回 null。`localText` 与 `changeRequestText` 读取一个文件的新版本,用来补出 hunk 之间未变更的行。每次检查还会把 `branch` 范围的规模统计为 `ForgePanelState.local`,供面板的变更卡片使用,始终以自动分叉点为准。`base` 指定了引用的 `branch` 范围改为与 HEAD 和该分支的合并基准比较;`diffBases` 列出视图可选的分支以及自动分叉点。
|
||||
变更视图经同一个 Remote 读取。`localDiff` 读取 Session 检出的某个 `ForgeLocalScope`;`changeRequestDiff` 经提供方的 `diff` 读取已绑定变更请求在其最新状态 head 处的变更(提供方只返回描述该 head 提交的补丁),或经提供方的 `compare` 读取它指定的 `ForgeCommitRange`;两者返回的文件都已在宿主上解析为 `ForgeDiffFile` 的 hunk,并带有分层与改动摘要。`localCommits` 与 `changeRequestCommits` 列出范围可以指定的提交,提供方不读取提交时后者返回 null。`localText` 与 `changeRequestText` 读取一个文件的新版本,用来补出 hunk 之间未变更的行。每次检查还会把 `branch` 范围的规模统计为 `ForgePanelState.local`,供面板的变更卡片使用,始终以自动分叉点为准。`base` 指定了引用的 `branch` 范围改为与 HEAD 和该分支的合并基准比较;`diffBases` 列出视图可选的分支以及自动分叉点。head 已越过最新状态时,提供方返回 `stale`,`ctx.forge.diff` 立即重新轮询该变更请求(每个 head 提交一次);`changeRequestDiff` 以 `forgePanel/stale-head` 拒绝且不缓存任何内容,变更视图保持加载,直到更新的状态到达后按该状态的 head 重新读取。因此评审评论始终指向视图所显示行的那个提交。
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
@@ -889,13 +896,18 @@ async merge(ref: ChangeRequestRef, request: MergeRequest, signal: AbortSignal):
|
||||
async fetchChecks(ref: ChangeRequestRef, signal: AbortSignal): Promise<ChangeRequestSnapshot | ForgeFailure>
|
||||
|
||||
/**
|
||||
* Read the changes of a change request as its host computes them.
|
||||
* Read the changes of a change request at one head commit as its host
|
||||
* 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.
|
||||
* @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.
|
||||
* @returns one unified patch with `a/` and `b/` path prefixes, or why the host did not provide it.
|
||||
* @returns one unified patch with `a/` and `b/` path prefixes, or why the host did not provide it at `headSha`.
|
||||
* @throws when no provider serves the change request's forge, or with `signal.reason` when it aborts.
|
||||
*/
|
||||
async diff(ref: ChangeRequestRef, signal: AbortSignal): Promise<string | ForgeFailure>
|
||||
async diff(ref: ChangeRequestRef, headSha: string, signal: AbortSignal): Promise<string | ForgeFailure>
|
||||
|
||||
/**
|
||||
* List the commits of a change request at its current head.
|
||||
@@ -1143,6 +1155,7 @@ Serves the forge panel. Following a Session inspects the checkout of its current
|
||||
* @returns the files, the commit they end at, each file's web page, and the commits the checkout has beyond the head.
|
||||
* @throws `forgePanel/not-bound` unless the URL is a visible binding of the Session that a provider serves;
|
||||
* `forgePanel/commits-missing` when chosen commits are not among the change request's commits in order;
|
||||
* `forgePanel/stale-head` without `commits` when the head moved past the latest status, which is then polled again;
|
||||
* `forgePanel/diff-too-large` when the host or its provider refuses the diff over a size limit;
|
||||
* `forgePanel/diff-failed` before its status was read, for a first chosen commit without a parent, and when the host
|
||||
* does not provide the commits, the diff, or an existing `.gitattributes` otherwise.
|
||||
|
||||
@@ -27,8 +27,8 @@
|
||||
en: 2916306e6cb9282c
|
||||
zh: cdec047989860202
|
||||
/right-sidebar/workspace-files:
|
||||
en: 2b1b49dbba9cc9ee
|
||||
zh: 13d2a45224743b37
|
||||
en: 264e28c7cdf4ae5d
|
||||
zh: a516ea0d8db9437c
|
||||
/right-sidebar/shipped-types:
|
||||
en: f8f0e368701f6669
|
||||
zh: de364aaf3345d70c
|
||||
|
||||
@@ -135,7 +135,7 @@ A resource stays open while it has a holder — a subscribed `useResource` or a
|
||||
|
||||
## Workspace Files
|
||||
|
||||
The Host `ctx.workspaceFiles` service and generated `workspaceFiles` Remote namespace read files allowed by the Session filesystem backend: `stat(path)` returns `{ absolutePath, version, bytes? }`; `read(path, { offset?, limit? })` returns one page of lines (`offset` 1-based, `limit` capped by the configured page size) as `{ …stat, offset, text, eof }`; `readBytes(path, { range?, baseFile? })` returns a complete file under `maxFileBytes` or a byte window under `maxBytes` as `{ …stat, offset, data, eof }` with native `Uint8Array` data. Its optional `range` uses 0-based `offset` and a byte `length`; `baseFile` resolves the relative target from another file’s directory. `list(path)` remains inside the workspace root and returns a directory's direct children (`name`, `type: 'file' | 'directory' | 'other'`, `size?`) cut to the configured cap with `truncated` set. `changes(path)` watches one target; the Host determines its type and confines directories to the workspace. It yields `{ kind: 'ready' }` once subscribed, then `{ kind: 'change', change }` frames whose payload is `{ absolutePath, version }` or `{ absolutePath, absent: true }` ([README](../../packages/api/workspace-files/README.md#use-this-package)). File reads reject final symlinks and enforce transfer caps; `read` additionally requires UTF-8 text. Failures use `workspace-file/*` codes ([failures](../../packages/api/workspace-files/README.md)).
|
||||
The Host `ctx.workspaceFiles` service and generated `workspaceFiles` Remote namespace read files allowed by the Session filesystem backend: `stat(path)` returns `{ absolutePath, version, bytes? }`; `read(path, { offset?, limit? })` returns one page of lines (`offset` 1-based, `limit` capped by the configured page size) as `{ …stat, offset, text, eof }`; `readBytes(path, { range?, baseFile? })` returns a complete file under `maxFileBytes` or a byte window under `maxBytes` as `{ …stat, offset, data, eof }` with native `Uint8Array` data. Its optional `range` uses 0-based `offset` and a byte `length`; `baseFile` resolves the relative target from another file’s directory. `list(path)` remains inside the workspace root and returns a directory's direct children (`name`, `type: 'file' | 'directory' | 'other'`, `size?`) cut to the configured cap with `truncated` set. `search({ query, excludedPaths, includeHidden, respectGitignore })` uses packaged rg to find files and inferred ancestor directories, returning flat `{ path, type }` workspace-relative matches; [search semantics](../../packages/api/workspace-files/README.md#search) define native ignore handling, caps and failures. `changes(path)` watches one target; the Host determines its type and confines directories to the workspace. It yields `{ kind: 'ready' }` once subscribed, then `{ kind: 'change', change }` frames whose payload is `{ absolutePath, version }` or `{ absolutePath, absent: true }` ([README](../../packages/api/workspace-files/README.md#use-this-package)). File reads reject final symlinks and enforce transfer caps; `read` additionally requires UTF-8 text. Failures use `workspace-file/*` codes ([failures](../../packages/api/workspace-files/README.md)).
|
||||
|
||||
[`dsh-api-workspace-files`](../../packages/api/workspace-files/README.md) registers the `file` provider, with `ResourceProtocolMap.file` directly naming `WorkspaceFileStat`. A Session address carries the authorizing Session and a relative or absolute path, passed unchanged to the Host for resolution. The provider waits for Host `ready` before stat and filters changes by `stat.absolutePath`. Bare `absolute` addresses have no authorizing Session and fail with `workspace-file/unknown-workspace`, without borrowing current or Tab Session. Any UI, including Global components, shares the observation for the same complete address. Preview's Remote callbacks use the Session in that address; `remote.workspaceFiles.readBytes` returns native bytes through Connection's binary RPC transport.
|
||||
|
||||
|
||||
@@ -135,7 +135,7 @@ Preview 记录已载入版本和读取开始时的观察版本。自动刷新默
|
||||
|
||||
## Workspace Files
|
||||
|
||||
Host 的 `ctx.workspaceFiles` 服务与生成的 `workspaceFiles` Remote 命名空间读取 Session 文件系统后端允许的文件:`stat(path)` 返回 `{ absolutePath, version, bytes? }`;`read(path, { offset?, limit? })` 返回一页行(`offset` 1 起,`limit` 受配置页长限制),形如 `{ …stat, offset, text, eof }`;`readBytes(path, { range?, baseFile? })` 在 `maxFileBytes` 上限内返回完整文件,或在 `maxBytes` 上限内返回字节窗口,形如 `{ …stat, offset, data, eof }`,其中 `data` 为原生 `Uint8Array`。可选 `range` 使用 0 起算的 `offset` 与字节数 `length`;`baseFile` 从另一文件所在目录解析相对目标。`list(path)` 仍限定在工作区根内,返回目录的直接子项(`name`、`type: 'file' | 'directory' | 'other'`、`size?`),按配置上限截断并置 `truncated`。`changes(path)` 监听一个目标,由 Host 判断目标类型并将目录限制在工作区。订阅就绪后产出 `{ kind: 'ready' }`,随后产出 `{ kind: 'change', change }` 帧,其载荷为 `{ absolutePath, version }` 或 `{ absolutePath, absent: true }`([README](../../packages/api/workspace-files/README.zh.md#use-this-package))。文件读取拒绝末端符号链接并执行传输上限;`read` 还要求 UTF-8 文本。失败使用 `workspace-file/*` 错误码([失败](../../packages/api/workspace-files/README.zh.md))。
|
||||
Host 的 `ctx.workspaceFiles` 服务与生成的 `workspaceFiles` Remote 命名空间读取 Session 文件系统后端允许的文件:`stat(path)` 返回 `{ absolutePath, version, bytes? }`;`read(path, { offset?, limit? })` 返回一页行(`offset` 1 起,`limit` 受配置页长限制),形如 `{ …stat, offset, text, eof }`;`readBytes(path, { range?, baseFile? })` 在 `maxFileBytes` 上限内返回完整文件,或在 `maxBytes` 上限内返回字节窗口,形如 `{ …stat, offset, data, eof }`,其中 `data` 为原生 `Uint8Array`。可选 `range` 使用 0 起算的 `offset` 与字节数 `length`;`baseFile` 从另一文件所在目录解析相对目标。`list(path)` 仍限定在工作区根内,返回目录的直接子项(`name`、`type: 'file' | 'directory' | 'other'`、`size?`),按配置上限截断并置 `truncated`。`search({ query, excludedPaths, includeHidden, respectGitignore })` 通过打包的 rg 搜索文件与推导的祖先目录,返回平铺的 `{ path, type }` 工作区相对路径匹配;原生忽略规则、上限与错误语义见[搜索说明](../../packages/api/workspace-files/README.zh.md#search)。`changes(path)` 监听一个目标,由 Host 判断目标类型并将目录限制在工作区。订阅就绪后产出 `{ kind: 'ready' }`,随后产出 `{ kind: 'change', change }` 帧,其载荷为 `{ absolutePath, version }` 或 `{ absolutePath, absent: true }`([README](../../packages/api/workspace-files/README.zh.md#use-this-package))。文件读取拒绝末端符号链接并执行传输上限;`read` 还要求 UTF-8 文本。失败使用 `workspace-file/*` 错误码([失败](../../packages/api/workspace-files/README.zh.md))。
|
||||
|
||||
[`dsh-api-workspace-files`](../../packages/api/workspace-files/README.zh.md) 注册 `file` 提供方,`ResourceProtocolMap.file` 直接是 `WorkspaceFileStat`。Session 地址携带授权 Session 与相对或绝对路径,Host 原样接收并解析。提供方在 stat 前等待 Host 的 `ready` 帧,并按 `stat.absolutePath` 过滤变更。裸 `absolute` 地址没有授权 Session,以 `workspace-file/unknown-workspace` 失败,不借用当前或 Tab Session。任何 UI(包括 Global)访问同一完整地址都共享观察。Preview 的 Remote 回调使用地址中的 Session;`remote.workspaceFiles.readBytes` 通过 Connection 的二进制 RPC 传输返回原生字节。
|
||||
|
||||
|
||||
@@ -485,6 +485,22 @@ Host Remote file reads and workspace directory observations over the composed fi
|
||||
*/
|
||||
@Remote async list(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise<WorkspaceDirectoryListing>
|
||||
|
||||
/**
|
||||
* Search files and their inferred ancestor directories with packaged ripgrep.
|
||||
* Native ignore rules apply unless disabled; VCS metadata, symlinks and empty
|
||||
* directories are absent. Within the bounded set of listed files, matches
|
||||
* sort by depth, then workspace-relative path.
|
||||
* @param workspaceFileScope - header-derived workspace root for the Session identity on the wire.
|
||||
* @param request - the query, literal exclusions, and hidden/ignore switches.
|
||||
* @param signal - caller cancellation, forwarded to the subprocess; rejection preserves its reason.
|
||||
* @returns matches capped by search.maxResults; incomplete marks excess rg files or exit 2.
|
||||
* @throws RemoteError `gateway/bad-request`: the query is blank.
|
||||
* @throws RemoteError `workspace-file/not-found`: the workspace root does not exist.
|
||||
* @throws RemoteError `workspace-file/not-directory`: the workspace root is not a directory.
|
||||
* @throws RemoteError `workspace-file/search-failed`: subprocess is missing, rg cannot launch or execute, or the search times out.
|
||||
*/
|
||||
@Remote async search( workspaceFileScope: WorkspaceFileScope, request: WorkspaceFileSearchRequest, signal: AbortSignal, ): Promise<WorkspaceFileSearchResult>
|
||||
|
||||
/**
|
||||
* Watch one file or a directory's direct entries in the Session's filesystem.
|
||||
* Files use the backend's read authority; directories remain workspace-scoped.
|
||||
|
||||
@@ -485,6 +485,22 @@ Host Remote file reads and workspace directory observations over the composed fi
|
||||
*/
|
||||
@Remote async list(workspaceFileScope: WorkspaceFileScope, path: string, signal: AbortSignal): Promise<WorkspaceDirectoryListing>
|
||||
|
||||
/**
|
||||
* Search files and their inferred ancestor directories with packaged ripgrep.
|
||||
* Native ignore rules apply unless disabled; VCS metadata, symlinks and empty
|
||||
* directories are absent. Within the bounded set of listed files, matches
|
||||
* sort by depth, then workspace-relative path.
|
||||
* @param workspaceFileScope - header-derived workspace root for the Session identity on the wire.
|
||||
* @param request - the query, literal exclusions, and hidden/ignore switches.
|
||||
* @param signal - caller cancellation, forwarded to the subprocess; rejection preserves its reason.
|
||||
* @returns matches capped by search.maxResults; incomplete marks excess rg files or exit 2.
|
||||
* @throws RemoteError `gateway/bad-request`: the query is blank.
|
||||
* @throws RemoteError `workspace-file/not-found`: the workspace root does not exist.
|
||||
* @throws RemoteError `workspace-file/not-directory`: the workspace root is not a directory.
|
||||
* @throws RemoteError `workspace-file/search-failed`: subprocess is missing, rg cannot launch or execute, or the search times out.
|
||||
*/
|
||||
@Remote async search( workspaceFileScope: WorkspaceFileScope, request: WorkspaceFileSearchRequest, signal: AbortSignal, ): Promise<WorkspaceFileSearchResult>
|
||||
|
||||
/**
|
||||
* Watch one file or a directory's direct entries in the Session's filesystem.
|
||||
* Files use the backend's read authority; directories remain workspace-scoped.
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.2-rc.1/cua-hover-ax-only/guide.md
|
||||
/:
|
||||
en: 5ebb4c8a6b20b644
|
||||
zh: 22a8983d06fa5789
|
||||
/macos-hover-keeps-the-system-pointer-in-place-by-default:
|
||||
en: 6833a7851486f10d
|
||||
zh: 22983f326fb725ce
|
||||
/macos-hover-keeps-the-system-pointer-in-place-by-default/change:
|
||||
en: 55c3070b24b4ad39
|
||||
zh: 2555d127b0f4e4a0
|
||||
/macos-hover-keeps-the-system-pointer-in-place-by-default/migration:
|
||||
en: 1f0ab7e8741e821f
|
||||
zh: f59adc3a23ebcb43
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
kind: upgrade-guide
|
||||
description: "macOS mouseMove requires AX alternate UI by default and returns HOVER_UNAVAILABLE instead of moving the user's cursor."
|
||||
---
|
||||
|
||||
# macOS hover keeps the system pointer in place by default
|
||||
|
||||
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.
|
||||
|
||||
## 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.
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
kind: upgrade-guide
|
||||
description: "macOS 的 mouseMove 默认要求控件支持 AX 备用界面,否则返回 HOVER_UNAVAILABLE,保留用户的光标位置。"
|
||||
---
|
||||
|
||||
# macOS 悬停默认保留系统光标位置
|
||||
|
||||
[English](guide.md) | 中文
|
||||
|
||||
## 变更
|
||||
|
||||
在 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`。要保留用户的光标位置,使用默认配置。
|
||||
@@ -12,5 +12,5 @@
|
||||
en: e5d1027faa55adba
|
||||
zh: 4b88ad6a70977fb3
|
||||
/native-cua-application-and-window-api/migration:
|
||||
en: 6cabae699a340845
|
||||
zh: f2256751518c5e36
|
||||
en: b09e3dde32391786
|
||||
zh: f702e1e25b260b29
|
||||
@@ -15,7 +15,7 @@ Providers supply startup capabilities alongside their platform guidance. The nat
|
||||
|
||||
## Migration
|
||||
|
||||
1. After `cua.getApp(selector)`, select the target from `app.getWindows()` with `app.getWindow(windowId)`. Use `window.getScreenshot()`, `window.click()`, `window.scroll()`, and `window.drag()`. Replace `hover` with `window.mouseMove()`.
|
||||
1. After `cua.getApp(selector)`, select the target from `app.getWindows()` with `app.getWindow(windowId)`. Use `window.getScreenshot()`, `window.click()`, `window.scroll()`, and `window.drag()`. Replace `hover` with `window.mouseMove()`. On macOS, `window.click(id)` sends mouse events at the element center. Switch to `invoke(id)` for the default accessibility action, or `performAction(id, "AXShowMenu")` to request an advertised menu action.
|
||||
2. Replace `getAXState()` with `getAccessibilityTree()`. Replace `getAXStateAndScreenshot()` with separate tree and screenshot reads. Selection reads the initial tree silently for a window, and for an app when `appLevelAccessibility` is true. Read the tree explicitly to display it.
|
||||
3. Pass the opaque string ID from the latest tree wherever you passed a numeric element index. In raw requests use `elementId` with `snapshotId`. Replace `performSecondaryAction` with `listActions(id)` and `performAction(id, exactName, argument?)`, or use `invoke(id)` for the default action. `setValue` accepts the provider’s supported JSON values. On macOS, `performAction(id, 'AXRaise')` performs the advertised raise action. Call `app.activate()` first when the application must become active.
|
||||
4. Replace a packed chord such as `pressKey('shift+Tab')` with `pressKey('Tab', ['shift'])`. Check `(await cua.getCapabilities()).methods` before using `sendKeyEvent`: macOS supports complete keystrokes and rejects directional events with `NOT_IMPLEMENTED`.
|
||||
|
||||
@@ -15,7 +15,7 @@ CUA 脚本在指定窗口上截图和操作鼠标。App 和 Window 对象共享
|
||||
|
||||
## 迁移
|
||||
|
||||
1. 调用 `cua.getApp(selector)` 后,从 `app.getWindows()` 返回的列表选定目标,再调用 `app.getWindow(windowId)`。在窗口上调用 `getScreenshot()`、`click()`、`scroll()` 和 `drag()`,将原有的 `hover` 调用改为 `window.mouseMove()`。
|
||||
1. 调用 `cua.getApp(selector)` 后,从 `app.getWindows()` 返回的列表选定目标,再调用 `app.getWindow(windowId)`。在窗口上调用 `getScreenshot()`、`click()`、`scroll()` 和 `drag()`,将原有的 `hover` 调用改为 `window.mouseMove()`。在 macOS 上,`window.click(id)` 在元素中心发送鼠标事件。需要执行默认辅助功能动作时,改用 `invoke(id)`。需要打开控件菜单且控件声明了 `AXShowMenu` 时,调用 `performAction(id, "AXShowMenu")`。
|
||||
2. 将 `getAXState()` 改为 `getAccessibilityTree()`,将 `getAXStateAndScreenshot()` 拆成读取树和截图两次调用。选择窗口时会静默读取初始树。选择应用时,只有 `appLevelAccessibility` 为真才读取。要显示树,请显式读取。
|
||||
3. 将数字元素索引改为从最新树中取得的字符串 ID。直接发送请求时使用 `elementId` 和 `snapshotId`。原先调用 `performSecondaryAction` 的地方,先用 `listActions(id)` 取得完整动作名,再调用 `performAction(id, exactName, argument?)`。要执行默认动作,可调用 `invoke(id)`。`setValue` 接受提供方支持的 JSON 值。 在 macOS 上,`performAction(id, 'AXRaise')` 只执行元素声明的抬起窗口动作。需要把应用切到前台时先调用 `app.activate()`。
|
||||
4. 将 `pressKey('shift+Tab')` 之类的组合键写法改为 `pressKey('Tab', ['shift'])`。调用 `sendKeyEvent` 前检查 `(await cua.getCapabilities()).methods`。macOS 支持完整按键,单独发送按下或松开事件会返回 `NOT_IMPLEMENTED`。
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.2-rc.1/forge-pinned-diff/guide.md
|
||||
/:
|
||||
en: ad1c3c4e42227656
|
||||
zh: 286eac73122ddbef
|
||||
/forge-diffs-read-one-head-commit:
|
||||
en: c017d657e5d9eb08
|
||||
zh: d0eb2aef480e7753
|
||||
/forge-diffs-read-one-head-commit/change:
|
||||
en: 6b6465c90a0f893e
|
||||
zh: fb265bd706a77196
|
||||
/forge-diffs-read-one-head-commit/migration:
|
||||
en: 147b16ca7c30624c
|
||||
zh: ce246be22d7c378a
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
kind: upgrade-guide
|
||||
description: "ForgeProvider.diff and ctx.forge.diff take the head commit to read, ForgeFailure gains the stale code, and ChangeRequestSnapshot gains threadsTruncated."
|
||||
---
|
||||
|
||||
# Forge diffs read one head commit
|
||||
|
||||
English | [中文](guide.zh.md)
|
||||
|
||||
## Change
|
||||
|
||||
`ForgeProvider.diff(ref, signal)` becomes `diff(ref, headSha, signal)`, and `ctx.forge.diff(ref, signal)` becomes `ctx.forge.diff(ref, headSha, signal)`. A provider returns a patch only when it describes `headSha`; when the change request's head is another commit, it resolves to a `ForgeFailure` whose code is the new `stale`. `ctx.forge.diff` then polls the change request again at once when it is watched. The GitHub provider reads the head and base commits in one GraphQL query, returns `stale` when the head is another commit, and otherwise reads the patch through the compare API between those two commits instead of `gh pr diff`. A GitHub pull request over 300 files, which `gh pr diff` refused as too large, now shows its complete diff; only output over `maxOutputBytes` or GitHub's own refusal remains `too-large`.
|
||||
|
||||
`ForgeFailure['code']` gains `stale`. `ChangeRequestSnapshot` gains the required `threadsTruncated: boolean`, which marks `unresolvedThreads` as a lower bound when the provider counted only some review threads.
|
||||
|
||||
The `forgePanel.changeRequestDiff` Remote rejects with `forgePanel/stale-head` instead of returning a patch read at another head.
|
||||
|
||||
This affects third-party `ForgeProvider` implementations registered with `ctx.forge.register`, code that calls `ctx.forge.diff` or `forgePanel.changeRequestDiff`, code that builds `ChangeRequestSnapshot` values, and code that switches exhaustively over `ForgeFailure['code']`. All types come from `@deepseek-ai/dsh-experimental-forge`.
|
||||
|
||||
## Migration
|
||||
|
||||
1. In a `ForgeProvider`, add the `headSha: string` parameter to `diff` before `signal`. Return the patch only when the host computed it at `headSha`; otherwise return `{ code: 'stale', message }`. [`GitHubProvider.diff`](../../../../packages/experimental/forge-github/src/provider.ts) reads the current head first and then a diff addressed by commit.
|
||||
2. Pass the head commit to every `ctx.forge.diff` call, usually `ctx.forge.latest(ref)`'s `snapshot.headSha`. On `stale`, wait for a later status and read again at its head; do not cache the result under the old head.
|
||||
3. Set `threadsTruncated` in every `ChangeRequestSnapshot` a provider builds: `false` when `unresolvedThreads` counts every review thread.
|
||||
4. Handle `stale` wherever code switches exhaustively over `ForgeFailure['code']`, and `forgePanel/stale-head` wherever a caller of `forgePanel.changeRequestDiff` distinguishes Remote error codes.
|
||||
5. Run your type check; it succeeds when every `diff` implementation and call passes a head commit, every snapshot sets `threadsTruncated`, and no `ForgeFailure` code is unhandled.
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
kind: upgrade-guide
|
||||
description: "ForgeProvider.diff 与 ctx.forge.diff 需要传入要读取的 head 提交,ForgeFailure 新增 stale 代码,ChangeRequestSnapshot 新增 threadsTruncated。"
|
||||
---
|
||||
|
||||
# Forge diff 读取指定的 head 提交
|
||||
|
||||
[English](guide.md) | 中文
|
||||
|
||||
## 变更
|
||||
|
||||
`ForgeProvider.diff(ref, signal)` 改为 `diff(ref, headSha, signal)`,`ctx.forge.diff(ref, signal)` 改为 `ctx.forge.diff(ref, headSha, signal)`。提供方只在补丁描述 `headSha` 时返回补丁;变更请求的 head 已是其他提交时,resolve 为代码为新增 `stale` 的 `ForgeFailure`。此时若该变更请求正被 watch,`ctx.forge.diff` 会立即重新轮询它。GitHub 提供方在一次 GraphQL 查询中读取 head 与基准提交,head 是其他提交时返回 `stale`,否则不再使用 `gh pr diff`,改经 compare API 读取这两个提交之间的补丁。超过 300 个文件、此前被 `gh pr diff` 判为过大的 GitHub pull request 现在显示完整 diff;只有输出超过 `maxOutputBytes` 或 GitHub 自身拒绝时才仍为 `too-large`。
|
||||
|
||||
`ForgeFailure['code']` 新增 `stale`。`ChangeRequestSnapshot` 新增必填字段 `threadsTruncated: boolean`;提供方只统计了部分评审讨论时,它表示 `unresolvedThreads` 只是下限。
|
||||
|
||||
`forgePanel.changeRequestDiff` Remote 以 `forgePanel/stale-head` 拒绝,不再返回在其他 head 读到的补丁。
|
||||
|
||||
受影响的是用 `ctx.forge.register` 注册的第三方 `ForgeProvider` 实现、调用 `ctx.forge.diff` 或 `forgePanel.changeRequestDiff` 的代码、构造 `ChangeRequestSnapshot` 的代码,以及对 `ForgeFailure['code']` 做穷尽分支的代码。这些类型都来自 `@deepseek-ai/dsh-experimental-forge`。
|
||||
|
||||
## 迁移
|
||||
|
||||
1. 在 `ForgeProvider` 的 `diff` 中,于 `signal` 之前加入 `headSha: string` 参数。只有托管平台在 `headSha` 处计算出补丁时才返回补丁,否则返回 `{ code: 'stale', message }`。[`GitHubProvider.diff`](../../../../packages/experimental/forge-github/src/provider.ts) 先读取当前 head,再读取按提交寻址的 diff。
|
||||
2. 每次调用 `ctx.forge.diff` 都传入 head 提交,通常是 `ctx.forge.latest(ref)` 的 `snapshot.headSha`。收到 `stale` 时,等待更新的状态并按其 head 重新读取;不要把结果缓存在旧 head 下。
|
||||
3. 提供方构造的每个 `ChangeRequestSnapshot` 都要设置 `threadsTruncated`:`unresolvedThreads` 统计了全部评审讨论时为 `false`。
|
||||
4. 凡对 `ForgeFailure['code']` 做穷尽分支的地方都处理 `stale`;凡区分 `forgePanel.changeRequestDiff` Remote 错误代码的调用方都处理 `forgePanel/stale-head`。
|
||||
5. 运行类型检查;所有 `diff` 实现与调用都传入 head 提交、所有快照都设置 `threadsTruncated`、没有未处理的 `ForgeFailure` 代码时,检查通过。
|
||||
@@ -15,8 +15,8 @@
|
||||
en: 1fdf707322fb6996
|
||||
zh: 0204f7e9d4aa3cce
|
||||
/session-controller/use-this-package:
|
||||
en: 9cac36e3c0a32959
|
||||
zh: 37c42cc3edafa798
|
||||
en: 50fa3dd0a2ce79c8
|
||||
zh: 88bd7ea6058d2122
|
||||
/session-controller/client-references:
|
||||
en: e8979a803ec3e194
|
||||
zh: c567efcd971728ba
|
||||
|
||||
@@ -70,7 +70,7 @@ Fork copies the exact inclusive event prefix selected by `atSeq`, including a cu
|
||||
|
||||
A resume blocked by an existing write handle returns `session/writer-held` with the Session id; other resume failures retain `gateway/internal`.
|
||||
|
||||
`loadThrough(seq)` retains older pages privately until its shared target is covered or loading ends, then publishes the successful pages as one ordered prepend. Live events remain visible while history loads. A later page failure retains the successful prefix; replacing the history window discards buffered pages from the replaced window. Ordinary `loadOlder()` publishes one Host-selected page directly.
|
||||
`loadThrough(seq)` retains older pages privately until its shared target is covered or loading ends, then publishes the successful pages as one ordered prepend. Live events remain visible while history loads. A later page failure retains the successful prefix; replacing the history window discards buffered pages from the replaced window. Ordinary `loadOlder()` prepares and consumes one Host-selected page; `{ deferConsumption: true }` retains that page until `consumeOlder()` publishes it synchronously. A retained page blocks another ordinary request without blocking live events. `historyLoadStatus` distinguishes `idle`, `loading`, `pendingConsumption`, and `error`; `loadingOlder` is derived from `loading`, including Turn jumps. Failures and pages without progress permit explicit retry rather than claiming exhaustion. `historyWasPartial` remembers an incomplete visible window for the Client Session lifetime, including reconnects, without persisting presentation state.
|
||||
|
||||
The Client's initial `follow`, reconnect openings, and `loadOlder()` request at least 50 append-origin `user/message` and `assistant/message` events and cross at least two `turn/start` events, including the partial Turn at the loaded window's beginning. The Host stops at the first Turn start satisfying both minima, or at 500 counted messages or history exhaustion, whichever comes first. Steering does not add a Turn boundary. Intervening events accompany the page without counting toward the message budget. The optional `turnWindow` on page and follow requests supplies these minima under `maxMessages`. `loadThrough()` uses the same rules with a 200-message minimum per page; the 500-message cap does not limit the whole jump. Requests without `turnWindow` retain message-aligned paging.
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ Fork 将 `atSeq` 选定的含边界事件前缀(包括开放轮次内的截点
|
||||
|
||||
恢复会话时若已有写句柄占用,返回 `session/writer-held`,并携带会话 id;其他恢复失败仍返回 `gateway/internal`。
|
||||
|
||||
`loadThrough(seq)` 在共享目标被覆盖或加载结束前私下保留较早页面,随后把成功取得的页面按顺序作为一次前插发布。历史加载期间实时事件仍然可见。后续页面失败时保留已成功取得的部分;历史窗口被替换时丢弃被替换窗口的暂存页面。普通 `loadOlder()` 直接发布 Host 选取的一页结果。
|
||||
`loadThrough(seq)` 在共享目标被覆盖或加载结束前私下保留较早页面,随后把成功取得的页面按顺序作为一次前插发布。历史加载期间实时事件仍然可见。后续页面失败时保留已成功取得的部分;历史窗口被替换时丢弃被替换窗口的暂存页面。普通 `loadOlder()` 准备并消费 Host 选取的一页;传入 `{ deferConsumption: true }` 时保留这一页,直到 `consumeOlder()` 同步发布。暂存页阻止重复普通请求,但不阻止实时事件。`historyLoadStatus` 区分 `idle`、`loading`、`pendingConsumption` 和 `error`;`loadingOlder` 从 `loading` 派生,包含 Turn 跳转。失败和无进展页面允许明确重试,不伪装成历史耗尽。`historyWasPartial` 在客户端 Session 生命周期内记住可见窗口曾不完整,重连保留,但不持久化呈现状态。
|
||||
|
||||
Client 的首次 `follow`、重连首屏与 `loadOlder()` 至少请求 50 条以 append 方式追加的 `user/message` 和 `assistant/message` 事件,并向前跨过至少两个 `turn/start`,其中包含已加载窗口开头尚未补齐的轮次。Host 在首次同时满足两个下限的轮次开头停止;若先达到 500 条计数消息或历史已耗尽,则立即返回。Steering 不增加轮次分界。区间内的其他事件随页返回,但不计入消息预算。分页和 follow 请求可选的 `turnWindow` 在 `maxMessages` 上限内指定这两个下限。`loadThrough()` 复用相同规则,仅将每页消息数下限设为 200;500 条上限不限制整次跳转。不带 `turnWindow` 的请求保留按消息对齐的分页方式。
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
* ObservableSnapshot half), and orchestration code calls the behavior verbs
|
||||
* below — nothing else. Widening this interface is the explicit act of
|
||||
* widening what features may do to a session (and what every test fixture
|
||||
* must stub); implementation-internal entry points (history staging, wire-frame
|
||||
* dispatch) stay on the class, invisible out here.
|
||||
* must stub); implementation-internal wire-frame dispatch stays on the class,
|
||||
* invisible out here.
|
||||
*/
|
||||
import type { FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
||||
import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
|
||||
@@ -132,10 +132,18 @@ export interface ISession {
|
||||
/**
|
||||
* Extend history by at least 50 messages and two Turn starts, including a
|
||||
* partial Turn at the window's beginning. Stop at 500 messages or history
|
||||
* exhaustion even when those minima cannot be met. Publish one prepend.
|
||||
* @returns completion; failures land in snapshot.openState/loadingOlder.
|
||||
* exhaustion even when those minima cannot be met. At most one page may be
|
||||
* loading or awaiting consumption; live events remain independently visible.
|
||||
* @param options - deferConsumption keeps the page unpublished until consumeOlder; omitted consumes before completion.
|
||||
* @returns request completion; failures and no progress set snapshot.historyLoadStatus to error.
|
||||
*/
|
||||
loadOlder(): Promise<void>
|
||||
loadOlder(options?: { deferConsumption?: boolean }): Promise<void>
|
||||
/**
|
||||
* Publish the prepared page synchronously if its generation and starting cursor still match.
|
||||
* A terminal empty page may publish hasMore=false; an invalidated page is discarded.
|
||||
* @returns whether a prepared page was published; false while loading, absent, or invalidated.
|
||||
*/
|
||||
consumeOlder(): boolean
|
||||
/**
|
||||
* Page history backwards until the window covers `seq` (inclusive) — the
|
||||
* turn-jump loader. Repeated calls while a jump is paging lower its shared
|
||||
|
||||
@@ -78,7 +78,12 @@ export interface SessionSnapshot {
|
||||
readonly openState: OpenState
|
||||
readonly openError: RemoteFailure | null
|
||||
readonly hasMore: boolean
|
||||
/** This Client Session has exposed an incomplete history window; remains true across replacements. */
|
||||
readonly historyWasPartial: boolean
|
||||
/** Whether historyLoadStatus is loading, including Turn-jump requests. */
|
||||
readonly loadingOlder: boolean
|
||||
/** History request status; pendingConsumption retains an unpublished ordinary page. */
|
||||
readonly historyLoadStatus: 'idle' | 'loading' | 'pendingConsumption' | 'error'
|
||||
readonly promptError: PromptError | null
|
||||
readonly blank: boolean
|
||||
readonly lastAgentError: string | null
|
||||
|
||||
@@ -67,6 +67,16 @@ interface PendingHistory {
|
||||
readonly pages: (readonly SessionEventLikeEntry[])[]
|
||||
}
|
||||
|
||||
interface OlderHistoryRequest {
|
||||
readonly generation: number
|
||||
readonly beforeSeq: SessionLogOffset
|
||||
page: { readonly entries: readonly SessionEventLikeEntry[]; readonly hasMore: boolean } | null
|
||||
invalidated: boolean
|
||||
recovering: boolean
|
||||
consuming: boolean
|
||||
promise?: Promise<void>
|
||||
}
|
||||
|
||||
/** Manager-owned observers of a Session object's local state edges. */
|
||||
export interface SessionOptions {
|
||||
/** Catalog-discovered address selecting non-activating subagent transport. */
|
||||
@@ -95,13 +105,16 @@ export class Session implements SessionFace {
|
||||
// ---- Window and derived state (all private; the snapshot is the only read API) ----
|
||||
private baseSeq = SessionLogOffset(0)
|
||||
private hasMore = false
|
||||
private historyWasPartial = false
|
||||
private openState: OpenState = 'cold'
|
||||
private openError: RemoteFailure | null = null
|
||||
private openPromise: Promise<void> | null = null
|
||||
/** Bumped by stream replacement to invalidate an in-flight doOpen. Stale
|
||||
* passes drop all writes once the generation moves on. */
|
||||
private openGeneration = 0
|
||||
private loadingOlder = false
|
||||
private get loadingOlder(): boolean { return this.historyLoadStatus === 'loading' }
|
||||
private historyLoadStatus: SessionSnapshot['historyLoadStatus'] = 'idle'
|
||||
private olderRequest: OlderHistoryRequest | null = null
|
||||
/** Shared low-water target of the running jump loop; null when no jump is paging. */
|
||||
private jumpTargetSeq: SessionSeq | null = null
|
||||
/** The running jump loop's completion, shared by retargeting callers. */
|
||||
@@ -415,30 +428,95 @@ export class Session implements SessionFace {
|
||||
return promise
|
||||
}
|
||||
|
||||
/** Prepend one Turn-aligned page: at least 50 messages and two Turn starts, capped at 500 messages. */
|
||||
async loadOlder(): Promise<void> {
|
||||
if (this.openState !== 'open' || !this.hasMore || this.loadingOlder) return
|
||||
/** @inheritdoc */
|
||||
async loadOlder(options?: { deferConsumption?: boolean }): Promise<void> {
|
||||
const request = this.prepareOlder()
|
||||
await request?.promise
|
||||
if (request !== null && this.olderRequest === request
|
||||
&& (!options?.deferConsumption || request.page?.entries.length === 0)) this.consumeOlder()
|
||||
}
|
||||
|
||||
private prepareOlder(): OlderHistoryRequest | null {
|
||||
if (this.olderRequest !== null) return this.olderRequest
|
||||
const events = this.events
|
||||
if (events === undefined) return
|
||||
this.loadingOlder = true
|
||||
if (this.openState !== 'open' || !this.hasMore || this.loadingOlder || events === undefined) return null
|
||||
const request: OlderHistoryRequest = {
|
||||
generation: this.openGeneration,
|
||||
beforeSeq: this.baseSeq,
|
||||
page: null,
|
||||
invalidated: false,
|
||||
recovering: false,
|
||||
consuming: false,
|
||||
}
|
||||
this.olderRequest = request
|
||||
this.historyLoadStatus = 'loading'
|
||||
this.notifier.markDirty()
|
||||
try {
|
||||
await events.prepend({
|
||||
beforeSeq: this.baseSeq,
|
||||
...HISTORY_PAGE_OPTIONS,
|
||||
})
|
||||
} catch (error) {
|
||||
if (!isRemoteFailure(error)) {
|
||||
console.error('[session-controller] loadOlder failed:', error)
|
||||
request.promise = (async () => {
|
||||
try {
|
||||
await events.prepend({ beforeSeq: request.beforeSeq, ...HISTORY_PAGE_OPTIONS })
|
||||
if (this.olderRequest !== request) return
|
||||
if (request.invalidated || request.generation !== this.openGeneration || request.beforeSeq !== this.baseSeq) return
|
||||
const page = request.page
|
||||
const first = page?.entries[0]
|
||||
this.historyLoadStatus = page === null || (first === undefined ? page.hasMore : first.event.seq >= request.beforeSeq)
|
||||
? 'error' : 'pendingConsumption'
|
||||
} catch (error) {
|
||||
if (this.olderRequest !== request) return
|
||||
request.page = null
|
||||
this.historyLoadStatus = 'error'
|
||||
if (!isRemoteFailure(error)) console.error('[session-controller] loadOlder failed:', error)
|
||||
} finally {
|
||||
if (this.olderRequest === request) {
|
||||
if (request.invalidated && this.events === events) {
|
||||
// A replacement restores the transport cursor before another page uses the visible window.
|
||||
request.recovering = true
|
||||
this.historyLoadStatus = 'loading'
|
||||
events.restart()
|
||||
} else if (this.historyLoadStatus === 'error') this.olderRequest = null
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
}
|
||||
})()
|
||||
return request
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
consumeOlder(): boolean {
|
||||
const request = this.olderRequest
|
||||
if (request === null || request.consuming || this.historyLoadStatus !== 'pendingConsumption' || request.page === null) return false
|
||||
if (this.openState !== 'open' || request.invalidated || request.generation !== this.openGeneration
|
||||
|| request.beforeSeq !== this.baseSeq) {
|
||||
this.clearOlderHistory()
|
||||
return false
|
||||
}
|
||||
const page = request.page
|
||||
// Retain the request through synchronous feed notification so subscribers cannot reenter consumption.
|
||||
request.consuming = true
|
||||
try {
|
||||
this.prependWindow(page.entries, page.hasMore)
|
||||
return true
|
||||
} catch (error) {
|
||||
if (this.olderRequest === request) this.historyLoadStatus = 'error'
|
||||
throw error
|
||||
} finally {
|
||||
this.loadingOlder = false
|
||||
if (this.olderRequest === request) {
|
||||
this.olderRequest = null
|
||||
if (this.historyLoadStatus !== 'error') this.historyLoadStatus = 'idle'
|
||||
}
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
}
|
||||
|
||||
private clearOlderHistory(): void {
|
||||
this.olderRequest = null
|
||||
this.historyLoadStatus = this.pendingHistory === null ? 'idle' : 'loading'
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
|
||||
/** Jump loader: page backwards until the window covers seq (see ISession.loadThrough). */
|
||||
loadThrough(seq: SessionSeq): Promise<void> {
|
||||
this.consumeOlder()
|
||||
if (this.olderRequest !== null) return Promise.resolve()
|
||||
if (this.openState !== 'open' || !this.hasMore || this.baseSeq <= seq) return Promise.resolve()
|
||||
if (this.jumpPromise !== null) {
|
||||
// Retarget the running loop to the lowest requested seq.
|
||||
@@ -459,7 +537,7 @@ export class Session implements SessionFace {
|
||||
}
|
||||
this.pendingHistory = pending
|
||||
this.jumpTargetSeq = seq
|
||||
this.loadingOlder = true
|
||||
this.historyLoadStatus = 'loading'
|
||||
this.notifier.markDirty()
|
||||
// Stale-pass guard (the doOpen pattern): a resync mid-loop replaces the
|
||||
// stream generation; this pass then stops instead of paging the new
|
||||
@@ -467,27 +545,35 @@ export class Session implements SessionFace {
|
||||
const generation = this.openGeneration
|
||||
this.jumpPromise = (async () => {
|
||||
try {
|
||||
while (pending.hasMore && this.jumpTargetSeq !== null && pending.beforeSeq > this.jumpTargetSeq) {
|
||||
while (this.jumpTargetSeq !== null && pending.beforeSeq > this.jumpTargetSeq) {
|
||||
if (generation !== this.openGeneration) return
|
||||
const before = pending.beforeSeq
|
||||
await events.prepend({ beforeSeq: before, ...JUMP_PAGE_OPTIONS })
|
||||
if (generation !== this.openGeneration || this.pendingHistory !== pending) return
|
||||
// No-progress guard: an empty or dropped page that still claims more
|
||||
// history must end the loop, not spin it.
|
||||
if (pending.beforeSeq >= before) return
|
||||
if (pending.beforeSeq >= before) {
|
||||
if (pending.hasMore) this.historyLoadStatus = 'error'
|
||||
return
|
||||
}
|
||||
if (!pending.hasMore) return
|
||||
}
|
||||
} catch (error) {
|
||||
if (generation === this.openGeneration && this.pendingHistory === pending) this.historyLoadStatus = 'error'
|
||||
if (!isRemoteFailure(error)) {
|
||||
console.error('[session-controller] loadThrough failed:', error)
|
||||
}
|
||||
} finally {
|
||||
this.jumpTargetSeq = null
|
||||
this.jumpPromise = null
|
||||
this.pendingHistory = null
|
||||
this.loadingOlder = false
|
||||
if (generation === this.openGeneration && pending.pages.length > 0) {
|
||||
this.prependWindow(pending.pages.reverse().flat(), pending.hasMore)
|
||||
if (this.pendingHistory === pending) {
|
||||
this.jumpTargetSeq = null
|
||||
this.jumpPromise = null
|
||||
this.pendingHistory = null
|
||||
if (this.historyLoadStatus === 'loading') this.historyLoadStatus = 'idle'
|
||||
if (generation === this.openGeneration && pending.pages.length > 0) {
|
||||
this.prependWindow(pending.pages.reverse().flat(), pending.hasMore)
|
||||
}
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
})()
|
||||
return this.jumpPromise
|
||||
@@ -499,6 +585,7 @@ export class Session implements SessionFace {
|
||||
async resync(): Promise<void> {
|
||||
if (this.openState === 'cold') return // never opened: no window to rebuild (doOpen flips to 'loading' synchronously, so cold implies no in-flight open)
|
||||
this.openGeneration++
|
||||
this.clearOlderHistory()
|
||||
const events = this.events
|
||||
this.events = undefined
|
||||
await events?.dispose()
|
||||
@@ -606,6 +693,7 @@ export class Session implements SessionFace {
|
||||
else this.retireFailedSubmission(requestId)
|
||||
}
|
||||
this.openGeneration++
|
||||
this.clearOlderHistory()
|
||||
const events = this.events
|
||||
this.events = undefined
|
||||
await events?.dispose()
|
||||
@@ -655,7 +743,9 @@ export class Session implements SessionFace {
|
||||
)
|
||||
return
|
||||
case 'prepend':
|
||||
this.prependWindow(change.entries, change.hasMore)
|
||||
if (this.olderRequest !== null) {
|
||||
this.olderRequest.page = { entries: change.entries, hasMore: change.hasMore }
|
||||
} else this.prependWindow(change.entries, change.hasMore)
|
||||
return
|
||||
case 'append':
|
||||
this.publishAssistantEntry(this.assistantStream.acceptDurable(change.entry))
|
||||
@@ -676,8 +766,13 @@ export class Session implements SessionFace {
|
||||
// attempts makes a held notification reopen follow once for an atomic
|
||||
// page/baseline pair instead of applying it to an unrelated repair cut.
|
||||
const visible = this.assistantStream.replace(entries, assistantStream)
|
||||
if (this.olderRequest !== null && this.loadingOlder && !this.olderRequest.recovering) {
|
||||
this.olderRequest.invalidated = true
|
||||
this.olderRequest.page = null
|
||||
} else this.clearOlderHistory()
|
||||
this.baseSeq = SessionLogOffset(entries[0]?.event.seq ?? 0)
|
||||
this.hasMore = hasMore
|
||||
this.historyWasPartial ||= hasMore
|
||||
if (this.pendingHistory !== null) {
|
||||
this.pendingHistory.beforeSeq = this.baseSeq
|
||||
this.pendingHistory.hasMore = hasMore
|
||||
@@ -745,6 +840,7 @@ export class Session implements SessionFace {
|
||||
}
|
||||
this.baseSeq = entries[0] === undefined ? this.baseSeq : SessionLogOffset(entries[0].event.seq)
|
||||
this.hasMore = hasMore
|
||||
this.historyWasPartial ||= hasMore
|
||||
this.eventSource.prepend(entries, hasMore)
|
||||
}
|
||||
|
||||
@@ -892,6 +988,7 @@ export class Session implements SessionFace {
|
||||
if (generation !== this.openGeneration || this.events !== events) return
|
||||
if (!isRemoteFailure(error)) throw error
|
||||
this.openGeneration++
|
||||
this.clearOlderHistory()
|
||||
this.events = undefined
|
||||
this.openPromise = null
|
||||
this.openState = 'error'
|
||||
@@ -917,7 +1014,9 @@ export class Session implements SessionFace {
|
||||
openState: this.openState,
|
||||
openError: this.openError,
|
||||
hasMore: this.hasMore,
|
||||
historyWasPartial: this.historyWasPartial,
|
||||
loadingOlder: this.loadingOlder,
|
||||
historyLoadStatus: this.historyLoadStatus,
|
||||
promptError: this.promptError,
|
||||
blank: this.blankBit,
|
||||
lastAgentError: this.lastAgentError,
|
||||
|
||||
@@ -199,6 +199,194 @@ describe('paging', () => {
|
||||
expect(eventSeqs(session)).toEqual([...older, ...newer].map(event => event.seq))
|
||||
})
|
||||
|
||||
it('stages one page without hiding live events and consumes it once', async ({ mock, start }) => {
|
||||
const older = plainTurn(SessionSeq(0), 0, 'old', 'answer')
|
||||
const newer = plainTurn(SessionSeq(6), 1, 'new', 'answer')
|
||||
const session = await sessionBench(mock, start, SID)
|
||||
mock.stream(FOLLOW, followScript(history(newer, true)))
|
||||
mock.remote.session.page.mockImplementation(pageRule(history(older)))
|
||||
await session.open()
|
||||
await session.loadOlder({ deferConsumption: true })
|
||||
expect(session.getSnapshot()).toMatchObject({ historyLoadStatus: 'pendingConsumption', loadingOlder: false, hasMore: true })
|
||||
expect(eventSeqs(session)).toEqual(newer.map(event => event.seq))
|
||||
await session.loadOlder({ deferConsumption: true })
|
||||
expect(mock.log.requests(PAGE)).toHaveLength(1)
|
||||
await pushEvent(mock, ev.turnStart(SessionSeq(12), 2))
|
||||
const consumeStates: { status: string; loading: boolean }[] = []
|
||||
const stop = session.eventSource.subscribe(() => {
|
||||
session.handleRunning(true)
|
||||
const snapshot = session.getSnapshot()
|
||||
consumeStates.push({ status: snapshot.historyLoadStatus, loading: snapshot.loadingOlder })
|
||||
expect(session.consumeOlder()).toBe(false)
|
||||
void session.loadOlder({ deferConsumption: true })
|
||||
})
|
||||
try {
|
||||
expect(session.consumeOlder()).toBe(true)
|
||||
} finally {
|
||||
stop()
|
||||
}
|
||||
expect(consumeStates).toEqual([{ status: 'pendingConsumption', loading: false }])
|
||||
expect(session.consumeOlder()).toBe(false)
|
||||
expect(mock.log.requests(PAGE)).toHaveLength(1)
|
||||
expect(eventSeqs(session)).toEqual([...older, ...newer].map(event => event.seq).concat(SessionSeq(12)))
|
||||
expect(session.getSnapshot()).toMatchObject({ historyLoadStatus: 'idle', hasMore: false })
|
||||
})
|
||||
|
||||
it('joins a deferred request for default consumption without requesting another page', async ({ mock, start }) => {
|
||||
const older = plainTurn(SessionSeq(0), 0, 'old', 'answer')
|
||||
const newer = plainTurn(SessionSeq(6), 1, 'new', 'answer')
|
||||
const session = await sessionBench(mock, start, SID)
|
||||
mock.stream(FOLLOW, followScript(history(newer, true)))
|
||||
const gate = Promise.withResolvers<RemoteResult<SessionEventsPage>>()
|
||||
mock.remote.session.page.mockReturnValueOnce(gate.promise)
|
||||
await session.open()
|
||||
const preparing = session.loadOlder({ deferConsumption: true })
|
||||
expect(session.getSnapshot()).toMatchObject({ historyLoadStatus: 'loading', loadingOlder: true })
|
||||
const consuming = session.loadOlder()
|
||||
gate.resolve(history(older))
|
||||
await Promise.all([preparing, consuming])
|
||||
expect(mock.log.requests(PAGE)).toHaveLength(1)
|
||||
expect(eventSeqs(session)).toEqual([...older, ...newer].map(event => event.seq))
|
||||
expect(session.getSnapshot()).toMatchObject({ historyLoadStatus: 'idle', loadingOlder: false })
|
||||
})
|
||||
|
||||
it('default loading consumes an already prepared page without another request', async ({ mock, start }) => {
|
||||
const older = plainTurn(SessionSeq(0), 0, 'old', 'answer')
|
||||
const newer = plainTurn(SessionSeq(6), 1, 'new', 'answer')
|
||||
const session = await sessionBench(mock, start, SID)
|
||||
mock.stream(FOLLOW, followScript(history(newer, true)))
|
||||
mock.remote.session.page.mockImplementation(pageRule(history(older)))
|
||||
await session.open()
|
||||
await session.loadOlder({ deferConsumption: true })
|
||||
await session.loadOlder()
|
||||
expect(mock.log.requests(PAGE)).toHaveLength(1)
|
||||
expect(eventSeqs(session)).toEqual([...older, ...newer].map(event => event.seq))
|
||||
expect(session.getSnapshot()).toMatchObject({ historyLoadStatus: 'idle', loadingOlder: false })
|
||||
})
|
||||
|
||||
it('does not consume a newer prepared page when an obsolete default request completes', async ({ mock, start }) => {
|
||||
const older = plainTurn(SessionSeq(0), 0, 'old', 'answer')
|
||||
const newer = plainTurn(SessionSeq(6), 1, 'new', 'answer')
|
||||
const session = await sessionBench(mock, start, SID)
|
||||
mock.stream(FOLLOW, followScript(history(newer, true)))
|
||||
mock.remote.session.page.mockImplementation(pageRule(history(older)))
|
||||
await session.open()
|
||||
const obsolete = Promise.withResolvers<undefined>()
|
||||
const prepend = vi.spyOn(SessionEventStream.prototype, 'prepend').mockImplementationOnce(() => obsolete.promise)
|
||||
const loading = session.loadOlder()
|
||||
try {
|
||||
await session.resync()
|
||||
await session.loadOlder({ deferConsumption: true })
|
||||
obsolete.resolve(undefined)
|
||||
await loading
|
||||
expect(session.getSnapshot()).toMatchObject({ historyLoadStatus: 'pendingConsumption', loadingOlder: false })
|
||||
expect(eventSeqs(session)).toEqual(newer.map(event => event.seq))
|
||||
expect(session.consumeOlder()).toBe(true)
|
||||
} finally {
|
||||
obsolete.resolve(undefined)
|
||||
await loading
|
||||
prepend.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('retains partial-history knowledge across exhaustion and replacement only for the same Session instance', async ({ mock, start }) => {
|
||||
const older = plainTurn(SessionSeq(0), 0, 'old', 'answer')
|
||||
const newer = plainTurn(SessionSeq(6), 1, 'new', 'answer')
|
||||
const complete = [...older, ...newer]
|
||||
const session = await sessionBench(mock, start, SID)
|
||||
expect(session.getSnapshot().historyWasPartial).toBe(false)
|
||||
mock.stream(FOLLOW, followScript(history(newer, true)))
|
||||
mock.remote.session.page.mockImplementation(pageRule(history(older)))
|
||||
await session.open()
|
||||
expect(session.getSnapshot().historyWasPartial).toBe(true)
|
||||
await session.loadOlder()
|
||||
expect(session.getSnapshot()).toMatchObject({ hasMore: false, historyWasPartial: true })
|
||||
mock.stream(FOLLOW, followScript(history(complete, false)))
|
||||
await session.resync()
|
||||
expect(session.getSnapshot()).toMatchObject({ hasMore: false, historyWasPartial: true })
|
||||
mock.streams.fail(FOLLOW, new RemoteStreamCarrierError('replace complete window'))
|
||||
await vi.waitFor(() => { expect(mock.log.requests(FOLLOW)).toHaveLength(3) })
|
||||
await mock.streams.drained(FOLLOW)
|
||||
expect(session.getSnapshot()).toMatchObject({ hasMore: false, historyWasPartial: true })
|
||||
const client = await start()
|
||||
const fresh = new Session(SID, client.ctx.remote)
|
||||
onTestFinished(() => fresh.dispose())
|
||||
expect(fresh.getSnapshot().historyWasPartial).toBe(false)
|
||||
await fresh.open()
|
||||
expect(fresh.getSnapshot()).toMatchObject({ hasMore: false, historyWasPartial: false })
|
||||
})
|
||||
|
||||
it('reports no progress and allows an explicit retry to confirm exhaustion', async ({ mock, start }) => {
|
||||
const session = await sessionBench(mock, start, SID)
|
||||
mock.stream(FOLLOW, followScript(history(plainTurn(SessionSeq(6), 1, 'new', 'answer'), true)))
|
||||
mock.remote.session.page.mockImplementation(pageRule(history([], true)))
|
||||
await session.open()
|
||||
await session.loadOlder({ deferConsumption: true })
|
||||
expect(session.getSnapshot()).toMatchObject({ historyLoadStatus: 'error', loadingOlder: false, hasMore: true })
|
||||
expect(session.consumeOlder()).toBe(false)
|
||||
mock.remote.session.page.mockImplementation(pageRule(history([], false)))
|
||||
await session.loadOlder({ deferConsumption: true })
|
||||
expect(session.getSnapshot()).toMatchObject({ historyLoadStatus: 'idle', loadingOlder: false, hasMore: false })
|
||||
expect(mock.log.requests(PAGE)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('consumes a staged page before a turn jump fetches the remaining history', async ({ mock, start }) => {
|
||||
const oldest = plainTurn(SessionSeq(0), 0, 'oldest', 'answer')
|
||||
const middle = plainTurn(SessionSeq(6), 1, 'middle', 'answer')
|
||||
const newest = plainTurn(SessionSeq(12), 2, 'newest', 'answer')
|
||||
const session = await sessionBench(mock, start, SID)
|
||||
mock.stream(FOLLOW, followScript(history(newest, true)))
|
||||
mock.remote.session.page.mockImplementation(pageRule(request => history(
|
||||
request.beforeSeq === 12 ? middle : oldest, request.beforeSeq === 12,
|
||||
)))
|
||||
await session.open()
|
||||
await session.loadOlder({ deferConsumption: true })
|
||||
await session.loadThrough(SessionSeq(0))
|
||||
expect(eventSeqs(session)).toEqual([...oldest, ...middle, ...newest].map(event => event.seq))
|
||||
expect((mock.log.requests(PAGE) as SessionPageRequest[]).map(request => request.beforeSeq)).toEqual([12, 6])
|
||||
expect(session.getSnapshot()).toMatchObject({ historyLoadStatus: 'idle', loadingOlder: false, hasMore: false })
|
||||
})
|
||||
|
||||
it('invalidates a staged page when the event window is replaced', async ({ mock, start }) => {
|
||||
const session = await sessionBench(mock, start, SID)
|
||||
const older = plainTurn(SessionSeq(0), 0, 'old', 'answer')
|
||||
const newer = plainTurn(SessionSeq(6), 1, 'new', 'answer')
|
||||
mock.stream(FOLLOW, followScript(history(newer, true)))
|
||||
mock.remote.session.page.mockImplementation(pageRule(history(older)))
|
||||
await session.open()
|
||||
await session.loadOlder({ deferConsumption: true })
|
||||
await session.resync()
|
||||
expect(session.consumeOlder()).toBe(false)
|
||||
expect(session.getSnapshot()).toMatchObject({ historyLoadStatus: 'idle', loadingOlder: false, hasMore: true })
|
||||
expect(eventSeqs(session)).toEqual(newer.map(event => event.seq))
|
||||
})
|
||||
|
||||
it('repairs the transport cursor when follow replaces a window during an older request', async ({ mock, start }) => {
|
||||
const session = await sessionBench(mock, start, SID)
|
||||
const older = plainTurn(SessionSeq(0), 0, 'old', 'answer')
|
||||
const newer = plainTurn(SessionSeq(6), 1, 'new', 'answer')
|
||||
mock.stream(FOLLOW, followScript(history(newer, true)))
|
||||
const page = Promise.withResolvers<RemoteResult<SessionEventsPage>>()
|
||||
mock.remote.session.page.mockReturnValueOnce(page.promise)
|
||||
await session.open()
|
||||
const loading = session.loadOlder({ deferConsumption: true })
|
||||
await vi.waitFor(() => { expect(mock.log.requests(PAGE)).toHaveLength(1) })
|
||||
mock.streams.fail(FOLLOW, new RemoteStreamCarrierError('replace while paging'))
|
||||
await vi.waitFor(() => { expect(mock.log.requests(FOLLOW)).toHaveLength(2) })
|
||||
await mock.streams.drained(FOLLOW)
|
||||
page.resolve(history(older))
|
||||
await loading
|
||||
await vi.waitFor(() => {
|
||||
expect(mock.log.requests(FOLLOW)).toHaveLength(3)
|
||||
expect(session.getSnapshot().historyLoadStatus).toBe('idle')
|
||||
})
|
||||
expect(session.consumeOlder()).toBe(false)
|
||||
expect(eventSeqs(session)).toEqual(newer.map(event => event.seq))
|
||||
mock.remote.session.page.mockImplementation(pageRule(history(older)))
|
||||
await session.loadOlder()
|
||||
expect(eventSeqs(session)).toEqual([...older, ...newer].map(event => event.seq))
|
||||
})
|
||||
|
||||
it('installs a page without interpreting business replacement metadata', async ({ mock, start }) => {
|
||||
const session = await sessionBench(mock, start, SID)
|
||||
mock.stream(FOLLOW, followScript(history([
|
||||
@@ -218,7 +406,7 @@ describe('paging', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('drops a discontinuous older page fail-soft (window unchanged, hasMore cleared)', async ({ mock, start }) => {
|
||||
it('reports a discontinuous older page without claiming history exhaustion', async ({ mock, start }) => {
|
||||
const session = await sessionBench(mock, start, SID)
|
||||
mock.stream(FOLLOW, followScript(history(plainTurn(SessionSeq(10), 1, '新', '页'), true)))
|
||||
mock.remote.session.page.mockImplementation(pageRule(history(plainTurn(SessionSeq(0), 0, '断', '层'), true))) // tail seq 5, but baseSeq is 10 → hole
|
||||
@@ -229,7 +417,7 @@ describe('paging', () => {
|
||||
await session.loadOlder()
|
||||
const snapshot = session.getSnapshot()
|
||||
expect(session.eventSource.getSnapshot().entries).toEqual(windowBefore.entries)
|
||||
expect(snapshot.hasMore).toBe(false)
|
||||
expect(snapshot).toMatchObject({ hasMore: true, historyLoadStatus: 'error' })
|
||||
} finally {
|
||||
errorSpy.mockRestore()
|
||||
}
|
||||
@@ -247,11 +435,11 @@ describe('paging', () => {
|
||||
mock.remote.session.page.mockImplementation(pageRule(request => (request.beforeSeq === 12 ? history(middle, true) : history(oldest))))
|
||||
mock.remote.session.page.mockReturnValueOnce(gate.promise)
|
||||
const jump = session.loadThrough(SessionSeq(0))
|
||||
expect(session.getSnapshot().loadingOlder).toBe(true)
|
||||
expect(session.getSnapshot()).toMatchObject({ historyLoadStatus: 'loading', loadingOlder: true })
|
||||
gate.resolve(history(middle, true))
|
||||
await jump
|
||||
const snapshot = session.getSnapshot()
|
||||
expect(snapshot.loadingOlder).toBe(false)
|
||||
expect(snapshot).toMatchObject({ historyLoadStatus: 'idle', loadingOlder: false })
|
||||
expect(eventSeqs(session)).toEqual([...oldest, ...middle, ...newest].map(event => event.seq))
|
||||
expect(mock.log.requests(PAGE)).toMatchObject([
|
||||
{ beforeSeq: 12, maxMessages: 500, turnWindow: { minMessages: 200, minTurns: 2 } },
|
||||
@@ -325,7 +513,7 @@ describe('paging', () => {
|
||||
|
||||
expect(changed).toHaveBeenCalledOnce()
|
||||
expect(eventSeqs(session)).toEqual([...middle, ...newest].map(event => event.seq))
|
||||
expect(session.getSnapshot()).toMatchObject({ loadingOlder: false, hasMore: true })
|
||||
expect(session.getSnapshot()).toMatchObject({ historyLoadStatus: 'error', loadingOlder: false, hasMore: true })
|
||||
})
|
||||
|
||||
it('discards buffered jump pages when the Session replaces its stream', async ({ mock, start }) => {
|
||||
@@ -430,7 +618,19 @@ describe('paging', () => {
|
||||
mock.remote.session.page.mockImplementation(pageRule(history([], true))) // empty page still claiming more history
|
||||
await session.open()
|
||||
await session.loadThrough(SessionSeq(0))
|
||||
expect(session.getSnapshot().loadingOlder).toBe(false)
|
||||
expect(session.getSnapshot()).toMatchObject({ historyLoadStatus: 'error', loadingOlder: false })
|
||||
expect(mock.log.requests(PAGE)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('loadThrough treats a terminal empty page as exhaustion rather than an error', async ({ mock, start }) => {
|
||||
const session = await sessionBench(mock, start, SID)
|
||||
const newest = plainTurn(SessionSeq(12), 2, 'x', 'y')
|
||||
mock.stream(FOLLOW, followScript(history(newest, true)))
|
||||
mock.remote.session.page.mockImplementation(pageRule(history([], false)))
|
||||
await session.open()
|
||||
await session.loadThrough(SessionSeq(0))
|
||||
expect(session.getSnapshot()).toMatchObject({ historyLoadStatus: 'idle', loadingOlder: false, hasMore: false })
|
||||
expect(eventSeqs(session)).toEqual(newest.map(event => event.seq))
|
||||
expect(mock.log.requests(PAGE)).toHaveLength(1)
|
||||
})
|
||||
|
||||
|
||||
@@ -3,20 +3,20 @@
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/api/workspace-files/README.md
|
||||
/:
|
||||
en: f83c75aa73ae21dd
|
||||
zh: e8873aafd23a8038
|
||||
en: 47d66aa37cb2ffab
|
||||
zh: 1568b049534ffa55
|
||||
/deepseek-ai-dsh-api-workspace-files:
|
||||
en: d2885c72d12d9924
|
||||
zh: e111f03d02383229
|
||||
/deepseek-ai-dsh-api-workspace-files/summary:
|
||||
en: 27c562441286bdbf
|
||||
zh: 205dfac32c96873b
|
||||
en: 1c5fd2461a374199
|
||||
zh: 914bb3f5c6d3d523
|
||||
/deepseek-ai-dsh-api-workspace-files/table-of-contents:
|
||||
en: d152484eb41ac6b4
|
||||
zh: 09388d293f9be9cb
|
||||
/deepseek-ai-dsh-api-workspace-files/use-this-package:
|
||||
en: 874054ed5e950973
|
||||
zh: e8b55d62939c4c2f
|
||||
en: a7cf7f26d6183666
|
||||
zh: 832b0b33fd389771
|
||||
/deepseek-ai-dsh-api-workspace-files/use-this-package/addressing-and-paths:
|
||||
en: b9565cfa7a1da451
|
||||
zh: fc4078a58322fc7f
|
||||
@@ -27,14 +27,17 @@
|
||||
en: 93ca9e832fbfd632
|
||||
zh: 69412adba259c6f8
|
||||
/deepseek-ai-dsh-api-workspace-files/use-this-package/file-read-and-directory-checks:
|
||||
en: aa85c7754557a22f
|
||||
zh: c576d06749fbe0a5
|
||||
en: c53c72a4fab3d957
|
||||
zh: e20b359c6ea5caa5
|
||||
/deepseek-ai-dsh-api-workspace-files/use-this-package/search:
|
||||
en: f5d2244caef36e4b
|
||||
zh: d6df79a18f4caf36
|
||||
/deepseek-ai-dsh-api-workspace-files/use-this-package/the-change-feed:
|
||||
en: 8bd2d820a512d461
|
||||
zh: 0614ddd842ae7526
|
||||
/deepseek-ai-dsh-api-workspace-files/use-this-package/configuration:
|
||||
en: e606e8b22ac6e0d0
|
||||
zh: 13a7214448036d7b
|
||||
en: 99ce72f0f731c570
|
||||
zh: 3f4c9bbd62844f1d
|
||||
/deepseek-ai-dsh-api-workspace-files/use-this-package/failures:
|
||||
en: c53d597c8f255e69
|
||||
zh: 1e00318f9a34f3bb
|
||||
@@ -48,8 +51,8 @@
|
||||
en: c3a8852c33dcc0e9
|
||||
zh: d24fd47da7faaa29
|
||||
/deepseek-ai-dsh-api-workspace-files/understand-the-implementation/source-map:
|
||||
en: 596a75f43bfafda3
|
||||
zh: 3cb267a0946cd75e
|
||||
en: c18e235fbd03e6be
|
||||
zh: da1c4d03bf34657d
|
||||
/deepseek-ai-dsh-api-workspace-files/further-exploration:
|
||||
en: 71f00fb919d12181
|
||||
zh: 1e3819cd90dfca31
|
||||
@@ -60,8 +63,8 @@
|
||||
en: ca75c51c89c2c9b0
|
||||
zh: 03126f3b1aac5859
|
||||
/deepseek-ai-dsh-api-workspace-files/known-limitations-and-deferred-work:
|
||||
en: ee05e55f5d16f253
|
||||
zh: 0c937eeac8fa3f08
|
||||
en: 6e6286d98f3caf3f
|
||||
zh: ab4d13e020a9cc34
|
||||
/deepseek-ai-dsh-api-workspace-files/known-limitations-and-deferred-work/dev-note:
|
||||
en: e67f633dc6fe7773
|
||||
zh: e88aeb617ffb74ea
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
description: "Workspace file service for the web GUI: bounded reads and target-scoped filesystem watches, plus directory listing inside the Session workspace root."
|
||||
description: "Workspace file service for the web GUI: bounded reads and target-scoped filesystem watches, plus directory listing and name or path search inside the Session workspace root."
|
||||
kind: "package-reference"
|
||||
---
|
||||
|
||||
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
## Summary
|
||||
|
||||
Use this package to preview files readable through a Session's filesystem from the web client. It reads UTF-8 text by page, reads bounded byte windows or complete files, resolves related files from a base file's directory, and reports file metadata. Follow changes to a file or a directory's direct entries. File reads and watches may target paths outside the workspace; directory listing and watches remain workspace-scoped. The service exposes no mutation operation.
|
||||
Use this package to preview files readable through a Session's filesystem from the web client. It reads UTF-8 text by page, reads bounded byte windows or complete files, resolves related files from a base file's directory, and reports file metadata. Follow changes to a file or a directory's direct entries. Search the workspace for entries by name or relative path. File reads and watches may target paths outside the workspace; directory listing, search, and directory watches remain workspace-scoped. The service exposes no mutation operation.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
@@ -25,7 +25,7 @@ Use this package to preview files readable through a Session's filesystem from t
|
||||
<a id="use-this-package"></a>
|
||||
## Use this package
|
||||
|
||||
Mount the package beside `dsh-fs`, `dsh-sandbox-policy`, the Session store, and the Typert Gateway; the bundle does so right after the Session Controller. Every method takes the Session identity on the wire, so a Client calls `remote.workspaceFiles.read(sessionId, path, range, signal)`, `stat(sessionId, path, signal)`, `readBytes(sessionId, path, options, signal)`, `list(sessionId, path, signal)`, or `changes(sessionId, path, signal)` and never names a root itself. The Host reads a live Session header or uses persistence `stat` for a cold Session; it does not activate an Agent, read the event body, or borrow a parent Session's root. Session persistence is optional for live reads, but without it a cold Session cannot resolve and the Gateway returns `gateway/lookup-not-found`.
|
||||
Mount the package beside `dsh-fs`, `dsh-sandbox-policy`, the Session store, and the Typert Gateway; the bundle does so right after the Session Controller. Every method takes the Session identity on the wire, so a Client calls `remote.workspaceFiles.read(sessionId, path, range, signal)`, `stat(sessionId, path, signal)`, `readBytes(sessionId, path, options, signal)`, `list(sessionId, path, signal)`, `search(sessionId, request, signal)`, or `changes(sessionId, path, signal)` and never names a root itself. The Host reads a live Session header or uses persistence `stat` for a cold Session; it does not activate an Agent, read the event body, or borrow a parent Session's root. Session persistence is optional for live reads, but without it a cold Session cannot resolve and the Gateway returns `gateway/lookup-not-found`.
|
||||
|
||||
| Method | Returns | Purpose |
|
||||
|---|---|---|
|
||||
@@ -33,6 +33,7 @@ Mount the package beside `dsh-fs`, `dsh-sandbox-policy`, the Session store, and
|
||||
| `read(path, { offset?, limit? })` | `WorkspaceFileText` = stat + `{ offset, text, lines, eof }` | One window of lines from a UTF-8 text file; `lines` counts them, so one empty line and a page past the end read differently |
|
||||
| `readBytes(path, { range?, baseFile? })` | `WorkspaceFileBytes` = stat + `{ offset, data, eof }` | Complete file or bounded byte range as `Uint8Array`; optionally resolve from another file's directory |
|
||||
| `list(path)` | `WorkspaceDirectoryListing { path, entries, truncated }` | Direct children of one directory |
|
||||
| `search({ query, excludedPaths, includeHidden, respectGitignore })` | `WorkspaceFileSearchResult { matches, truncated, incomplete }` | Entries anywhere in the workspace whose name or relative path contains the query |
|
||||
| `changes(path)` | stream of `WorkspaceFileWatchFrame` | Subscription readiness, then invalidations of one file or a directory's direct entries |
|
||||
|
||||
### Addressing and paths
|
||||
@@ -51,7 +52,16 @@ Pass the required options object as `{}` for a complete file under `maxFileBytes
|
||||
|
||||
### File-read and directory checks
|
||||
|
||||
Every operation first uses `lstat` to reject a missing path or the wrong file kind; the file-reading operations also refuse a final symlink, even one pointing back inside the workspace. `list` instead follows a final link — a directory symlink on any platform, including a Windows junction — and requires it to resolve to a directory inside the workspace root, so a linked directory lists like its target; `changes` confines the watched directory the same way. File operations then resolve and read through the composed filesystem without an additional workspace-containment check. The configured page, window, complete-file, and listing caps still apply. Text pages additionally reject invalid UTF-8 and NUL bytes; byte reads do not decode content. An empty read or listing path is a `gateway/bad-request`.
|
||||
Every path-taking operation first uses `lstat` to reject a missing path or the wrong file kind; the file-reading operations also refuse a final symlink, even one pointing back inside the workspace. `list` instead follows a final link — a directory symlink on any platform, including a Windows junction — and requires it to resolve to a directory inside the workspace root, so a linked directory lists like its target; `changes` confines the watched directory the same way. File operations then resolve and read through the composed filesystem without an additional workspace-containment check. The configured page, window, complete-file, and listing caps still apply. Text pages additionally reject invalid UTF-8 and NUL bytes; byte reads do not decode content. An empty read or listing path is a `gateway/bad-request`.
|
||||
|
||||
<a id="search"></a>
|
||||
### Search
|
||||
|
||||
`search` trims `query` and refuses an empty result as `gateway/bad-request`. Queries without `/` match names; queries with `/` match `/`-joined workspace-relative paths. Both use case-insensitive substring matching. Matches include files and deduplicated ancestor directories inferred from those files; empty directories are absent. An `excludedPaths` value without `/` excludes that name at every depth; a value with `/` excludes the exact path rooted at the workspace. Glob metacharacters are literal. With `includeHidden` false, hidden files and directories are skipped; VCS metadata is always excluded.
|
||||
|
||||
`respectGitignore` uses native ripgrep ignore rules, including its repository detection, nested and parent `.gitignore`, `.git/info/exclude`, global Git excludes and `.ignore`. Disabling it passes `--no-ignore`. The service does not parse Git configuration or apply `core.ignorecase` to ignore matching. `maxFileBytes` does not bound ignore files; read failures follow rg exit semantics.
|
||||
|
||||
Each call obtains `subprocess` on demand and runs packaged `rg --files --sort=path`. It considers at most `search.maxEntries` listed files, sorts matches from that bounded set by depth then path, and keeps `search.maxResults`; extra matches set `truncated`. An additional file record detects overflow without contributing matches. Exceeding the file cap terminates rg and sets `incomplete`; rg exit 2 also returns partial results, while exit 1 means no results. File and directory links are neither returned nor followed. Cancellation reaches the subprocess and rejects with the signal reason after cleanup. Missing subprocess, launch/execution failure and timeout all fail with `workspace-file/search-failed`, with the cause explained in the message; no JS traversal fallback exists, and other file operations remain available.
|
||||
|
||||
### The change feed
|
||||
|
||||
@@ -65,6 +75,11 @@ Every operation first uses `lstat` to reject a missing path or the wrong file ki
|
||||
| `maxFileBytes` | `33554432` (32 MiB) | Inclusive complete-file cap for `readBytes` without `range`; larger files fail with `too-large` |
|
||||
| `maxLines` | `5000` | Default and largest page size in lines; a larger `limit` is refused |
|
||||
| `maxEntries` | `2000` | Cap on returned directory entries; the rest is dropped and reported cut |
|
||||
| `search.maxResults` | `512` | Returned match cap; additional matches set `truncated` |
|
||||
| `search.maxEntries` | `100000` | Cap on files considered from rg output; an extra file terminates the process and sets `incomplete` |
|
||||
| `search.graceMs` | `3000` | Process termination grace in milliseconds |
|
||||
| `search.timeoutMs` | `30000` | Search deadline in milliseconds; expiry rejects |
|
||||
| `search.stderrMaxBytes` | `65536` | Retained process stderr diagnostic byte cap |
|
||||
|
||||
The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-api-workspace-files) is the exhaustive source for every accepted field and its JSDoc.
|
||||
|
||||
@@ -100,7 +115,8 @@ Complete-file reads delegate size enforcement to `fs.readBytes` and return raw b
|
||||
|
||||
| File | Role |
|
||||
|---|---|
|
||||
| [`src/index.ts`](src/index.ts) | `WorkspaceFiles`: the `workspaceFiles` service and Remote namespace, `Config`, the gates, the page cutter, `read`, `readBytes`, `stat`, `list` |
|
||||
| [`src/index.ts`](src/index.ts) | `WorkspaceFiles`: the `workspaceFiles` service and Remote namespace, `Config`, the gates, the page cutter, `read`, `readBytes`, `stat`, `list`, `search` |
|
||||
| [`src/search.ts`](src/search.ts) | rg arguments, file-ancestor inference, sorting and bounded search |
|
||||
| [`src/changes.ts`](src/changes.ts) | `WorkspaceChangeFeed`: target watchers, matching `fs/observed` notifications, and one queue per generation |
|
||||
| [`src/types.ts`](src/types.ts) | Wire types and the `RemoteErrorDetailsMap` codes, published as `./types` for Client packages |
|
||||
| [`src/client/index.ts`](src/client/index.ts), [`provider.ts`](src/client/provider.ts), [`change-feed.ts`](src/client/change-feed.ts) | Browser plugin, file metadata, and per-target change feeds |
|
||||
@@ -145,6 +161,7 @@ None; this package neither assembles nor sends a provider request.
|
||||
- **Reads are not transactional** — result metadata comes from stat before content is read; a concurrent write can make the reported version and returned contents differ.
|
||||
- **Unbounded generation queue** — a `changes` generation buffers operation observations and target invalidations until its consumer pulls; a stalled consumer grows Host memory for the life of the stream.
|
||||
- **`maxEntries` bounds the answer, not the listing** — `list` asks `ctx.fs.listDir` for every child and cuts the array afterwards, so a directory far above the cap still costs the Host the whole listing (on `fs-local`, one stat per child); bounding that work needs a limit on the filesystem seam's `listDir`.
|
||||
- **Search execution world** — each search starts rg without an index. The subprocess provider must execute the Host-packaged binary and share the filesystem workspace; otherwise search fails, including SSH profiles missing that path. The path prefix read before the file cap can omit shallower matches; `incomplete` reports that limitation.
|
||||
- **Dead feeds retain metadata** — after the Host ends `changes` or the stream fails terminally, open values retain their last state until reopened.
|
||||
|
||||
<a id="dev-note"></a>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
description: "面向 Web GUI 的工作区文件服务:有界文件读取、按目标监听文件系统变化,以及 Session 工作区根内的目录列举。"
|
||||
description: "面向 Web GUI 的工作区文件服务:有界文件读取、按目标监听文件系统变化,以及 Session 工作区根内的目录列举与按名称或路径搜索。"
|
||||
kind: "package-reference"
|
||||
---
|
||||
|
||||
@@ -9,7 +9,7 @@ kind: "package-reference"
|
||||
|
||||
## 概述
|
||||
|
||||
使用本包可从 Web Client 预览 Session 文件系统允许读取的文件。它按页读取 UTF-8 文本、按有界窗口或完整文件读取原始字节、从基文件目录解析关联文件,并报告文件元数据。可以跟随一个文件或一个目录直接子项的变化。文件读取和监听可以指向工作区外路径;目录列举和监听仍限定于工作区。本服务不提供修改操作。
|
||||
使用本包可从 Web Client 预览 Session 文件系统允许读取的文件。它按页读取 UTF-8 文本、按有界窗口或完整文件读取原始字节、从基文件目录解析关联文件,并报告文件元数据。可以跟随一个文件或一个目录直接子项的变化。可以按名称或相对路径搜索工作区条目。文件读取和监听可以指向工作区外路径;目录列举、搜索和目录监听仍限定于工作区。本服务不提供修改操作。
|
||||
|
||||
## 目录
|
||||
|
||||
@@ -25,7 +25,7 @@ kind: "package-reference"
|
||||
<a id="use-this-package"></a>
|
||||
## 使用本包
|
||||
|
||||
把本包与 `dsh-fs`、`dsh-sandbox-policy`、Session store 和 Typert Gateway 一起挂载;bundle 把它紧随 Session Controller 之后挂载。每个方法都在线路上携带 Session 身份,Client 调用 `remote.workspaceFiles.read(sessionId, path, range, signal)`、`stat(sessionId, path, signal)`、`readBytes(sessionId, path, options, signal)`、`list(sessionId, path, signal)` 或 `changes(sessionId, path, signal)`,从不自己指定根。Host 读取 live Session header,cold Session 则使用持久层 `stat`;它不会激活 Agent、读取事件正文或借用父 Session 的根。live 读取不要求挂载 Session persistence;未挂载时 cold Session 无法解析,Gateway 返回 `gateway/lookup-not-found`。
|
||||
把本包与 `dsh-fs`、`dsh-sandbox-policy`、Session store 和 Typert Gateway 一起挂载;bundle 把它紧随 Session Controller 之后挂载。每个方法都在线路上携带 Session 身份,Client 调用 `remote.workspaceFiles.read(sessionId, path, range, signal)`、`stat(sessionId, path, signal)`、`readBytes(sessionId, path, options, signal)`、`list(sessionId, path, signal)`、`search(sessionId, request, signal)` 或 `changes(sessionId, path, signal)`,从不自己指定根。Host 读取 live Session header,cold Session 则使用持久层 `stat`;它不会激活 Agent、读取事件正文或借用父 Session 的根。live 读取不要求挂载 Session persistence;未挂载时 cold Session 无法解析,Gateway 返回 `gateway/lookup-not-found`。
|
||||
|
||||
| 方法 | 返回 | 用途 |
|
||||
|---|---|---|
|
||||
@@ -33,6 +33,7 @@ kind: "package-reference"
|
||||
| `read(path, { offset?, limit? })` | `WorkspaceFileText` = stat + `{ offset, text, lines, eof }` | UTF-8 文本文件的一个行窗口;`lines` 计行数,使单个空行与越过文件末尾的页可区分 |
|
||||
| `readBytes(path, { range?, baseFile? })` | `WorkspaceFileBytes` = stat + `{ offset, data, eof }` | 以 `Uint8Array` 返回完整文件或有界字节范围;可从另一个文件所在目录解析目标 |
|
||||
| `list(path)` | `WorkspaceDirectoryListing { path, entries, truncated }` | 一个目录的直接子项 |
|
||||
| `search({ query, excludedPaths, includeHidden, respectGitignore })` | `WorkspaceFileSearchResult { matches, truncated, incomplete }` | 工作区任意位置中名称或相对路径包含查询的条目 |
|
||||
| `changes(path)` | `WorkspaceFileWatchFrame` 流 | 订阅就绪确认,随后为单个文件或目录直接子项的失效通知 |
|
||||
|
||||
### 寻址与路径
|
||||
@@ -51,7 +52,16 @@ kind: "package-reference"
|
||||
|
||||
### 文件读取与目录检查
|
||||
|
||||
每项操作都先通过 `lstat` 拒绝不存在的路径或错误的文件类型;读取文件的操作还拒绝末端符号链接,包括指回工作区内的链接。`list` 改为跟随末端链接——任何平台上的目录符号链接,包括 Windows 目录联接——要求它解析为工作区内的目录,因此被链接的目录与其目标一样可列举;`changes` 也以同样方式把被监听的目录限制在工作区内。文件操作随后通过组合文件系统解析和读取,不做额外的工作区包含检查。配置的分页、窗口、完整文件和目录列举上限仍然适用。文本页还拒绝无效 UTF-8 与 NUL 字节;字节读取不解码内容。读取或列举的空路径是 `gateway/bad-request`。
|
||||
每个带路径的操作都先通过 `lstat` 拒绝不存在的路径或错误的文件类型;读取文件的操作还拒绝末端符号链接,包括指回工作区内的链接。`list` 改为跟随末端链接——任何平台上的目录符号链接,包括 Windows 目录联接——要求它解析为工作区内的目录,因此被链接的目录与其目标一样可列举;`changes` 也以同样方式把被监听的目录限制在工作区内。文件操作随后通过组合文件系统解析和读取,不做额外的工作区包含检查。配置的分页、窗口、完整文件和目录列举上限仍然适用。文本页还拒绝无效 UTF-8 与 NUL 字节;字节读取不解码内容。读取或列举的空路径是 `gateway/bad-request`。
|
||||
|
||||
<a id="search"></a>
|
||||
### 搜索
|
||||
|
||||
`search` 对 `query` 做 trim,结果为空时以 `gateway/bad-request` 拒绝。不含 `/` 的查询按名称匹配,含 `/` 的查询按以 `/` 连接的工作区相对路径匹配;两者都为不区分大小写的子串匹配。匹配包含文件与从文件推导并去重的祖先目录;空目录不出现。`excludedPaths` 不含 `/` 的值按任意深度的名称排除,含 `/` 的值只排除根下的精确相对路径;所有 glob 元字符均按字面处理。`includeHidden` 为 false 时跳过隐藏文件与目录;VCS 元数据始终排除。
|
||||
|
||||
`respectGitignore` 使用 ripgrep 原生忽略规则,包括其仓库检测、嵌套与祖先 `.gitignore`、`.git/info/exclude`、全局 Git 排除文件及 `.ignore`。关闭时传入 `--no-ignore`。本服务不解析 Git 配置,也不把 `core.ignorecase` 用于忽略匹配。`maxFileBytes` 不限制忽略文件;读取错误沿用 rg 的退出语义。
|
||||
|
||||
每次调用通过按需获取的 `subprocess` 运行打包的 `rg --files --sort=path`。最多处理 rg 列出的 `search.maxEntries` 个文件,在此有界文件集内按深度与路径排序并截到 `search.maxResults`;匹配数超过结果上限时置 `truncated`。额外的一条文件记录只用于检测超限,不贡献匹配。超过文件数上限会终止 rg 并置 `incomplete`,rg 退出码 2 同样表示部分结果;退出码 1 表示无结果。链接文件与目录均不返回,也不跟随。取消信号传给子进程,调用等待进程清理后以 signal 的 reason 拒绝。缺少 subprocess、启动或执行失败以及超时均以 `workspace-file/search-failed` 失败,消息说明具体原因;不回退到 JS 遍历,其他文件操作不受影响。
|
||||
|
||||
### 变更流
|
||||
|
||||
@@ -65,6 +75,11 @@ kind: "package-reference"
|
||||
| `maxFileBytes` | `33554432`(32 MiB) | `readBytes` 未传 `range` 时的完整文件字节上限(含);更大文件以 `too-large` 失败 |
|
||||
| `maxLines` | `5000` | 页大小的缺省值与上限(行);更大的 `limit` 被拒绝 |
|
||||
| `maxEntries` | `2000` | 返回目录条目数上限;其余丢弃并报告截断 |
|
||||
| `search.maxResults` | `512` | 返回匹配数上限;更多匹配置 `truncated` |
|
||||
| `search.maxEntries` | `100000` | 处理 rg 输出的文件数上限;发现额外文件时终止进程并置 `incomplete` |
|
||||
| `search.graceMs` | `3000` | 进程终止宽限期(毫秒) |
|
||||
| `search.timeoutMs` | `30000` | 搜索截止时间(毫秒);到期拒绝调用 |
|
||||
| `search.stderrMaxBytes` | `65536` | 子进程 stderr 诊断保留字节上限 |
|
||||
|
||||
生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-api-workspace-files)是每个可接受字段及其 JSDoc 的完备来源。
|
||||
|
||||
@@ -100,7 +115,8 @@ kind: "package-reference"
|
||||
|
||||
| 文件 | 职责 |
|
||||
|---|---|
|
||||
| [`src/index.ts`](src/index.ts) | `WorkspaceFiles`:`workspaceFiles` 服务与 Remote 命名空间、`Config`、四道关、切页器、`read`、`readBytes`、`stat`、`list` |
|
||||
| [`src/index.ts`](src/index.ts) | `WorkspaceFiles`:`workspaceFiles` 服务与 Remote 命名空间、`Config`、四道关、切页器、`read`、`readBytes`、`stat`、`list`、`search` |
|
||||
| [`src/search.ts`](src/search.ts) | rg 参数、文件祖先推导、排序与有界搜索 |
|
||||
| [`src/changes.ts`](src/changes.ts) | `WorkspaceChangeFeed`:目标监听、匹配的 `fs/observed` 通知与每代流各自的队列 |
|
||||
| [`src/types.ts`](src/types.ts) | 线路类型与 `RemoteErrorDetailsMap` 错误码,以 `./types` 发布给 Client 包 |
|
||||
| [`src/client/index.ts`](src/client/index.ts)、[`provider.ts`](src/client/provider.ts)、[`change-feed.ts`](src/client/change-feed.ts) | 浏览器插件、文件元数据与按目标建立的变更流 |
|
||||
@@ -145,6 +161,7 @@ Typert 生成 `./typert` 与 `./remote` 暴露的 Host 与 Client Remote 产物
|
||||
- **读取不具备事务性**——结果元数据来自内容读取之前的 stat;并发写入可能使报告版本与返回内容不一致。
|
||||
- **generation 队列无界**——一个 `changes` generation 会缓冲操作观察与目标失效通知直到消费方 pull;停滞的消费方会在流的生命期内持续增长 Host 内存。
|
||||
- **`maxEntries` 限制的是答案,不是列举**——`list` 让 `ctx.fs.listDir` 列出全部子项后再截断数组,远超上限的目录仍让 Host 付出整个列举的代价(`fs-local` 上每个子项一次 stat);要限制这份工作,需要文件系统 seam 的 `listDir` 支持上限。
|
||||
- **搜索执行环境**——每次搜索重新运行 rg,不维护索引。子进程提供方必须能执行 Host 打包的二进制,并与文件系统共享工作区;不满足时搜索失败,包括缺少该路径的 SSH 配置。文件数上限前的路径前缀可能漏掉较浅的匹配;`incomplete` 明确报告这种不完整性。
|
||||
- **失效流保留元数据**——Host 结束 `changes` 或流终态失败后,已打开的值保持最后已知状态,直到重新打开。
|
||||
|
||||
<a id="dev-note"></a>
|
||||
|
||||
@@ -56,7 +56,8 @@
|
||||
"@deepseek-ai/dsh-deque": "workspace:*",
|
||||
"@deepseek-ai/dsh-typert-protocol": "workspace:*",
|
||||
"@deepseek-ai/schemastery": "workspace:~",
|
||||
"zod": "4.6.6"
|
||||
"zod": "4.6.6",
|
||||
"@deepseek-ai/dsh-util-ripgrep": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:~"
|
||||
@@ -73,7 +74,9 @@
|
||||
"@deepseek-ai/dsh-sandbox-policy": "workspace:*",
|
||||
"@deepseek-ai/dsh-session": "workspace:*",
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:*",
|
||||
"@deepseek-ai/dsh-util-workspace-path": "workspace:*"
|
||||
"@deepseek-ai/dsh-util-workspace-path": "workspace:*",
|
||||
"@deepseek-ai/dsh-subprocess": "workspace:*",
|
||||
"@deepseek-ai/dsh-subprocess-local": "workspace:*"
|
||||
},
|
||||
"files": [
|
||||
"locale/*.json",
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
/**
|
||||
* Workspace file service: read-only file previews, workspace directory
|
||||
* listings, and the filesystem-observation change feed, exposed as
|
||||
* `workspaceFiles`.
|
||||
* listings, workspace-wide name and path search, and the
|
||||
* filesystem-observation change feed, exposed as `workspaceFiles`.
|
||||
*
|
||||
* File reads follow the composed filesystem's read access, including paths
|
||||
* outside the workspace. The selected Session header supplies the base for
|
||||
* relative paths, with the sandbox policy root as its no-cwd fallback, not a
|
||||
* read-containment restriction. Directory listings and change observations
|
||||
* remain workspace-scoped. File-kind checks and configured read caps apply to
|
||||
* read-containment restriction. Directory listings, searches, and change
|
||||
* observations remain workspace-scoped; a search reports workspace-relative
|
||||
* paths. File-kind checks and configured read caps apply to
|
||||
* every preview; this service exposes no mutations.
|
||||
*
|
||||
* A page is cut from `streamText`, which decodes and rejects non-UTF-8 as it
|
||||
@@ -30,6 +31,7 @@ import type {} from '@deepseek-ai/dsh-session-persistence'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import { Remote, RemoteError, TypertRemoteService, type TypertLookup } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import { WorkspaceChangeFeed } from './changes.ts'
|
||||
import { searchWorkspace, type SearchLimits } from './search.ts'
|
||||
import type {
|
||||
WorkspaceByteRange,
|
||||
WorkspaceByteReadOptions,
|
||||
@@ -37,6 +39,8 @@ import type {
|
||||
WorkspaceDirectoryListing,
|
||||
WorkspaceFileBytes,
|
||||
WorkspaceFileRange,
|
||||
WorkspaceFileSearchRequest,
|
||||
WorkspaceFileSearchResult,
|
||||
WorkspaceFileStat,
|
||||
WorkspaceFileText,
|
||||
WorkspaceFileWatchFrame,
|
||||
@@ -66,7 +70,7 @@ declare module '@deepseek-ai/dsh-typert-protocol' {
|
||||
}
|
||||
}
|
||||
|
||||
/** Deployment caps on one page or one listing. */
|
||||
/** Deployment caps on one page, one listing, or one search. */
|
||||
export interface Config {
|
||||
/**
|
||||
* Inclusive byte cap on one page's text and on one byte window.
|
||||
@@ -82,6 +86,8 @@ export interface Config {
|
||||
readonly maxLines: number
|
||||
/** Cap on returned directory entries; the rest is dropped and reported cut. */
|
||||
readonly maxEntries: number
|
||||
/** Budgets on one search's matches, listed files, deadline and ripgrep process. */
|
||||
readonly search: SearchLimits
|
||||
}
|
||||
|
||||
/** One page cut from a decoded text stream. */
|
||||
@@ -188,13 +194,20 @@ export class WorkspaceFiles extends TypertRemoteService {
|
||||
maxFileBytes: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER - 1).default(32 * 1024 * 1024),
|
||||
maxLines: z.number().step(1).min(1).default(5000),
|
||||
maxEntries: z.number().step(1).min(1).default(2000),
|
||||
search: z.object({
|
||||
maxResults: z.number().step(1).min(1).default(512),
|
||||
maxEntries: z.number().step(1).min(1).default(100000),
|
||||
graceMs: z.number().step(1).min(1).max(2147483647).default(3000),
|
||||
timeoutMs: z.number().step(1).min(1).max(2147483647).default(30000),
|
||||
stderrMaxBytes: z.number().step(1).min(1).default(65536),
|
||||
}),
|
||||
})
|
||||
|
||||
private readonly feed: WorkspaceChangeFeed
|
||||
|
||||
/**
|
||||
* @param ctx - Host context carrying the filesystem and the sandbox policy.
|
||||
* @param config - deployment caps on one page or one listing.
|
||||
* @param config - deployment caps on one page, one listing, or one search.
|
||||
*/
|
||||
constructor(ctx: Context, private readonly config: Config) {
|
||||
super(ctx, 'workspaceFiles')
|
||||
@@ -328,6 +341,52 @@ export class WorkspaceFiles extends TypertRemoteService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search files and their inferred ancestor directories with packaged ripgrep.
|
||||
* Native ignore rules apply unless disabled; VCS metadata, symlinks and empty
|
||||
* directories are absent. Within the bounded set of listed files, matches
|
||||
* sort by depth, then workspace-relative path.
|
||||
* @param workspaceFileScope - header-derived workspace root for the Session identity on the wire.
|
||||
* @param request - the query, literal exclusions, and hidden/ignore switches.
|
||||
* @param signal - caller cancellation, forwarded to the subprocess; rejection preserves its reason.
|
||||
* @returns matches capped by search.maxResults; incomplete marks excess rg files or exit 2.
|
||||
* @throws RemoteError `gateway/bad-request`: the query is blank.
|
||||
* @throws RemoteError `workspace-file/not-found`: the workspace root does not exist.
|
||||
* @throws RemoteError `workspace-file/not-directory`: the workspace root is not a directory.
|
||||
* @throws RemoteError `workspace-file/search-failed`: subprocess is missing, rg cannot launch or execute, or the search times out.
|
||||
*/
|
||||
@Remote
|
||||
async search(
|
||||
workspaceFileScope: WorkspaceFileScope,
|
||||
request: WorkspaceFileSearchRequest,
|
||||
signal: AbortSignal,
|
||||
): Promise<WorkspaceFileSearchResult> {
|
||||
const query = request.query.trim()
|
||||
if (query.length === 0) throw new RemoteError('gateway/bad-request', 'query must not be blank', {})
|
||||
signal.throwIfAborted()
|
||||
const { workspaceRoot } = workspaceFileScope
|
||||
const root = await this.ctx.fs.resolve(workspaceRoot, { signal })
|
||||
const info = await this.ctx.fs.stat(root, signal)
|
||||
if (info === undefined) {
|
||||
throw new RemoteError('workspace-file/not-found', `no entry at "${workspaceRoot}"`, { path: workspaceRoot })
|
||||
}
|
||||
if (info.type !== 'directory') {
|
||||
throw new RemoteError('workspace-file/not-directory', `"${workspaceRoot}" is a ${info.type}`, { path: workspaceRoot, kind: info.type })
|
||||
}
|
||||
const subprocess = this.ctx.get('subprocess')
|
||||
if (subprocess === undefined) {
|
||||
throw new RemoteError('workspace-file/search-failed', 'Workspace file search requires a subprocess provider', {})
|
||||
}
|
||||
return searchWorkspace(subprocess, {
|
||||
cwd: this.ctx.fs.processPath(root),
|
||||
query,
|
||||
excludedPaths: request.excludedPaths,
|
||||
includeHidden: request.includeHidden,
|
||||
respectGitignore: request.respectGitignore,
|
||||
...this.config.search,
|
||||
}, signal)
|
||||
}
|
||||
|
||||
/**
|
||||
* Watch one file or a directory's direct entries in the Session's filesystem.
|
||||
* Files use the backend's read authority; directories remain workspace-scoped.
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
/** Workspace-relative name and path discovery through packaged ripgrep. */
|
||||
import { readRipgrepLines, spawnRipgrep, type RipgrepLinesResult } from '@deepseek-ai/dsh-util-ripgrep'
|
||||
import type { SubprocessRuntime } from '@deepseek-ai/dsh-subprocess'
|
||||
import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import type { WorkspaceFileSearchMatch, WorkspaceFileSearchResult } from './types.ts'
|
||||
|
||||
/** Deployment budgets on one search's results, listed files and process. */
|
||||
export interface SearchLimits {
|
||||
/** Cap on returned matches; additional matches report truncation after the bounded scan. */
|
||||
readonly maxResults: number
|
||||
/** Cap on files considered from rg output; an extra file terminates rg and reports the search incomplete. */
|
||||
readonly maxEntries: number
|
||||
/** Search process termination grace in milliseconds. */
|
||||
readonly graceMs: number
|
||||
/** Search deadline in milliseconds; expiry rejects rather than returning partial results. */
|
||||
readonly timeoutMs: number
|
||||
/** Byte cap on retained search-process stderr diagnostics. */
|
||||
readonly stderrMaxBytes: number
|
||||
}
|
||||
|
||||
/** Resolved inputs of one search; the caller validates the root and trims the query. */
|
||||
export interface SearchSpec extends SearchLimits {
|
||||
/** Canonical workspace root in the subprocess execution world. */
|
||||
readonly cwd: string
|
||||
/** Non-empty query without surrounding whitespace. */
|
||||
readonly query: string
|
||||
/** Literal names at any depth, or exact root-relative paths when containing `/`. */
|
||||
readonly excludedPaths: readonly string[]
|
||||
/** Whether hidden files and directories may participate. */
|
||||
readonly includeHidden: boolean
|
||||
/** Whether ripgrep's native ignore rules apply. */
|
||||
readonly respectGitignore: boolean
|
||||
}
|
||||
|
||||
/** VCS metadata never contributes files or inferred directories. */
|
||||
const VCS_NAMES = ['.git', '.svn', '.hg', '.bzr', '.jj', '.sl']
|
||||
|
||||
/** Quote names as literal rg glob components, not patterns. */
|
||||
function exclusion(path: string): string {
|
||||
const literal = path.replace(/[\\*?\[\]{}!]/g, '\\$&')
|
||||
return `--glob=!${path.includes('/') ? '/' : ''}${literal}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Match names, or relative paths for queries containing `/`, without case.
|
||||
* Directories derive only from listed files; empty directories and links are
|
||||
* absent. Results sort by depth, then path, after scanning the bounded file set.
|
||||
* Native ignore handling and read failures belong to rg: exit 2 reports partial
|
||||
* results; launch and other execution failures reject with a RemoteError.
|
||||
* @param subprocess - the composed process spawner, co-located with the filesystem.
|
||||
* @param spec - validated root, query, filters and process budgets.
|
||||
* @param signal - cancellation; rejection preserves its exact reason.
|
||||
* @returns bounded shallow-first matches and independent truncation/incompleteness flags.
|
||||
*/
|
||||
export async function searchWorkspace(
|
||||
subprocess: Pick<SubprocessRuntime, 'spawn'>,
|
||||
spec: SearchSpec,
|
||||
signal: AbortSignal,
|
||||
): Promise<WorkspaceFileSearchResult> {
|
||||
const timeout = new AbortController()
|
||||
const timer = setTimeout(() => {
|
||||
timeout.abort(new RemoteError('workspace-file/search-failed', 'Workspace file search timed out; narrow the workspace or increase search.timeoutMs', {}))
|
||||
}, spec.timeoutMs)
|
||||
const combined = AbortSignal.any([signal, timeout.signal])
|
||||
const needle = spec.query.toLowerCase()
|
||||
const byPath = spec.query.includes('/')
|
||||
const entries = new Map<string, WorkspaceFileSearchMatch>()
|
||||
const admit = (path: string, type: WorkspaceFileSearchMatch['type']): void => {
|
||||
const name = path.slice(path.lastIndexOf('/') + 1)
|
||||
if ((byPath ? path : name).toLowerCase().includes(needle)) entries.set(path, { path, type })
|
||||
}
|
||||
try {
|
||||
combined.throwIfAborted()
|
||||
let run: RipgrepLinesResult
|
||||
let stderr = ''
|
||||
try {
|
||||
const handle = await spawnRipgrep(subprocess, {
|
||||
argv: [
|
||||
'--files', '--sort=path', '--null', '--path-separator=/',
|
||||
...spec.includeHidden ? ['--hidden'] : [],
|
||||
...spec.respectGitignore ? [] : ['--no-ignore'],
|
||||
...[...VCS_NAMES, ...spec.excludedPaths].map(exclusion),
|
||||
],
|
||||
cwd: spec.cwd,
|
||||
stdio: { stdin: 'ignore', stdout: 'pipe', stderr: { maxBytes: spec.stderrMaxBytes } },
|
||||
graceMs: spec.graceMs,
|
||||
signal: combined,
|
||||
})
|
||||
let listed = 0
|
||||
run = await readRipgrepLines(handle, '\0', (path) => {
|
||||
if (listed === spec.maxEntries) return false
|
||||
admit(path, 'file')
|
||||
let end = path.lastIndexOf('/')
|
||||
while (end !== -1) {
|
||||
admit(path.slice(0, end), 'directory')
|
||||
end = path.lastIndexOf('/', end - 1)
|
||||
}
|
||||
listed += 1
|
||||
return true
|
||||
})
|
||||
stderr = handle.collected.stderr?.readFrom(0).text.trim() ?? ''
|
||||
} catch (cause: unknown) {
|
||||
signal.throwIfAborted()
|
||||
timeout.signal.throwIfAborted()
|
||||
throw new RemoteError('workspace-file/search-failed', 'Could not launch or read workspace file search; the subprocess provider must be able to execute the packaged ripgrep binary', {}, { cause })
|
||||
}
|
||||
const { outcome, stopped } = run
|
||||
combined.throwIfAborted()
|
||||
if (!stopped && (outcome.signal !== null || (outcome.exitCode !== 0 && outcome.exitCode !== 1 && outcome.exitCode !== 2))) {
|
||||
throw new RemoteError('workspace-file/search-failed', `Workspace file search exited with code ${outcome.exitCode} and signal ${outcome.signal}; check the subprocess provider${stderr.length > 0 ? `: ${stderr}` : ''}`, {})
|
||||
}
|
||||
const matches = [...entries.values()].sort((a, b) => {
|
||||
const depth = a.path.split('/').length - b.path.split('/').length
|
||||
return depth || (a.path < b.path ? -1 : 1)
|
||||
})
|
||||
return {
|
||||
matches: matches.slice(0, spec.maxResults),
|
||||
truncated: matches.length > spec.maxResults,
|
||||
incomplete: stopped || outcome.exitCode === 2,
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,9 @@
|
||||
* same path.
|
||||
* - `list` speaks workspace paths — the same syntax its `path` argument accepts —
|
||||
* because its consumer is a tree rooted at the workspace root.
|
||||
* - `search` reports `/`-joined workspace-relative paths, and its query and
|
||||
* exclusions use the same relative syntax, because its consumer is the same
|
||||
* workspace tree.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-api-workspace-files/types
|
||||
*/
|
||||
@@ -122,6 +125,56 @@ export interface WorkspaceDirectoryListing {
|
||||
readonly truncated: boolean
|
||||
}
|
||||
|
||||
/** One workspace-wide search by entry name or workspace-relative path. */
|
||||
export interface WorkspaceFileSearchRequest {
|
||||
/**
|
||||
* Case-insensitive substring to find. Surrounding whitespace is trimmed, and
|
||||
* an empty result is refused. Without `/` it matches entry names; with `/` it
|
||||
* matches `/`-joined workspace-relative paths.
|
||||
*/
|
||||
readonly query: string
|
||||
/**
|
||||
* Literal exclusions passed as escaped rg globs. A value without `/` excludes
|
||||
* that name at any depth; a value with `/` excludes the exact path rooted at
|
||||
* the workspace. Glob metacharacters in names are not interpreted as patterns.
|
||||
*/
|
||||
readonly excludedPaths: readonly string[]
|
||||
/** Whether rg includes hidden files and directories; VCS metadata remains excluded. */
|
||||
readonly includeHidden: boolean
|
||||
/**
|
||||
* Whether native rg ignore rules apply, including repository detection,
|
||||
* nested and parent `.gitignore`, `.git/info/exclude`, global Git excludes
|
||||
* and `.ignore`. The service does not read `core.ignorecase`; false passes
|
||||
* `--no-ignore`.
|
||||
*/
|
||||
readonly respectGitignore: boolean
|
||||
}
|
||||
|
||||
/** One entry a workspace search found. */
|
||||
export interface WorkspaceFileSearchMatch {
|
||||
/** `/`-joined path relative to the workspace root, never empty. */
|
||||
readonly path: string
|
||||
/** What the entry resolves to, as {@link WorkspaceDirectoryEntry.type} reports it. */
|
||||
readonly type: WorkspaceDirectoryEntry['type']
|
||||
}
|
||||
|
||||
/** The outcome of one workspace search. */
|
||||
export interface WorkspaceFileSearchResult {
|
||||
/**
|
||||
* Matching files and inferred ancestor directories from the bounded set of
|
||||
* listed files, sorted by depth then workspace-relative path and cut to the
|
||||
* configured result cap.
|
||||
*/
|
||||
readonly matches: readonly WorkspaceFileSearchMatch[]
|
||||
/** Whether matches exceeded the result cap after scanning the bounded file set. */
|
||||
readonly truncated: boolean
|
||||
/**
|
||||
* Whether rg listed more files than the file cap and was terminated early,
|
||||
* or exited with code 2 to report partial discovery.
|
||||
*/
|
||||
readonly incomplete: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Current target metadata after a filesystem invalidation. File consumers may
|
||||
* ignore an already known version; directory consumers relist on every frame
|
||||
@@ -152,6 +205,8 @@ export type WorkspaceFileWatchFrame =
|
||||
|
||||
declare module '@deepseek-ai/dsh-typert-protocol' {
|
||||
interface RemoteErrorDetailsMap {
|
||||
/** Search cannot run: missing subprocess, launch/execution failure, or configured timeout. */
|
||||
'workspace-file/search-failed': Record<string, never>
|
||||
/** The filesystem provider cannot initialize a watch for this target. */
|
||||
'workspace-file/watch-unsupported': { readonly path: string }
|
||||
/** No entry exists at that path inside the workspace. */
|
||||
|
||||
@@ -23,7 +23,13 @@ beforeEach(async () => {
|
||||
await ctx.plugin(RemoteAccessService)
|
||||
await ctx.plugin(TypertGateway)
|
||||
await ctx.plugin((scope) => { new HostConnectionService(scope, [], {} as BrowserAuth) })
|
||||
await ctx.plugin(WorkspaceFiles, { maxBytes: 8, maxFileBytes: 4, maxLines: 100, maxEntries: 100 })
|
||||
await ctx.plugin(WorkspaceFiles, {
|
||||
maxBytes: 8,
|
||||
maxFileBytes: 4,
|
||||
maxLines: 100,
|
||||
maxEntries: 100,
|
||||
search: { maxResults: 100, maxEntries: 1000, graceMs: 3000, timeoutMs: 30000, stderrMaxBytes: 65536 },
|
||||
})
|
||||
ctx.sessions.create(harness.scope.sessionId, { meta: { cwd: harness.workspace, origin: 'subagent' } })
|
||||
const connection = ctx.get('connection') as HostConnectionService
|
||||
const handler = connection.createSharedFetchHandler('/api')
|
||||
|
||||
@@ -72,7 +72,13 @@ async function ready(stream: ReturnType<typeof open>): Promise<void> {
|
||||
async function ownedEndpoint() {
|
||||
let service: WorkspaceFiles | undefined
|
||||
const fiber = await harness.ctx.plugin(Object.assign((ctx: Context) => {
|
||||
service = new WorkspaceFiles(ctx, { maxBytes: 1024, maxFileBytes: 1024, maxLines: 10, maxEntries: 10 })
|
||||
service = new WorkspaceFiles(ctx, {
|
||||
maxBytes: 1024,
|
||||
maxFileBytes: 1024,
|
||||
maxLines: 10,
|
||||
maxEntries: 10,
|
||||
search: { maxResults: 100, maxEntries: 1000, graceMs: 3000, timeoutMs: 30000, stderrMaxBytes: 65536 },
|
||||
})
|
||||
}, { inject: ['fs', 'sandboxPolicy'] }))
|
||||
cleanups.push(() => fiber.dispose())
|
||||
if (service === undefined) throw new Error('plugin body did not run')
|
||||
|
||||
@@ -24,6 +24,9 @@ function fileScope(workspaceRoot: string): WorkspaceFileScope {
|
||||
|
||||
export const signal = (): AbortSignal => new AbortController().signal
|
||||
|
||||
/** Per-test service caps; omitted read, list and search fields keep the suite defaults. */
|
||||
export type Caps = Partial<Omit<Config, 'search'>> & { readonly search?: Partial<Config['search']> }
|
||||
|
||||
/** One temp workspace and the context serving it. */
|
||||
export interface Harness {
|
||||
readonly workspace: string
|
||||
@@ -34,7 +37,7 @@ export interface Harness {
|
||||
* The service under test, at the given caps. One per test: the service key is
|
||||
* global to the Context, so a second call with caps is a defect in the test.
|
||||
*/
|
||||
endpoint(caps?: Partial<Config>): WorkspaceFiles
|
||||
endpoint(caps?: Caps): WorkspaceFiles
|
||||
dispose(): Promise<void>
|
||||
}
|
||||
|
||||
@@ -72,6 +75,13 @@ export async function openWorkspace(prefix: string): Promise<Harness> {
|
||||
maxFileBytes: caps?.maxFileBytes ?? 1024 * 1024,
|
||||
maxLines: caps?.maxLines ?? 5000,
|
||||
maxEntries: caps?.maxEntries ?? 2000,
|
||||
search: {
|
||||
maxResults: caps?.search?.maxResults ?? 512,
|
||||
maxEntries: caps?.search?.maxEntries ?? 100000,
|
||||
graceMs: caps?.search?.graceMs ?? 3000,
|
||||
timeoutMs: caps?.search?.timeoutMs ?? 30000,
|
||||
stderrMaxBytes: caps?.search?.stderrMaxBytes ?? 65536,
|
||||
},
|
||||
})
|
||||
return service
|
||||
},
|
||||
|
||||
@@ -10,6 +10,7 @@ const CAPS = {
|
||||
maxFileBytes: 1024,
|
||||
maxLines: 100,
|
||||
maxEntries: 100,
|
||||
search: { maxResults: 100, maxEntries: 1000, graceMs: 3000, timeoutMs: 30000, stderrMaxBytes: 65536 },
|
||||
}
|
||||
|
||||
function header(id: SessionId, cwd?: string): SessionHeader {
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
/** Local subprocess fixture with private Git configuration, without mutating process.env. */
|
||||
import { mkdir } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
|
||||
import type { Harness } from './harness.ts'
|
||||
|
||||
/** Mount rg's real process provider with a per-test home for native global ignore rules. */
|
||||
export async function mountSearchSubprocess(harness: Harness): Promise<string> {
|
||||
const home = join(harness.outside, 'home')
|
||||
await mkdir(home)
|
||||
await harness.ctx.plugin(LocalSubprocessRuntime)
|
||||
const spawn = harness.ctx.subprocess.spawn.bind(harness.ctx.subprocess)
|
||||
harness.ctx.subprocess.spawn = spec => spawn({
|
||||
...spec,
|
||||
env: { HOME: home, USERPROFILE: home, XDG_CONFIG_HOME: home, ...spec.env },
|
||||
})
|
||||
return home
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
/** Process failures, budgets and lossless literal path filters over real ripgrep. */
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { chmod, mkdir, realpath, symlink, writeFile } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { PassThrough } from 'node:stream'
|
||||
import { mountSearchSubprocess } from './search-harness.ts'
|
||||
import type { SubprocessHandle, SubprocessOutcome } from '@deepseek-ai/dsh-subprocess'
|
||||
import { WorkspaceFiles, type Config, type WorkspaceFileSearchRequest } from '../src/index.ts'
|
||||
import { failureOf, openWorkspace, signal, type Caps, type Harness } from './harness.ts'
|
||||
|
||||
let harness: Harness
|
||||
beforeEach(async () => {
|
||||
harness = await openWorkspace('dsh-workspace-search-rg-')
|
||||
})
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks()
|
||||
await harness.ctx.fiber.dispose()
|
||||
await harness.dispose()
|
||||
})
|
||||
async function files(...paths: string[]): Promise<void> {
|
||||
for (const path of paths) {
|
||||
const absolute = join(harness.workspace, path)
|
||||
await mkdir(dirname(absolute), { recursive: true })
|
||||
await writeFile(absolute, '')
|
||||
}
|
||||
}
|
||||
const request = (query: string, options: Partial<WorkspaceFileSearchRequest> = {}): WorkspaceFileSearchRequest => ({
|
||||
query, includeHidden: true, respectGitignore: false, excludedPaths: [], ...options,
|
||||
})
|
||||
const search = (query: string, caps?: Caps, options?: Partial<WorkspaceFileSearchRequest>, abort = signal()) =>
|
||||
harness.endpoint(caps).search(harness.scope, request(query, options), abort)
|
||||
|
||||
describe('ripgrep-backed workspace discovery', () => {
|
||||
beforeEach(async () => { await mountSearchSubprocess(harness) })
|
||||
|
||||
it('sorts shallow matches before path-sorted deep files and truncates only matching results', async () => {
|
||||
await files('a-match/deep-match/file-match', 'z-match', 'b-match')
|
||||
expect(await search('match', { search: { maxResults: 3 } })).toEqual({ matches: [
|
||||
{ path: 'a-match', type: 'directory' }, { path: 'b-match', type: 'file' }, { path: 'z-match', type: 'file' },
|
||||
], truncated: true, incomplete: false })
|
||||
})
|
||||
|
||||
it('sorts full paths independently of rg directory visitation order', async () => {
|
||||
await files('a/file', 'a-file/file')
|
||||
expect((await search('file')).matches.map(match => match.path)).toEqual(['a-file', 'a-file/file', 'a/file'])
|
||||
})
|
||||
|
||||
it('does not truncate at exactly the result cap', async () => {
|
||||
await files('a1', 'a2')
|
||||
expect(await search('a', { search: { maxResults: 2 } })).toMatchObject({ truncated: false, incomplete: false })
|
||||
})
|
||||
|
||||
it.each([2, 3])('reports incompleteness only when %s files exceed the two-file cap', async (total) => {
|
||||
await files(...Array.from({ length: total }, (_, i) => i < 2 ? `a${i}` : `z-a/a${i}`))
|
||||
const original = harness.ctx.subprocess.spawn.bind(harness.ctx.subprocess)
|
||||
const terminate = vi.fn()
|
||||
const waitForExit = vi.fn()
|
||||
vi.spyOn(harness.ctx.subprocess, 'spawn').mockImplementation((spec) => {
|
||||
const handle = original(spec)
|
||||
return { ...handle,
|
||||
terminate: () => { terminate(); handle.terminate() },
|
||||
waitForExit: async () => { waitForExit(); return handle.waitForExit() },
|
||||
}
|
||||
})
|
||||
expect(await search('a', { search: { maxEntries: 2 } })).toEqual({ matches: [
|
||||
{ path: 'a0', type: 'file' }, { path: 'a1', type: 'file' },
|
||||
], truncated: false, incomplete: total > 2 })
|
||||
expect(terminate).toHaveBeenCalledTimes(total > 2 ? 1 : 0)
|
||||
expect(waitForExit).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('uses native hidden/ignore switches, path sorting, lossless separators and no ambient config', async () => {
|
||||
const spawn = vi.spyOn(harness.ctx.subprocess, 'spawn')
|
||||
await search('absent')
|
||||
expect(spawn.mock.calls[0]?.[0]).toMatchObject({ cwd: await realpath(harness.workspace), graceMs: 3000 })
|
||||
expect(spawn.mock.calls[0]?.[0].argv).toEqual(expect.arrayContaining([
|
||||
'--no-config', '--files', '--sort=path', '--null', '--path-separator=/', '--hidden', '--no-ignore',
|
||||
]))
|
||||
expect(spawn.mock.calls[0]?.[0].argv[1]).toBe('--no-config')
|
||||
})
|
||||
|
||||
it.each(['.git', '.svn', '.hg', '.bzr', '.jj', '.sl'])('excludes %s internals even with hidden and ignored files enabled', async (vcs) => {
|
||||
await files(`${vcs}/a.txt`, `sub/${vcs}/a.txt`, 'a.txt')
|
||||
expect((await search('a.txt')).matches).toEqual([{ path: 'a.txt', type: 'file' }])
|
||||
expect((await search(vcs)).matches).toEqual([])
|
||||
})
|
||||
|
||||
it.each(['[x]', '{x,y}', '!x'])('quotes the portable glob metacharacters in %s literally', async (name) => {
|
||||
await files(`${name}/target`, `sub/${name}/target`, 'x/target', 'y/target')
|
||||
expect((await search('target', undefined, { excludedPaths: [name] })).matches.map(m => m.path)).toEqual(['x/target', 'y/target'])
|
||||
expect((await search('target', undefined, { excludedPaths: [`sub/${name}`] })).matches.map(m => m.path)).toEqual([`${name}/target`, 'x/target', 'y/target'].sort())
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32')('quotes star, question mark and backslash names and preserves embedded newlines', async () => {
|
||||
// Windows does not permit these literal filename characters.
|
||||
await files('a*/target', 'a?/target', 'a\\b/target', 'ax/target', 'new\nline/target')
|
||||
expect((await search('target', undefined, { excludedPaths: ['a*', 'a?', 'a\\b'] })).matches.map(m => m.path)).toEqual(['ax/target', 'new\nline/target'])
|
||||
})
|
||||
|
||||
it('does not return empty directories or directory links inside/outside the root', async () => {
|
||||
await files('real/target')
|
||||
await mkdir(join(harness.workspace, 'empty'))
|
||||
await writeFile(join(harness.outside, 'outside-target'), '')
|
||||
await symlink(join(harness.workspace, 'real'), join(harness.workspace, 'linked'), 'junction')
|
||||
await symlink(harness.outside, join(harness.workspace, 'outside-link'), 'junction')
|
||||
expect((await search('link')).matches).toEqual([])
|
||||
expect((await search('empty')).matches).toEqual([])
|
||||
expect((await search('target')).matches).toEqual([{ path: 'real/target', type: 'file' }])
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32')('omits regular and broken file symlinks', async () => {
|
||||
// File symlinks on Windows require privileges absent from some test hosts.
|
||||
await files('target')
|
||||
await symlink(join(harness.workspace, 'target'), join(harness.workspace, 'file-link'))
|
||||
await symlink(join(harness.workspace, 'missing'), join(harness.workspace, 'broken-link'))
|
||||
expect((await search('link')).matches).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps git ignore repository detection and does not interpret core.ignorecase', async () => {
|
||||
await files('SECRET.txt')
|
||||
await writeFile(join(harness.workspace, '.gitignore'), 'SECRET.txt\n')
|
||||
expect((await search('secret', undefined, { respectGitignore: true })).matches).toHaveLength(1)
|
||||
await mkdir(join(harness.workspace, '.git'))
|
||||
expect((await search('secret', undefined, { respectGitignore: true })).matches).toEqual([])
|
||||
await writeFile(join(harness.workspace, '.gitignore'), 'secret.txt\n')
|
||||
await writeFile(join(harness.workspace, '.git', 'config'), '[core]\nignorecase = true\n')
|
||||
expect((await search('secret', undefined, { respectGitignore: true })).matches).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('honors the private home global Git exclude file', async () => {
|
||||
const home = join(harness.outside, 'home')
|
||||
await mkdir(join(harness.workspace, '.git'))
|
||||
await writeFile(join(home, '.gitconfig'), '[core]\nexcludesFile = ~/.global-ignore\n')
|
||||
await writeFile(join(home, '.global-ignore'), 'global.txt\n')
|
||||
await files('global.txt', 'keep.txt')
|
||||
expect((await search('.txt', undefined, { respectGitignore: true })).matches).toEqual([{ path: 'keep.txt', type: 'file' }])
|
||||
expect((await search('global.txt')).matches).toEqual([{ path: 'global.txt', type: 'file' }])
|
||||
})
|
||||
|
||||
it('honors parent .gitignore and .ignore without disabling native defaults', async () => {
|
||||
await mkdir(join(dirname(harness.workspace), '.git'))
|
||||
await writeFile(join(dirname(harness.workspace), '.gitignore'), 'parent.txt\n')
|
||||
await writeFile(join(harness.workspace, '.ignore'), 'dot.txt\n')
|
||||
await files('parent.txt', 'dot.txt', 'keep.txt')
|
||||
expect((await search('.txt', undefined, { respectGitignore: true })).matches).toEqual([{ path: 'keep.txt', type: 'file' }])
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)('returns actual rg exit-2 partial results for an unreadable directory', async () => {
|
||||
// POSIX permissions cannot deny reads to root and have no Windows equivalent.
|
||||
await files('a.txt', 'blocked/a.txt')
|
||||
const blocked = join(harness.workspace, 'blocked')
|
||||
await chmod(blocked, 0)
|
||||
try {
|
||||
expect(await search('a.txt')).toEqual({ matches: [{ path: 'a.txt', type: 'file' }], truncated: false, incomplete: true })
|
||||
} finally {
|
||||
await chmod(blocked, 0o700)
|
||||
}
|
||||
})
|
||||
|
||||
it('reports synchronous and asynchronous launch failures with a stable Remote code', async () => {
|
||||
const spawn = vi.spyOn(harness.ctx.subprocess, 'spawn').mockImplementation(() => { throw new Error('remote executable missing') })
|
||||
expect((await failureOf(search('a'))).code).toBe('workspace-file/search-failed')
|
||||
spawn.mockRestore()
|
||||
const original = harness.ctx.subprocess.spawn.bind(harness.ctx.subprocess)
|
||||
vi.spyOn(harness.ctx.subprocess, 'spawn').mockImplementation(spec => original({ ...spec, argv: [join(harness.workspace, 'missing-rg')] }))
|
||||
expect((await failureOf(search('a'))).code).toBe('workspace-file/search-failed')
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ exitCode: 2, signal: null }, { exitCode: 3, signal: null }, { exitCode: null, signal: 'SIGTERM' }, { exitCode: null, signal: null },
|
||||
] satisfies SubprocessOutcome[])('classifies exit facts %j independently from retained paths', async (outcome) => {
|
||||
await files('a.txt')
|
||||
const original = harness.ctx.subprocess.spawn.bind(harness.ctx.subprocess)
|
||||
vi.spyOn(harness.ctx.subprocess, 'spawn').mockImplementation((spec) => {
|
||||
const handle = original(spec)
|
||||
return { ...handle, done: handle.done.then(() => outcome) }
|
||||
})
|
||||
if (outcome.exitCode === 2) expect(await search('a')).toEqual({ matches: [{ path: 'a.txt', type: 'file' }], truncated: false, incomplete: true })
|
||||
else expect((await failureOf(search('a'))).code).toBe('workspace-file/search-failed')
|
||||
})
|
||||
|
||||
it('includes the configured bounded stderr excerpt in execution failures', async () => {
|
||||
const original = harness.ctx.subprocess.spawn.bind(harness.ctx.subprocess)
|
||||
vi.spyOn(harness.ctx.subprocess, 'spawn').mockImplementation((spec) => {
|
||||
const handle = original({ ...spec, argv: [...spec.argv, '--unknown-search-flag'] })
|
||||
return { ...handle, done: handle.done.then(() => ({ exitCode: 3, signal: null })) }
|
||||
})
|
||||
const pending = search('a', { search: { stderrMaxBytes: 65536 } })
|
||||
await expect(pending).rejects.toMatchObject({ code: 'workspace-file/search-failed' })
|
||||
await expect(pending).rejects.toThrow('unknown-search-flag')
|
||||
})
|
||||
|
||||
it('preserves cancellation before start and after real stdout arrives', async () => {
|
||||
const aborted = new AbortController()
|
||||
const reason = new Error('cancel search')
|
||||
aborted.abort(reason)
|
||||
await expect(search('a', undefined, undefined, aborted.signal)).rejects.toBe(reason)
|
||||
await files('a.txt')
|
||||
const active = new AbortController()
|
||||
const original = harness.ctx.subprocess.spawn.bind(harness.ctx.subprocess)
|
||||
vi.spyOn(harness.ctx.subprocess, 'spawn').mockImplementation((spec) => {
|
||||
const handle = original(spec)
|
||||
handle.stdout!.once('data', () => { active.abort(reason) })
|
||||
return handle
|
||||
})
|
||||
await expect(search('a', undefined, undefined, active.signal)).rejects.toBe(reason)
|
||||
})
|
||||
|
||||
it('enforces the configured deadline and waits for process cleanup', async () => {
|
||||
const exited = vi.fn(async () => true)
|
||||
vi.spyOn(harness.ctx.subprocess, 'spawn').mockImplementation((spec) => {
|
||||
const stdout = new PassThrough()
|
||||
let finish!: (outcome: SubprocessOutcome) => void
|
||||
const done = new Promise<SubprocessOutcome>((resolve) => { finish = resolve })
|
||||
const terminate = () => { stdout.end(); finish({ exitCode: null, signal: 'SIGTERM' }) }
|
||||
spec.signal!.addEventListener('abort', terminate, { once: true })
|
||||
return { stdout, stdin: undefined, stderr: undefined, control: undefined,
|
||||
collected: {}, done, terminate, waitForExit: exited } satisfies SubprocessHandle
|
||||
})
|
||||
expect((await failureOf(search('a', { search: { timeoutMs: 1 } }))).code).toBe('workspace-file/search-failed')
|
||||
expect(exited).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
it('fails only search when subprocess is absent; reads and listings remain usable', async () => {
|
||||
await files('a.txt')
|
||||
expect((await failureOf(search('a'))).code).toBe('workspace-file/search-failed')
|
||||
expect((await harness.endpoint().list(harness.scope, '.', signal())).entries).toHaveLength(1)
|
||||
expect((await harness.endpoint().read(harness.scope, 'a.txt', {}, signal())).text).toBe('')
|
||||
})
|
||||
|
||||
/** Parse loader input, which may omit any defaulted field, including the whole `search` group. */
|
||||
const parseConfig = (input: object): Config => new WorkspaceFiles.Config(input as Config)
|
||||
|
||||
it('validates subprocess budgets and supplies defaults', () => {
|
||||
const defaults = new WorkspaceFiles.Config()
|
||||
expect(defaults.search).toMatchObject({ graceMs: 3000, timeoutMs: 30000, stderrMaxBytes: 65536 })
|
||||
for (const name of ['graceMs', 'timeoutMs', 'stderrMaxBytes']) {
|
||||
expect(() => parseConfig({ search: { ...defaults.search, [name]: 0 } })).toThrow()
|
||||
expect(() => parseConfig({ search: { ...defaults.search, [name]: 1.5 } })).toThrow()
|
||||
}
|
||||
expect(() => parseConfig({ search: { ...defaults.search, timeoutMs: 2147483648 } })).toThrow()
|
||||
})
|
||||
|
||||
it('fills every search default when the search group is omitted', () => {
|
||||
expect(parseConfig({ maxEntries: 7 })).toEqual({
|
||||
maxBytes: 2 * 1024 * 1024,
|
||||
maxFileBytes: 32 * 1024 * 1024,
|
||||
maxLines: 5000,
|
||||
maxEntries: 7,
|
||||
search: { maxResults: 512, maxEntries: 100000, graceMs: 3000, timeoutMs: 30000, stderrMaxBytes: 65536 },
|
||||
})
|
||||
})
|
||||
|
||||
it('fills the remaining search defaults when the search group is partial', () => {
|
||||
expect(parseConfig({ search: { maxResults: 3, timeoutMs: 10 } }).search)
|
||||
.toEqual({ maxResults: 3, maxEntries: 100000, graceMs: 3000, timeoutMs: 10, stderrMaxBytes: 65536 })
|
||||
})
|
||||
@@ -0,0 +1,144 @@
|
||||
/** The `search` endpoint: matching, filters, ignore files, caps, failures, cancellation, and links. */
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { mountSearchSubprocess } from './search-harness.ts'
|
||||
import type { WorkspaceFileSearchRequest, WorkspaceFileSearchResult } from '../src/index.ts'
|
||||
import { failureOf, openWorkspace, signal, type Caps, type Harness } from './harness.ts'
|
||||
|
||||
let harness: Harness
|
||||
let workspace: string
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await openWorkspace('dsh-workspace-files-search-')
|
||||
await mountSearchSubprocess(harness)
|
||||
workspace = harness.workspace
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks()
|
||||
await harness.ctx.fiber.dispose()
|
||||
await harness.dispose()
|
||||
})
|
||||
|
||||
/** Create empty files, and their parent directories, at workspace-relative paths. */
|
||||
async function files(...paths: string[]): Promise<void> {
|
||||
for (const path of paths) {
|
||||
await mkdir(dirname(join(workspace, path)), { recursive: true })
|
||||
await writeFile(join(workspace, path), '', 'utf8')
|
||||
}
|
||||
}
|
||||
|
||||
/** Write one text file at a workspace-relative path. */
|
||||
async function text(path: string, content: string | Uint8Array): Promise<void> {
|
||||
await mkdir(dirname(join(workspace, path)), { recursive: true })
|
||||
await writeFile(join(workspace, path), content)
|
||||
}
|
||||
|
||||
const search = (
|
||||
query: string,
|
||||
options: Partial<Omit<WorkspaceFileSearchRequest, 'query'>> = {},
|
||||
caps?: Caps,
|
||||
abort: AbortSignal = signal(),
|
||||
): Promise<WorkspaceFileSearchResult> =>
|
||||
harness.endpoint(caps).search(harness.scope, {
|
||||
query,
|
||||
excludedPaths: options.excludedPaths ?? [],
|
||||
includeHidden: options.includeHidden ?? false,
|
||||
respectGitignore: options.respectGitignore ?? false,
|
||||
}, abort)
|
||||
|
||||
const pathsOf = (result: WorkspaceFileSearchResult): string[] => result.matches.map(match => match.path)
|
||||
|
||||
describe('workspaceFiles.search — matching', () => {
|
||||
it('matches entry names case-insensitively, shallower levels first, with each type', async () => {
|
||||
await files('src/deep/Button.tsx', 'BUTTON.md', 'notes.txt')
|
||||
await files('src/button-group/a', 'src/button-group/b')
|
||||
await mkdir(join(workspace, 'empty-button'))
|
||||
const result = await search(' button ')
|
||||
expect(result).toEqual({
|
||||
matches: [
|
||||
{ path: 'BUTTON.md', type: 'file' },
|
||||
{ path: 'src/button-group', type: 'directory' },
|
||||
{ path: 'src/deep/Button.tsx', type: 'file' },
|
||||
],
|
||||
truncated: false,
|
||||
incomplete: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('matches workspace-relative paths case-insensitively when the query holds a slash', async () => {
|
||||
await files('src/app/main.ts', 'lib/src/app.ts', 'other/main.ts')
|
||||
const result = await search('SRC/APP')
|
||||
expect(pathsOf(result)).toEqual(['src/app', 'lib/src/app.ts', 'src/app/main.ts'])
|
||||
})
|
||||
|
||||
it('refuses a blank query as a bad request', async () => {
|
||||
const failure = await failureOf(search(' \t '))
|
||||
expect(failure.code).toBe('gateway/bad-request')
|
||||
})
|
||||
|
||||
it('reports a missing workspace root as not found and a file root as not a directory', async () => {
|
||||
await files('root-file')
|
||||
const service = harness.endpoint()
|
||||
const request = { query: 'a', excludedPaths: [], includeHidden: false, respectGitignore: false }
|
||||
const missing = await failureOf(service.search({ ...harness.scope, workspaceRoot: join(workspace, 'gone') }, request, signal()))
|
||||
expect(missing.code).toBe('workspace-file/not-found')
|
||||
const file = await failureOf(service.search({ ...harness.scope, workspaceRoot: join(workspace, 'root-file') }, request, signal()))
|
||||
expect(file).toMatchObject({ code: 'workspace-file/not-directory', details: { kind: 'file' } })
|
||||
})
|
||||
})
|
||||
|
||||
describe('workspaceFiles.search — exclusions and hidden entries', () => {
|
||||
it('excludes a name at every depth without entering it', async () => {
|
||||
await files('node_modules/pkg/index.ts', 'app/node_modules/dep/index.ts', 'app/index.ts')
|
||||
const result = await search('index', { excludedPaths: ['node_modules'] })
|
||||
expect(pathsOf(result)).toEqual(['app/index.ts'])
|
||||
})
|
||||
|
||||
it('excludes one exact relative path without entering it', async () => {
|
||||
await files('a/b/target.txt', 'c/a/b/target.txt', 'a/target.txt')
|
||||
const result = await search('target', { excludedPaths: ['a/b'] })
|
||||
expect(pathsOf(result)).toEqual(['a/target.txt', 'c/a/b/target.txt'])
|
||||
expect(pathsOf(await search('b', { excludedPaths: ['a/b'] }))).toEqual(['c/a/b'])
|
||||
})
|
||||
|
||||
it('leaves hidden entries out and unentered unless they are included', async () => {
|
||||
await files('.env', '.config/env.ts', 'env.md')
|
||||
expect(pathsOf(await search('env'))).toEqual(['env.md'])
|
||||
expect(pathsOf(await search('env', { includeHidden: true }))).toEqual(['.env', 'env.md', '.config/env.ts'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('workspaceFiles.search — ignore files', () => {
|
||||
beforeEach(async () => {
|
||||
await text('.gitignore', '*.log\n!keep.log\nbuild/\n')
|
||||
await text('sub/.gitignore', 'secret.txt\n!sub-keep.log\n')
|
||||
await text('.git/info/exclude', 'excluded.txt\n')
|
||||
await files(
|
||||
'debug.log', 'keep.log', 'secret.txt', 'excluded.txt',
|
||||
'sub/secret.txt', 'sub/sub-keep.log', 'sub/other.log',
|
||||
'other/secret.txt', 'out/build/target.txt', 'tools/build',
|
||||
)
|
||||
})
|
||||
|
||||
it('applies nested .gitignore files to their own subtree, with negation and directory-only patterns', async () => {
|
||||
const result = await search('.', { respectGitignore: true })
|
||||
expect(pathsOf(result)).toEqual(['keep.log', 'secret.txt', 'other/secret.txt', 'sub/sub-keep.log'])
|
||||
expect(result.incomplete).toBe(false)
|
||||
expect(pathsOf(await search('build', { respectGitignore: true }))).toEqual(['tools/build'])
|
||||
expect(pathsOf(await search('target', { respectGitignore: true }))).toEqual([])
|
||||
})
|
||||
|
||||
it('applies the root .git/info/exclude', async () => {
|
||||
expect(pathsOf(await search('excluded', { respectGitignore: true }))).toEqual([])
|
||||
})
|
||||
|
||||
it('applies no ignore file when ignore files are not respected', async () => {
|
||||
const result = await search('.', {})
|
||||
expect(pathsOf(result)).toEqual([
|
||||
'debug.log', 'excluded.txt', 'keep.log', 'secret.txt',
|
||||
'other/secret.txt', 'sub/other.log', 'sub/secret.txt', 'sub/sub-keep.log', 'out/build/target.txt',
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -8,7 +8,8 @@
|
||||
"files": [
|
||||
"src/index.ts",
|
||||
"src/types.ts",
|
||||
"src/changes.ts"
|
||||
"src/changes.ts",
|
||||
"src/search.ts"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
@@ -34,6 +35,12 @@
|
||||
},
|
||||
{
|
||||
"path": "../../util/deque"
|
||||
},
|
||||
{
|
||||
"path": "../../util/ripgrep"
|
||||
},
|
||||
{
|
||||
"path": "../../subprocess/subprocess"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -33,8 +33,6 @@ export const de = {
|
||||
'more': 'Mehr',
|
||||
'collapse': 'Einklappen',
|
||||
'expand': 'Ausklappen',
|
||||
'subtitle.expand': 'Beschreibung für {name} ausklappen',
|
||||
'subtitle.collapse': 'Beschreibung für {name} einklappen',
|
||||
'back': 'Zurück',
|
||||
'brand.localBuild': 'Lokaler DSH-Build',
|
||||
'workspace.defaultName': 'Standardarbeitsbereich',
|
||||
|
||||
@@ -32,8 +32,6 @@ export const en = {
|
||||
'more': 'More',
|
||||
'collapse': 'Collapse',
|
||||
'expand': 'Expand',
|
||||
'subtitle.expand': 'Expand description for {name}',
|
||||
'subtitle.collapse': 'Collapse description for {name}',
|
||||
'back': 'Back',
|
||||
'brand.localBuild': 'DSH Local Build',
|
||||
'workspace.defaultName': 'Default workspace',
|
||||
|
||||
@@ -33,8 +33,6 @@ export const es = {
|
||||
'more': 'Más',
|
||||
'collapse': 'Contraer',
|
||||
'expand': 'Expandir',
|
||||
'subtitle.expand': 'Expandir la descripción de {name}',
|
||||
'subtitle.collapse': 'Contraer la descripción de {name}',
|
||||
'back': 'Atrás',
|
||||
'brand.localBuild': 'Compilación local de DSH',
|
||||
'workspace.defaultName': 'Espacio de trabajo predeterminado',
|
||||
|
||||
@@ -33,8 +33,6 @@ export const fr = {
|
||||
'more': 'Plus',
|
||||
'collapse': 'Réduire',
|
||||
'expand': 'Développer',
|
||||
'subtitle.expand': 'Développer la description de {name}',
|
||||
'subtitle.collapse': 'Réduire la description de {name}',
|
||||
'back': 'Retour',
|
||||
'brand.localBuild': 'Version locale de DSH',
|
||||
'workspace.defaultName': 'Espace de travail par défaut',
|
||||
|
||||
@@ -33,8 +33,6 @@ export const ja = {
|
||||
'more': 'その他',
|
||||
'collapse': '折りたたむ',
|
||||
'expand': '展開',
|
||||
'subtitle.expand': '{name} の説明を展開',
|
||||
'subtitle.collapse': '{name} の説明を折りたたむ',
|
||||
'back': '戻る',
|
||||
'brand.localBuild': 'DSHローカルビルド',
|
||||
'workspace.defaultName': 'デフォルトワークスペース',
|
||||
|
||||
@@ -33,8 +33,6 @@ export const pt = {
|
||||
'more': 'Mais',
|
||||
'collapse': 'Recolher',
|
||||
'expand': 'Expandir',
|
||||
'subtitle.expand': 'Expandir descrição de {name}',
|
||||
'subtitle.collapse': 'Recolher descrição de {name}',
|
||||
'back': 'Voltar',
|
||||
'brand.localBuild': 'Versão local do DSH',
|
||||
'workspace.defaultName': 'Espaço de trabalho padrão',
|
||||
|
||||
@@ -33,8 +33,6 @@ export const ru = {
|
||||
'more': 'Ещё',
|
||||
'collapse': 'Свернуть',
|
||||
'expand': 'Развернуть',
|
||||
'subtitle.expand': 'Развернуть описание {name}',
|
||||
'subtitle.collapse': 'Свернуть описание {name}',
|
||||
'back': 'Назад',
|
||||
'brand.localBuild': 'Локальная сборка DSH',
|
||||
'workspace.defaultName': 'Рабочая область по умолчанию',
|
||||
|
||||
@@ -30,8 +30,6 @@ export const zh = {
|
||||
'more': '更多',
|
||||
'collapse': '收起',
|
||||
'expand': '展开',
|
||||
'subtitle.expand': '展开{name}的描述',
|
||||
'subtitle.collapse': '收起{name}的描述',
|
||||
'back': '返回',
|
||||
'brand.localBuild': 'DSH 本地构建',
|
||||
'workspace.defaultName': '默认工作区',
|
||||
|
||||
@@ -36,8 +36,8 @@
|
||||
en: e2b02942a55fb669
|
||||
zh: a38bbee0cc69ae4e
|
||||
/deepseek-ai-dsh-client-ui-chat/scroll-ownership:
|
||||
en: dd4e69828096c3a0
|
||||
zh: 2a9bc370f44efcd7
|
||||
en: 1b12aa6c505dde3b
|
||||
zh: 88e1654dd63816d7
|
||||
/deepseek-ai-dsh-client-ui-chat/model-experience:
|
||||
en: c17b4dd43a61c569
|
||||
zh: f7414309810c3a08
|
||||
@@ -45,8 +45,8 @@
|
||||
en: 15a7151ff9268f15
|
||||
zh: 6eae68c718be579b
|
||||
/deepseek-ai-dsh-client-ui-chat/known-limitations-and-deferred-work:
|
||||
en: 811787a5d2feaa90
|
||||
zh: 3f1697bbc1fc7691
|
||||
en: b3b03e8e2fe371a1
|
||||
zh: c5eb8b7205e0e880
|
||||
/deepseek-ai-dsh-client-ui-chat/known-limitations-and-deferred-work/dev-note:
|
||||
en: e67f633dc6fe7773
|
||||
zh: e88aeb617ffb74ea
|
||||
@@ -184,7 +184,9 @@ Within a process group, wheel, touchstart, and any pointerdown interrupt an acti
|
||||
|
||||
`useChatViewport` owns turn-aware DOM reads, clamped writes, native events, and one retained paging anchor. For Load older, Node and Group seats mark eligible anchors from their existing disclosure state. The viewport selects the first nonempty, unhidden marker in transcript order without hit testing or geometry-based search, then measures that element and its scroll containers. It compensates the anchor's capped group first, then gives the remaining displacement to the transcript scrollport. An inner compensation write cancels that group's animation through its bound controller and pauses following; reader scrolling back to the bottom resumes it. Commits and later content resizes reuse that anchor; a remounted row is resolved by the same semantic key. Compensation stays within the actual scroll ranges without adding bottom space.
|
||||
|
||||
`useChatReading` owns follow policy, sampled reader input, and semantic memory; `useChatNavigation` owns turn jumps and requests preservation from the viewport. Reading gestures release the paging anchor, but composer clicks, typing, and non-scrolling keys retain it; while a page is still loading, `scrollend` captures the reader's new position. `useChatScroll` coordinates their committed inputs. Explicit navigation carries its measured landing into reading policy, so it does not rediscover the known target with a hit test.
|
||||
`useChatReading` owns follow policy, sampled reader input, and semantic memory; `useChatNavigation` owns turn jumps and requests preservation from the viewport. Reading gestures release the paging anchor, but composer clicks, typing, and non-scrolling keys retain it. A deferred history request captures its anchor at consumption rather than at request time. `useChatScroll` coordinates their committed inputs. Explicit navigation carries its measured landing into reading policy, so it does not rediscover the known target with a hit test.
|
||||
|
||||
History prefetch uses the existing 500ms reading sample and content/size notifications, with one distance check before requesting within two viewport heights. At most one page waits for consumption. Touch and actual inner scrolling retain automatic intent before motion blockers are evaluated, then recheck after release; other active motion, pointer holds, or an unavailable anchor return automatic loading to the button without retrying the cached page. Loading and processing buttons remain clickable, joining the current request without duplication. Explicit clicks retain intent until consumption, exhaustion, failure, or superseding navigation; once a page is available they bypass automatic blockers, use the first paging anchor at the top or the current reading anchor elsewhere with a paging fallback, and consume through existing preservation without waiting for animation completion. If no anchor is available, manual consumption still proceeds without a position guarantee. Automatic stationary consumption uses retained positioning; ordinary outer scrolling uses browser anchoring. For automatic consumption at scrollTop ≤ 0.5, the first paging anchor uses the same captured-screen-position restoration as persistent preservation, once, then releases immediately; it does not add scroll-position deltas that native anchoring may already have applied. The one-shot record is settled on history consumption completion even when the visible Node order stays unchanged; view closure or history failure discards it without application. The actual Chat scrollport disables vertical overscroll bounce, without adding a scrolling animation. The history row keeps its height through loading, processing, retry, and exhaustion; initially complete Sessions omit it.
|
||||
|
||||
Active-Turn highlighting is approximate: `readVisibleTurn` binary-searches the flow slot's direct Node/Group boxes (or the column's in standalone rendering) and retains the preceding candidate in gaps. It neither hit-tests the document nor searches Group members or all Turn markers. Empty Seats retain zero-height in-flow boxes so outer positions remain ordered without extra spacing. This lookup does not change semantic position capture or paging compensation.
|
||||
|
||||
@@ -211,6 +213,8 @@ None; Chat presentation does not assemble or mutate provider requests.
|
||||
|
||||
<a id="known-limitations-and-deferred-work"></a>
|
||||
|
||||
- **Manual paging during an active gesture** — manual consumption captures a persistent anchor when its page arrives, even if a touch gesture is already in progress. Subsequent movement can be restored to that anchor, constraining the gesture or its momentum until a new reading input releases it. The automatic touch deferral does not protect this explicit manual path.
|
||||
|
||||
- **Running-icon resolution** — the animated whale uses a 28×28 image at a default 14×14 CSS size. Larger content fonts or device pixel ratios above 2 can soften the raster stroke; the static fallback remains vector-based. Browser image caching owns the animation clock, so remounting does not guarantee playback restarts at the first frame.
|
||||
|
||||
- **Tool-change presentation** — The `developer-message` Definition shares context presentation with `input-message`. Tool-only developer messages name a single added or removed tool inline without expansion. Multiple changes show added/removed counts and expand to comma-separated tool lists, one line per change kind. Mixed content uses the generic context presentation.
|
||||
|
||||
@@ -184,7 +184,9 @@ Chat 会在历史前插与 renderer 重新挂载时恢复语义锚点,并且
|
||||
|
||||
`useChatViewport` 负责识别轮次的 DOM 读取、限制范围后的写入、原生事件及一个持续保留的分页锚点。加载更早时,Node 与 Group 容器根据已有开合状态标记可选锚点。视口按正文顺序选择第一个非空、未隐藏的标记,不做命中测试或按几何位置搜索,再测量该元素及其滚动容器。先补偿锚点所在的限高组,再把剩余位移交给整段文本的滚动区域。内层补偿写入通过该组绑定的控制器取消动画并暂停跟随,读者滚回底部后恢复。提交和后续内容尺寸变化复用该锚点;消息行重挂载时按同一语义 key 重新定位。补偿限于实际可滚动范围,不增加底部空白。
|
||||
|
||||
`useChatReading` 负责跟随策略、读者输入采样和语义位置记忆;`useChatNavigation` 负责轮次跳转,并向视口请求位置保持。阅读手势释放分页锚点,composer 内的点击、打字和非滚动按键则保留锚点;分页仍在加载时,`scrollend` 会记录读者的新位置。`useChatScroll` 协调已提交的输入。显式导航把测得的落点交给阅读策略,因此无需通过命中测试重新寻找已知目标。
|
||||
`useChatReading` 负责跟随策略、读者输入采样和语义位置记忆;`useChatNavigation` 负责轮次跳转,并向视口请求位置保持。阅读手势释放分页锚点,composer 内的点击、打字和非滚动按键则保留锚点。延迟消费的历史请求在消费时采集锚点,不在发请求时采集。`useChatScroll` 协调已提交的输入。显式导航把测得的落点交给阅读策略,因此无需通过命中测试重新寻找已知目标。
|
||||
|
||||
历史预取复用已有 500ms 阅读采样及内容/尺寸通知,在请求前确认距顶部两屏以内,最多一页等待消费。触摸和实际内层滚动优先于动画阻塞判断保留自动意图,释放后重查;其余动画、指针按住或无法取得锚点使自动加载退回按钮,不自动重试缓存页。加载和处理状态的按钮仍可点击,加入当前请求而不重复请求。明确点击保留意图,直到消费、到头、失败或被新导航取代;页面可用后绕过自动阻塞,在顶部使用首条分页锚点,其他位置使用当前阅读锚点并以分页锚点兜底,通过既有保位消费,不等待动画结束。没有可用锚点时手动消费仍继续,但不保证位置稳定。自动静止消费使用保位,普通外层滚动使用浏览器锚定;自动消费在 scrollTop ≤ 0.5 时,首条分页锚点与持续保位共用采集屏幕位置的恢复公式,只应用一次便立即释放;不再叠加可能已由原生锚定产生的滚动位置增量。历史消费完成时,即使可见 Node 顺序未变化,也会结算一次补偿记录;视图关闭或历史失败时丢弃记录,不应用补偿。实际 Chat 滚动区关闭纵向越界回弹,不新增滚动动画。历史行在加载、处理、重试和到头时保持高度,首次打开就完整的 Session 不显示该行。
|
||||
|
||||
活跃轮次高亮采用近似定位:`readVisibleTurn` 对 flow 插槽的直属 Node/Group 布局盒二分(独立渲染时使用正文容器的直属布局盒),落在间隙时保留前方候选。它不做文档命中测试,也不查询 Group 成员或全部 Turn 标记。空 Seat 保留零高度的流内布局盒,使外层位置有序且不增加间距。这种定位不改变语义位置捕获或分页补偿。
|
||||
|
||||
@@ -211,6 +213,8 @@ Markdown 文件、链接、图片、引用 chip 和工具文件控件将明确
|
||||
|
||||
<a id="known-limitations-and-deferred-work"></a>
|
||||
|
||||
- **进行中手势遇到手动分页**——手动消费在页面到达时采集持续保位锚点,即使触摸手势已经开始。随后移动可能被恢复到该锚点,约束手势或其惯性,直到新的阅读输入释放锚点。自动路径的触摸延迟不保护这一明确的手动路径。
|
||||
|
||||
- **运行图标分辨率**——动态鲸鱼使用 28×28 图片,默认 CSS 尺寸为 14×14。较大的正文字号或大于 2 的设备像素比可能使位图描边变模糊;静态回退图标仍为矢量图。浏览器图片缓存管理动画时钟,因此重新挂载不保证从第一帧重新播放。
|
||||
|
||||
- **工具变更展示** — `developer-message` Definition 与 `input-message` 共用上下文展示。仅包含工具变更的 developer 消息在单个工具新增或移除时直接显示工具名,不提供展开操作。多个变更显示新增、移除数量,展开后按变更类型分别以逗号分隔显示一行工具列表。混合内容使用通用上下文展示。
|
||||
|
||||
@@ -287,7 +287,8 @@ export function apply(ctx: Context): void {
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
},
|
||||
loadOlder: () => { void session.loadOlder() },
|
||||
loadOlder: () => { void session.loadOlder({ deferConsumption: true }) },
|
||||
consumeOlder: () => session.consumeOlder(),
|
||||
loadThrough: seq => session.loadThrough(seq),
|
||||
loadImage: Object.assign(
|
||||
(attachment: ImageAttachmentRef) => ctx.uiConversation.imageUrl(sessionId, attachment),
|
||||
|
||||
@@ -37,6 +37,11 @@
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.scroll,
|
||||
:global([data-conversation-scroll]):has(.root) {
|
||||
overscroll-behavior-y: none;
|
||||
}
|
||||
|
||||
.root[data-chat-following-tail] .scroll,
|
||||
:global([data-conversation-scroll]):has(.root[data-chat-following-tail]) {
|
||||
overflow-anchor: none;
|
||||
@@ -318,11 +323,15 @@
|
||||
}
|
||||
|
||||
.older {
|
||||
overflow-anchor: none;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.older button {
|
||||
display: grid;
|
||||
line-height: 18px;
|
||||
white-space: nowrap;
|
||||
border: none;
|
||||
border-radius: var(--dsw-radius-sm);
|
||||
padding: 4px 12px;
|
||||
@@ -332,6 +341,14 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.older button > span {
|
||||
grid-area: 1 / 1;
|
||||
}
|
||||
|
||||
.historyHidden {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.older button:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.6;
|
||||
|
||||
@@ -63,7 +63,7 @@ const MOTION_TAIL_MS = 800
|
||||
export function ChatView({
|
||||
useSession, useChat, useConversation, useSessions, renderSlot,
|
||||
sessionId, openFile, openExternalLink, resolveFilePath, useContextMenu,
|
||||
loadOlder, loadThrough, loadImage, inspectCall, chatScroll, forkAt,
|
||||
loadOlder, consumeOlder, loadThrough, loadImage, inspectCall, chatScroll, forkAt,
|
||||
usePresentation, useProjection, useInteractionIds, dispatchInteraction, t, fileDrag,
|
||||
}: ChatViewSlotProps) {
|
||||
const { getTriggerProps } = useContextMenu()
|
||||
@@ -136,6 +136,8 @@ export function ChatView({
|
||||
const openError = useSession(s => s.openError)
|
||||
const hasMore = useSession(s => s.hasMore)
|
||||
const loadingOlder = useSession(s => s.loadingOlder)
|
||||
const historyLoadStatus = useSession(s => s.historyLoadStatus)
|
||||
const historyWasPartial = useSession(s => s.historyWasPartial)
|
||||
const [fileOpenError, setFileOpenError] = useState<{ path: string; message: string } | null>(null)
|
||||
const [fileOpenBusy, setFileOpenBusy] = useState(false)
|
||||
// Close/retry must ignore a settlement that started before the latest
|
||||
@@ -211,8 +213,9 @@ export function ChatView({
|
||||
const steeringId = latestSteering?.source.kind === 'user' && 'rpcId' in latestSteering.source
|
||||
? latestSteering.source.rpcId : latestSteering?.id ?? null
|
||||
const scroll = useChatScroll({
|
||||
ready: openState === 'open',
|
||||
order, firstSeq, lastKey, running, loadingOlder, hasMore, chatScroll, loadOlder, loadThrough, deferCompletedTurns,
|
||||
isOpen: openState === 'open',
|
||||
order, firstSeq, lastKey, running, loadingOlder, historyLoadStatus, hasMore, chatScroll,
|
||||
loadOlder, consumeOlder, loadThrough, deferCompletedTurns,
|
||||
lastIsUser: lastKey !== null && nodeStore.get(lastKey)?.kind === 'user',
|
||||
steeringId,
|
||||
submissionId: visibleSubmissions.at(-1)?.requestId ?? null,
|
||||
@@ -247,10 +250,24 @@ export function ChatView({
|
||||
{t('chat.loadError', { message: openError.message, code: openError.code })}
|
||||
</div>
|
||||
)}
|
||||
{hasMore && (
|
||||
<div className={css.older}>
|
||||
<button type="button" disabled={loadingOlder} onClick={scroll.loadEarlier}>
|
||||
{loadingOlder ? t('loading') : t('chat.loadOlder')}
|
||||
{(hasMore || historyWasPartial) && (
|
||||
<div className={css.older} ref={scroll.historyEdgeRef} data-chat-history={historyLoadStatus}>
|
||||
<button type="button" disabled={!hasMore}
|
||||
onClick={scroll.loadEarlier}>
|
||||
{([
|
||||
['more', t('chat.loadOlder')],
|
||||
['loading', t('chat.loadingOlder')],
|
||||
['processing', t('chat.processingOlder')],
|
||||
['error', t('chat.retryOlder')],
|
||||
['start', t('chat.historyStart')],
|
||||
] as const).map(([state, label]) => {
|
||||
const current = !hasMore ? 'start' : loadingOlder ? 'loading'
|
||||
: historyLoadStatus === 'error' ? 'error'
|
||||
: scroll.historyInputHeld ? 'more'
|
||||
: scroll.historyProcessing ? 'processing' : 'more'
|
||||
return <span key={state} aria-hidden={state !== current}
|
||||
className={state === current ? undefined : css.historyHidden}>{label}</span>
|
||||
})}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -72,6 +72,8 @@ export interface InputFoldDriver {
|
||||
|
||||
/** Animation lifetime and fold coordination for one viewport. */
|
||||
export interface FlowMotion extends FlowMotionRows {
|
||||
/** @returns whether owned transitions or a scheduled, running, or settling batch remain active. */
|
||||
active(): boolean
|
||||
/** @returns whether a row owned by this viewport is closing. */
|
||||
foldActive(): boolean
|
||||
/** @param listener - callback after the last fold. @returns cancellation for a pending callback. */
|
||||
@@ -494,6 +496,7 @@ export function createFlowMotion(
|
||||
|
||||
return {
|
||||
collapse, reveal, cancelMotion, foldActive, onFoldIdle, cancelInput, resumeInput, clear, hideNow,
|
||||
active: () => running.size > 0 || batchScheduled || batch || settling,
|
||||
inputActive: () => batch || (batchScheduled && [...boxes.values()].some(box => box.closing && batchMember(box))),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/** Turn jumps and history-prepend anchoring, independent of DOM measurement. */
|
||||
import { useLayoutEffect, useState } from 'react'
|
||||
import type { SessionSnapshot } from '@deepseek-ai/dsh-api-session-controller/client'
|
||||
import type { SessionSeq } from '@deepseek-ai/dsh-session/types'
|
||||
import type { ChatNode } from '../contract/chat-nodes.ts'
|
||||
import type { ChatViewSlotProps } from '../contract/slots.ts'
|
||||
@@ -8,10 +9,12 @@ import type { ChatReading, ReadingSample } from './use-chat-reading.ts'
|
||||
import type { ChatViewport } from './use-chat-viewport.ts'
|
||||
|
||||
/** History availability and loading operations for the committed Chat window. */
|
||||
export interface ChatNavigationInput extends Pick<ChatViewSlotProps, 'loadOlder' | 'loadThrough'> {
|
||||
export interface ChatNavigationInput extends Pick<ChatViewSlotProps, 'loadOlder' | 'loadThrough' | 'consumeOlder'> {
|
||||
readonly firstSeq: ChatNode['anchorSeq'] | null
|
||||
readonly hasMore: boolean
|
||||
readonly loadingOlder: boolean
|
||||
readonly historyLoadStatus: SessionSnapshot['historyLoadStatus']
|
||||
readonly isOpen: boolean
|
||||
}
|
||||
|
||||
interface TurnJump {
|
||||
@@ -22,23 +25,33 @@ interface TurnJump {
|
||||
repageHead: ChatNavigationInput['firstSeq']
|
||||
}
|
||||
|
||||
type HistoryIntent = 'none' | 'auto' | 'manual'
|
||||
|
||||
/** Owns one replaceable turn jump and the anchor retained while history loads. */
|
||||
export class ChatNavigation {
|
||||
private jump: TurnJump | null = null
|
||||
private settleFrame: number | null = null
|
||||
private historyFrame: number | null = null
|
||||
private historyIntent: HistoryIntent = 'none'
|
||||
private historyRequested = false
|
||||
|
||||
constructor(
|
||||
private readonly viewport: ChatViewport,
|
||||
private readonly reading: ChatReading,
|
||||
private input: ChatNavigationInput,
|
||||
private readonly onBusyTurn: (turn: number | null) => void,
|
||||
private readonly onHistoryIntent: (intent: HistoryIntent) => void = () => {},
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Adopt committed history availability without starting a request.
|
||||
* @param input - history state from the latest committed render.
|
||||
*/
|
||||
setInput(input: ChatNavigationInput): void { this.input = input }
|
||||
setInput(input: ChatNavigationInput): void {
|
||||
this.input = input
|
||||
this.historyRequested = false
|
||||
if (!input.isOpen || input.historyLoadStatus === 'error' || !input.hasMore) this.setHistoryIntent('none')
|
||||
}
|
||||
|
||||
/** Cancel navigation when opening a Chat view. */
|
||||
reset(): void {
|
||||
@@ -48,10 +61,19 @@ export class ChatNavigation {
|
||||
/** Cancel local callbacks; late history completions cannot revive a task. */
|
||||
dispose(): void {
|
||||
this.clearTask()
|
||||
if (this.historyFrame !== null) cancelAnimationFrame(this.historyFrame)
|
||||
this.historyFrame = null
|
||||
}
|
||||
|
||||
/** Release the jump, paging anchor, and busy indicator without cancelling shared history I/O. */
|
||||
cancel(): void {
|
||||
this.setHistoryIntent('none')
|
||||
this.clearTask()
|
||||
this.onBusyTurn(null)
|
||||
}
|
||||
|
||||
/** Release a jump after reader input without cancelling an accepted history click. */
|
||||
readerInteracted(): void {
|
||||
this.clearTask()
|
||||
this.onBusyTurn(null)
|
||||
}
|
||||
@@ -60,6 +82,7 @@ export class ChatNavigation {
|
||||
this.cancelFrame()
|
||||
this.jump = null
|
||||
this.viewport.stopPreserving()
|
||||
this.viewport.cancelHistoryPrepend()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -90,12 +113,98 @@ export class ChatNavigation {
|
||||
this.request(jump)
|
||||
}
|
||||
|
||||
/** Request one older page while retaining the current semantic position. */
|
||||
/** Request and consume one page, retaining the click until success, exhaustion, or failure. */
|
||||
readonly loadEarlier = (): void => {
|
||||
this.cancel()
|
||||
this.viewport.beginPaging()
|
||||
this.setHistoryIntent('manual')
|
||||
this.reading.pauseFollowing()
|
||||
this.input.loadOlder()
|
||||
if (this.input.historyLoadStatus === 'error') {
|
||||
this.historyRequested = true
|
||||
this.input.loadOlder()
|
||||
} else this.requestHistoryCheck()
|
||||
}
|
||||
|
||||
private setHistoryIntent(intent: HistoryIntent): void {
|
||||
if (intent === this.historyIntent) return
|
||||
this.historyIntent = intent
|
||||
this.onHistoryIntent(intent)
|
||||
}
|
||||
|
||||
/** Coalesce event-driven checks; waiting never schedules its own next frame. */
|
||||
requestHistoryCheck(): void {
|
||||
if (!this.input.isOpen || this.input.historyLoadStatus === 'error') return
|
||||
if (this.historyIntent === 'none' && (this.input.historyLoadStatus !== 'idle'
|
||||
|| !this.input.hasMore || this.reading.followingTail)) return
|
||||
if (this.historyFrame !== null || typeof requestAnimationFrame !== 'function') return
|
||||
this.historyFrame = requestAnimationFrame(() => {
|
||||
this.historyFrame = null
|
||||
this.checkHistory()
|
||||
})
|
||||
}
|
||||
|
||||
private checkHistory(): void {
|
||||
const input = this.input
|
||||
if (!input.isOpen || this.jump !== null || this.historyRequested || input.loadingOlder) return
|
||||
if (input.historyLoadStatus === 'error') return
|
||||
if (input.historyLoadStatus === 'pendingConsumption') {
|
||||
if (this.historyIntent === 'none') return
|
||||
if (this.reading.followingTail && this.historyIntent === 'auto') {
|
||||
this.setHistoryIntent('none')
|
||||
return
|
||||
}
|
||||
if (this.historyIntent === 'manual') {
|
||||
this.viewport.beginManualPaging()
|
||||
this.reading.pauseFollowing()
|
||||
this.consumeHistory('preserve')
|
||||
return
|
||||
}
|
||||
const mode = this.viewport.historyConsumption()
|
||||
switch (mode) {
|
||||
case 'touch':
|
||||
case 'inner-scroll':
|
||||
return
|
||||
case 'motion':
|
||||
case 'pointer':
|
||||
case 'unavailable':
|
||||
this.setHistoryIntent('none')
|
||||
return
|
||||
case 'prepend':
|
||||
if (!this.viewport.beginHistoryPrepend() && input.firstSeq !== null) {
|
||||
this.setHistoryIntent('none')
|
||||
return
|
||||
}
|
||||
break
|
||||
case 'preserve':
|
||||
case 'native':
|
||||
break
|
||||
}
|
||||
if (mode === 'preserve') {
|
||||
this.viewport.beginPreserving()
|
||||
if (!this.viewport.preserving) this.viewport.beginPaging()
|
||||
if (!this.viewport.preserving && input.firstSeq !== null) {
|
||||
this.setHistoryIntent('none')
|
||||
return
|
||||
}
|
||||
this.reading.pauseFollowing()
|
||||
}
|
||||
this.consumeHistory(mode)
|
||||
return
|
||||
}
|
||||
if (!input.hasMore || this.historyIntent !== 'manual' && (this.reading.followingTail
|
||||
|| !this.viewport.confirmHistoryStart())) return
|
||||
if (this.historyIntent === 'none') this.setHistoryIntent('auto')
|
||||
this.historyRequested = true
|
||||
input.loadOlder()
|
||||
}
|
||||
|
||||
private consumeHistory(mode: 'native' | 'preserve' | 'prepend'): void {
|
||||
this.historyRequested = true
|
||||
if (this.input.consumeOlder()) this.setHistoryIntent('none')
|
||||
else {
|
||||
this.historyRequested = false
|
||||
if (mode === 'preserve') this.viewport.stopPreserving()
|
||||
if (mode === 'prepend') this.viewport.cancelHistoryPrepend()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -120,13 +229,6 @@ export class ChatNavigation {
|
||||
return true
|
||||
}
|
||||
|
||||
/** Retarget a still-loading page only after inner or outer reader scrolling ends. */
|
||||
readerSettled(): void {
|
||||
if (this.input.loadingOlder && this.jump === null && !this.viewport.preserving && !this.reading.followingTail) {
|
||||
this.viewport.beginPreserving()
|
||||
}
|
||||
}
|
||||
|
||||
/** Land, retry, or complete the current jump against the committed window. */
|
||||
reconcile(): void {
|
||||
const jump = this.jump
|
||||
@@ -197,10 +299,11 @@ export class ChatNavigation {
|
||||
*/
|
||||
export function useChatNavigation(
|
||||
viewport: ChatViewport, reading: ChatReading, input: ChatNavigationInput,
|
||||
): { navigation: ChatNavigation; busyTurn: number | null } {
|
||||
): { navigation: ChatNavigation; busyTurn: number | null; historyPending: boolean; historyProcessing: boolean } {
|
||||
const [busyTurn, setBusyTurn] = useState<number | null>(null)
|
||||
const [navigation] = useState(() => new ChatNavigation(viewport, reading, input, setBusyTurn))
|
||||
const [historyIntent, setHistoryIntent] = useState<HistoryIntent>('none')
|
||||
const [navigation] = useState(() => new ChatNavigation(viewport, reading, input, setBusyTurn, setHistoryIntent))
|
||||
useLayoutEffect(() => { navigation.setInput(input) }, [navigation, input])
|
||||
useLayoutEffect(() => () => { navigation.dispose() }, [navigation])
|
||||
return { navigation, busyTurn }
|
||||
return { navigation, busyTurn, historyPending: historyIntent === 'manual', historyProcessing: historyIntent !== 'none' }
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/** Composes viewport operations, reading policy, and history navigation for Chat. */
|
||||
import { useCallback, useLayoutEffect, useMemo, useRef, type RefObject } from 'react'
|
||||
import { useCallback, useLayoutEffect, useMemo, useRef, useState, type RefObject } from 'react'
|
||||
import type { TranscriptViewMode } from '../../chat-settings.ts'
|
||||
import type { ChatSnapshot } from '../contract/snapshot.ts'
|
||||
import type { ChatViewSlotProps } from '../contract/slots.ts'
|
||||
@@ -11,7 +11,6 @@ import type { FlowMotionRows } from './flow-motion.ts'
|
||||
/** Committed content and Session operations used to reconcile scroll ownership. */
|
||||
export interface ChatScrollInput extends ChatNavigationInput {
|
||||
readonly chatScroll: ChatViewSlotProps['chatScroll']
|
||||
readonly ready: boolean
|
||||
readonly order: readonly string[]
|
||||
readonly lastKey: string | null
|
||||
readonly lastIsUser: boolean
|
||||
@@ -33,12 +32,16 @@ interface ChatScrollState extends ChatReadingState {
|
||||
readonly busyTurn: number | null
|
||||
readonly navigateToTurn: ChatNavigation['navigateToTurn']
|
||||
readonly loadEarlier: ChatNavigation['loadEarlier']
|
||||
readonly historyInputHeld: boolean
|
||||
readonly historyProcessing: boolean
|
||||
readonly historyPending: boolean
|
||||
readonly historyEdgeRef: (element: HTMLDivElement | null) => void
|
||||
readonly returnToBottom: () => void
|
||||
readonly motion: FlowMotionRows
|
||||
}
|
||||
|
||||
/**
|
||||
* Coordinate scroll policy after Chat content commits. Initial history must be ready
|
||||
* Coordinate scroll policy after Chat content commits. Initial history must be available
|
||||
* before automatic scroll or resize handling can initialize reading state and fold motion.
|
||||
* New submitted input supersedes pending reader sampling.
|
||||
* @param input - current Chat content, scroll memory, and history operations.
|
||||
@@ -46,15 +49,17 @@ interface ChatScrollState extends ChatReadingState {
|
||||
*/
|
||||
export function useChatScroll(input: ChatScrollInput): ChatScrollState {
|
||||
const {
|
||||
ready, order, firstSeq, lastKey, lastIsUser, steeringId, submissionId, submissionSteers, running,
|
||||
deferCompletedTurns, loadedTurns, chatScroll, hasMore, loadingOlder, loadOlder, loadThrough, transcriptView,
|
||||
isOpen, order, firstSeq, lastKey, lastIsUser, steeringId, submissionId, submissionSteers, running,
|
||||
deferCompletedTurns, loadedTurns, chatScroll, hasMore, loadingOlder, historyLoadStatus,
|
||||
loadOlder, consumeOlder, loadThrough, transcriptView,
|
||||
} = input
|
||||
const { viewport, listRef, columnRef } = useChatViewport()
|
||||
const [historyInputHeld, setHistoryInputHeld] = useState(false)
|
||||
const { reading, state } = useChatReading(viewport, chatScroll, loadedTurns.at(-1)?.turn ?? null)
|
||||
const navigationInput = useMemo(() => ({
|
||||
firstSeq, loadingOlder, hasMore, loadOlder, loadThrough,
|
||||
}), [firstSeq, loadingOlder, hasMore, loadOlder, loadThrough])
|
||||
const { navigation, busyTurn } = useChatNavigation(viewport, reading, navigationInput)
|
||||
firstSeq, loadingOlder, hasMore, historyLoadStatus, isOpen, loadOlder, consumeOlder, loadThrough,
|
||||
}), [firstSeq, loadingOlder, hasMore, historyLoadStatus, isOpen, loadOlder, consumeOlder, loadThrough])
|
||||
const { navigation, busyTurn, historyPending, historyProcessing } = useChatNavigation(viewport, reading, navigationInput)
|
||||
const content = useRef<{ input: ChatScrollInput; applied: ChatScrollInput | null; opened: boolean }>({
|
||||
input, applied: null, opened: false,
|
||||
})
|
||||
@@ -64,7 +69,7 @@ export function useChatScroll(input: ChatScrollInput): ChatScrollState {
|
||||
|
||||
const processContent = useCallback(() => {
|
||||
const current = content.current.input
|
||||
if (!current.ready && !content.current.opened) return
|
||||
if (!current.isOpen && !content.current.opened) return
|
||||
const previous = content.current.applied
|
||||
const steeringInput = current.steeringId !== null && current.steeringId !== previous?.steeringId
|
||||
&& current.steeringId !== previous?.submissionId
|
||||
@@ -73,7 +78,7 @@ export function useChatScroll(input: ChatScrollInput): ChatScrollState {
|
||||
const ownInput = (current.lastIsUser && current.lastKey !== previous?.lastKey) || steeringInput || submissionInput
|
||||
if (reading.pending && !ownInput) return
|
||||
content.current.applied = current
|
||||
if (current.ready && !content.current.opened) {
|
||||
if (current.isOpen && !content.current.opened) {
|
||||
content.current.opened = true
|
||||
navigation.reset()
|
||||
reading.restore()
|
||||
@@ -92,7 +97,7 @@ export function useChatScroll(input: ChatScrollInput): ChatScrollState {
|
||||
navigation.reconcile()
|
||||
return
|
||||
}
|
||||
const tipChanged = previous === null || current.ready !== previous.ready
|
||||
const tipChanged = previous === null || current.isOpen !== previous.isOpen
|
||||
|| current.firstSeq !== previous.firstSeq || current.lastKey !== previous.lastKey
|
||||
|| current.order.length !== previous.order.length || current.running !== previous.running
|
||||
|| current.steeringId !== previous.steeringId || current.submissionId !== previous.submissionId
|
||||
@@ -112,12 +117,17 @@ export function useChatScroll(input: ChatScrollInput): ChatScrollState {
|
||||
},
|
||||
scrollEnd: (outer) => {
|
||||
reading.onScrollEnd(outer)
|
||||
navigation.readerSettled()
|
||||
navigation.requestHistoryCheck()
|
||||
},
|
||||
interact: () => { navigation.readerInteracted() },
|
||||
history: () => {
|
||||
setHistoryInputHeld(viewport.historyInputHeld)
|
||||
if (content.current.opened) navigation.requestHistoryCheck()
|
||||
},
|
||||
interact: () => { navigation.cancel() },
|
||||
intent: () => { cancelPendingFollow(); reading.interruptFollow() },
|
||||
resize: () => {
|
||||
if (!content.current.opened) return
|
||||
navigation.requestHistoryCheck()
|
||||
if (navigation.contentCommitted()) {
|
||||
navigation.reconcile()
|
||||
return
|
||||
@@ -129,6 +139,7 @@ export function useChatScroll(input: ChatScrollInput): ChatScrollState {
|
||||
const disconnectReading = reading.connect((sample) => {
|
||||
navigation.readerSampled(sample)
|
||||
processContent()
|
||||
navigation.requestHistoryCheck()
|
||||
})
|
||||
return () => {
|
||||
disconnectViewport()
|
||||
@@ -141,7 +152,7 @@ export function useChatScroll(input: ChatScrollInput): ChatScrollState {
|
||||
useLayoutEffect(() => {
|
||||
const previous = content.current.input
|
||||
content.current.input = {
|
||||
ready, order, lastKey, lastIsUser, steeringId, submissionId, submissionSteers, running, loadedTurns,
|
||||
order, lastKey, lastIsUser, steeringId, submissionId, submissionSteers, running, loadedTurns,
|
||||
chatScroll, deferCompletedTurns, transcriptView, ...navigationInput,
|
||||
}
|
||||
if (!deferCompletedTurns) {
|
||||
@@ -149,13 +160,20 @@ export function useChatScroll(input: ChatScrollInput): ChatScrollState {
|
||||
reading.interruptFollow()
|
||||
}
|
||||
viewport.updateTurns(loadedTurns)
|
||||
const layoutChanged = previous.order !== order || previous.ready !== ready
|
||||
const layoutChanged = previous.order !== order || previous.isOpen !== isOpen
|
||||
const historyConsumed = previous.historyLoadStatus === 'pendingConsumption' && historyLoadStatus === 'idle'
|
||||
if (!isOpen || historyLoadStatus === 'error') viewport.cancelHistoryPrepend()
|
||||
else if (layoutChanged || historyConsumed) {
|
||||
const landing = viewport.finishHistoryPrepend()
|
||||
if (landing !== null) reading.preservePosition(landing)
|
||||
}
|
||||
if (layoutChanged) viewport.invalidate()
|
||||
viewport.reclaimBelow()
|
||||
processContent()
|
||||
if (layoutChanged && content.current.opened) reading.refreshActiveTurn()
|
||||
if (content.current.opened) navigation.requestHistoryCheck()
|
||||
}, [
|
||||
viewport, reading, processContent, navigationInput, ready, order, lastKey, lastIsUser,
|
||||
viewport, reading, processContent, navigationInput, isOpen, order, lastKey, lastIsUser,
|
||||
steeringId, submissionId, submissionSteers, running, loadedTurns, chatScroll, deferCompletedTurns, transcriptView,
|
||||
cancelPendingFollow,
|
||||
])
|
||||
@@ -182,6 +200,10 @@ export function useChatScroll(input: ChatScrollInput): ChatScrollState {
|
||||
listRef, columnRef, ...state, busyTurn, motion: viewport.motion,
|
||||
navigateToTurn: navigation.navigateToTurn,
|
||||
loadEarlier: navigation.loadEarlier,
|
||||
historyPending,
|
||||
historyProcessing,
|
||||
historyInputHeld,
|
||||
historyEdgeRef: viewport.setHistoryEdge,
|
||||
returnToBottom,
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,8 @@ interface ViewportEvents {
|
||||
interact: () => void
|
||||
/** Any reading gesture, before paging ownership is consulted. */
|
||||
intent?: () => void
|
||||
/** Recheck a prepared history page after an input or transition settles. */
|
||||
history?: () => void
|
||||
}
|
||||
|
||||
interface ViewportElements {
|
||||
@@ -79,7 +81,13 @@ export class ChatViewport {
|
||||
private turns: ReturnType<ChatSnapshot['navigation']['items']> = []
|
||||
private observation: { top: number; landing: ViewportLanding | null } = { top: 0, landing: null }
|
||||
private paging: PagingPosition | null = null
|
||||
private historyPrepend: PagingPosition | null = null
|
||||
private reader: FoldReader | null = null
|
||||
private historyEdge: HTMLElement | null = null
|
||||
private historyHeight = 0
|
||||
private historyScrolling = new Set<EventTarget>()
|
||||
private historyPointer = false
|
||||
private historyTouch = false
|
||||
/** Rows measured before an instant close; see {@link ChatViewport.keepAboveClosing}. */
|
||||
private closing: ClosingLayout | null = null
|
||||
/** Discards a closing measurement that no size observation consumed by the end of its frame. */
|
||||
@@ -114,18 +122,26 @@ export class ChatViewport {
|
||||
const elements = { list, column, scroller, composer, spacer }
|
||||
this.elements = elements
|
||||
if (spacer !== null) spacer.style.height = ''
|
||||
scroller.addEventListener('scroll', this.onHistoryScroll, { passive: true, capture: true })
|
||||
scroller.addEventListener('pointerdown', this.onHistoryPointerDown, { passive: true, capture: true })
|
||||
scroller.addEventListener('touchstart', this.onHistoryTouch, { passive: true, capture: true })
|
||||
for (const type of ['pointerup', 'pointercancel', 'touchend', 'touchcancel', 'transitionend', 'transitioncancel']) {
|
||||
scroller.ownerDocument.addEventListener(type, this.onHistoryRelease, true)
|
||||
}
|
||||
scroller.addEventListener('scroll', this.onScroll, { passive: true })
|
||||
scroller.addEventListener('scrollend', this.onScrollEnd, { passive: true, capture: true })
|
||||
for (const type of READING_INTENTS) scroller.addEventListener(type, this.onIntent, { passive: true, capture: true })
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
this.observer = new ResizeObserver(() => {
|
||||
this.observer = new ResizeObserver((entries) => {
|
||||
if (this.elements !== elements) return
|
||||
// Read while layout is still clean, before this callback writes room or scroll positions. Those writes
|
||||
// leave the content height unchanged, so it is also the content this frame paints.
|
||||
if (entries.some(entry => entry.target === elements.scroller)) this.historyHeight = elements.scroller.clientHeight
|
||||
this.settledContent = elements.scroller.scrollHeight - this.spacerHeight()
|
||||
this.keepAboveClosing()
|
||||
this.reclaimBelow()
|
||||
this.invalidate()
|
||||
this.pruneHistoryScrolling()
|
||||
this.events?.resize()
|
||||
})
|
||||
this.observer.observe(column)
|
||||
@@ -136,10 +152,22 @@ export class ChatViewport {
|
||||
|
||||
/** Disconnect DOM resources and clear observations for the detached view. */
|
||||
detach(): void {
|
||||
this.historyPrepend = null
|
||||
this.stopPreserving()
|
||||
this.motion.clear()
|
||||
if (this.elements !== null) this.elements.scroller.style.overflowAnchor = ''
|
||||
this.dropClosing()
|
||||
const scroller = this.elements?.scroller
|
||||
scroller?.removeEventListener('scroll', this.onHistoryScroll, true)
|
||||
scroller?.removeEventListener('pointerdown', this.onHistoryPointerDown, true)
|
||||
scroller?.removeEventListener('touchstart', this.onHistoryTouch, true)
|
||||
for (const type of ['pointerup', 'pointercancel', 'touchend', 'touchcancel', 'transitionend', 'transitioncancel']) {
|
||||
scroller?.ownerDocument.removeEventListener(type, this.onHistoryRelease, true)
|
||||
}
|
||||
this.historyHeight = 0
|
||||
this.historyScrolling.clear()
|
||||
this.historyPointer = false
|
||||
this.historyTouch = false
|
||||
this.elements?.scroller.removeEventListener('scroll', this.onScroll)
|
||||
this.elements?.scroller.removeEventListener('scrollend', this.onScrollEnd, true)
|
||||
for (const type of READING_INTENTS) this.elements?.scroller.removeEventListener(type, this.onIntent, true)
|
||||
@@ -769,13 +797,27 @@ export class ChatViewport {
|
||||
return metrics === null ? null : this.write(position.scrollTop, metrics, null)
|
||||
}
|
||||
|
||||
/** Capture a manual paging anchor without consulting automatic-consumption blockers. */
|
||||
beginManualPaging(): void {
|
||||
if (this.elements === null) return
|
||||
if (this.elements.scroller.scrollTop <= 0.5) this.beginPaging()
|
||||
else {
|
||||
this.beginPreserving()
|
||||
if (!this.preserving) this.beginPaging()
|
||||
}
|
||||
}
|
||||
|
||||
/** Retain the first eligible transcript seat in DOM order; selection reads no geometry. */
|
||||
beginPaging(): void {
|
||||
this.stopPreserving()
|
||||
const row = this.elements?.list.querySelector<HTMLElement>(
|
||||
const row = this.pagingAnchor()
|
||||
if (row !== null) this.retain(row)
|
||||
}
|
||||
|
||||
private pagingAnchor(): HTMLElement | null {
|
||||
return this.elements?.list.querySelector<HTMLElement>(
|
||||
'[data-chat-paging-anchor]:not(:empty):not([hidden]):not([hidden] *)',
|
||||
)
|
||||
if (row != null) this.retain(row)
|
||||
) ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -790,18 +832,16 @@ export class ChatViewport {
|
||||
this.retain(row, position)
|
||||
}
|
||||
|
||||
private retain(row: HTMLElement, position?: ChatScrollPosition, groupTop?: number): PagingPosition | null {
|
||||
private measurePaging(row: HTMLElement, position?: ChatScrollPosition, previousGroup?: PagingPosition['group']): PagingPosition | null {
|
||||
const elements = this.elements
|
||||
const key = row.dataset.chatAnchorKey
|
||||
if (elements === null || key === undefined) return null
|
||||
const previous = this.paging?.group
|
||||
if (previous != null) this.observer?.unobserve(previous.content)
|
||||
const top = row.getBoundingClientRect().top
|
||||
const body = row.closest<HTMLElement>('[data-step-process-body]')
|
||||
const content = body?.querySelector<HTMLElement>('[data-step-process-content]')
|
||||
const group = body === null || content == null ? null
|
||||
: { body, content, top: groupTop ?? top - body.getBoundingClientRect().top }
|
||||
this.paging = {
|
||||
: { body, content, top: previousGroup?.top ?? top - body.getBoundingClientRect().top }
|
||||
return {
|
||||
row, group,
|
||||
position: position ?? {
|
||||
anchorKey: key,
|
||||
@@ -809,8 +849,19 @@ export class ChatViewport {
|
||||
scrollTop: elements.scroller.scrollTop,
|
||||
},
|
||||
}
|
||||
if (group !== null) this.observer?.observe(group.content)
|
||||
return this.paging
|
||||
}
|
||||
|
||||
private retain(row: HTMLElement, position?: ChatScrollPosition): PagingPosition | null {
|
||||
const paging = this.measurePaging(row, position)
|
||||
if (paging === null) return null
|
||||
this.retainPaging(paging)
|
||||
return paging
|
||||
}
|
||||
|
||||
private retainPaging(paging: PagingPosition): void {
|
||||
this.stopPreserving()
|
||||
this.paging = paging
|
||||
if (paging.group !== null) this.observer?.observe(paging.group.content)
|
||||
}
|
||||
|
||||
/** Release paging ownership and its content-size observation. */
|
||||
@@ -832,24 +883,33 @@ export class ChatViewport {
|
||||
* @returns the actual landing, or null when no visible retained row remains.
|
||||
*/
|
||||
preserve(): ViewportLanding | null {
|
||||
let paging = this.paging
|
||||
const paging = this.resolvePaging(this.paging)
|
||||
if (paging === null) {
|
||||
this.stopPreserving()
|
||||
return null
|
||||
}
|
||||
if (paging !== this.paging) this.retainPaging(paging)
|
||||
return this.applyPaging(paging)
|
||||
}
|
||||
|
||||
private resolvePaging(paging: PagingPosition | null): PagingPosition | null {
|
||||
const elements = this.elements
|
||||
if (paging === null || elements === null) return null
|
||||
if (!elements.list.contains(paging.row)) {
|
||||
// Segmentation can remount the same semantic row under another group.
|
||||
const replacement = this.anchor(paging.position.anchorKey)
|
||||
if (replacement === null) {
|
||||
this.stopPreserving()
|
||||
return null
|
||||
}
|
||||
paging = this.retain(replacement, paging.position, paging.group?.top)
|
||||
if (replacement === null) return null
|
||||
paging = this.measurePaging(replacement, paging.position, paging.group)
|
||||
if (paging === null) return null
|
||||
}
|
||||
return paging.row.closest('[hidden]') !== null || paging.row.matches(':empty') ? null : paging
|
||||
}
|
||||
|
||||
/** Restore the captured screen position; native anchoring already applied to this change needs no further correction. */
|
||||
private applyPaging(paging: PagingPosition): ViewportLanding | null {
|
||||
const elements = this.elements
|
||||
if (elements === null) return null
|
||||
const { row, group, position } = paging
|
||||
if (row.closest('[hidden]') !== null || row.matches(':empty')) {
|
||||
this.stopPreserving()
|
||||
return null
|
||||
}
|
||||
if (group !== null && group.body.contains(row)) {
|
||||
const top = row.getBoundingClientRect().top - group.body.getBoundingClientRect().top
|
||||
const metrics = scrollMetrics(group.body)
|
||||
@@ -919,6 +979,103 @@ export class ChatViewport {
|
||||
return landing
|
||||
}
|
||||
|
||||
/**
|
||||
* Track the history control for request-distance checks.
|
||||
* @param element - stable history control, or null when its view unmounts.
|
||||
*/
|
||||
readonly setHistoryEdge = (element: HTMLDivElement | null): void => {
|
||||
this.historyEdge = element
|
||||
}
|
||||
|
||||
private pruneHistoryScrolling(): void {
|
||||
for (const target of this.historyScrolling) {
|
||||
if (!(target instanceof HTMLElement) || this.elements?.scroller.contains(target) !== true) {
|
||||
this.historyScrolling.delete(target)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm the actual history distance immediately before an automatic request.
|
||||
* @returns whether the control is within two viewport heights.
|
||||
*/
|
||||
confirmHistoryStart(): boolean {
|
||||
const scroller = this.elements?.scroller
|
||||
if (scroller === undefined || this.historyEdge === null || this.historyHeight <= 0) return false
|
||||
const distance = scroller.getBoundingClientRect().top - this.historyEdge.getBoundingClientRect().bottom
|
||||
return distance <= this.historyHeight * 2
|
||||
}
|
||||
|
||||
/**
|
||||
* Expose held input to history status copy.
|
||||
* @returns whether a touch or pointer remains down.
|
||||
*/
|
||||
get historyInputHeld(): boolean { return this.historyTouch || this.historyPointer }
|
||||
|
||||
/**
|
||||
* Select consumption behavior from current input and motion state.
|
||||
* @returns the blocker or positioning path for a prepared page.
|
||||
*/
|
||||
historyConsumption(): 'motion' | 'touch' | 'pointer' | 'inner-scroll' | 'unavailable' | 'native' | 'preserve' | 'prepend' {
|
||||
const elements = this.elements
|
||||
if (elements === null) return 'unavailable'
|
||||
if (this.historyTouch) return 'touch'
|
||||
if (this.historyScrolling.size > 0
|
||||
&& (this.historyScrolling.size !== 1 || !this.historyScrolling.has(elements.scroller))) return 'inner-scroll'
|
||||
if (this.motion.active() || this.reader?.follow.animating) return 'motion'
|
||||
if (this.historyPointer) return 'pointer'
|
||||
if (this.historyScrolling.size === 0) return elements.scroller.scrollTop <= 0.5 ? 'prepend' : 'preserve'
|
||||
if (this.preserving || typeof CSS === 'undefined' || !CSS.supports('overflow-anchor', 'auto')) return 'unavailable'
|
||||
return elements.scroller.scrollTop <= 0.5 ? 'prepend' : 'native'
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture one top-edge prepend anchor without acquiring continuous scroll ownership.
|
||||
* @returns whether an old paging row was captured.
|
||||
*/
|
||||
beginHistoryPrepend(): boolean {
|
||||
const row = this.pagingAnchor()
|
||||
this.historyPrepend = row === null ? null : this.measurePaging(row)
|
||||
return this.historyPrepend !== null
|
||||
}
|
||||
|
||||
/** Clear an unapplied one-shot correction when its page was not consumed. */
|
||||
cancelHistoryPrepend(): void { this.historyPrepend = null }
|
||||
|
||||
/**
|
||||
* Correct a committed prepend once, then release positioning ownership.
|
||||
* @returns the compensated landing for reading memory, or null when no anchor remains.
|
||||
*/
|
||||
finishHistoryPrepend(): ViewportLanding | null {
|
||||
const pending = this.historyPrepend
|
||||
this.historyPrepend = null
|
||||
const paging = this.resolvePaging(pending)
|
||||
return paging === null ? null : this.applyPaging(paging)
|
||||
}
|
||||
|
||||
private readonly onHistoryScroll = (event: Event): void => {
|
||||
if (event.target === this.elements?.scroller || event.target instanceof HTMLElement
|
||||
&& event.target.hasAttribute('data-step-process-body')) {
|
||||
this.historyScrolling.add(event.target)
|
||||
}
|
||||
}
|
||||
|
||||
private readonly onHistoryPointerDown = (): void => {
|
||||
this.historyPointer = true
|
||||
this.events?.history?.()
|
||||
}
|
||||
private readonly onHistoryTouch = (): void => {
|
||||
this.historyTouch = true
|
||||
this.events?.history?.()
|
||||
}
|
||||
private readonly onHistoryRelease = (event: Event): void => {
|
||||
if (event.type === 'pointerup' || event.type === 'pointercancel') this.historyPointer = false
|
||||
if (event.type === 'touchend' || event.type === 'touchcancel') {
|
||||
this.historyTouch = typeof TouchEvent !== 'undefined' && event instanceof TouchEvent && event.touches.length > 0
|
||||
}
|
||||
this.events?.history?.()
|
||||
}
|
||||
|
||||
private readonly onScroll = (event: Event): void => {
|
||||
if (this.elements === null || event.target !== this.elements.scroller) return
|
||||
if (this.observation.landing !== null && this.elements.scroller.scrollTop === this.observation.top) return
|
||||
@@ -932,6 +1089,8 @@ export class ChatViewport {
|
||||
}
|
||||
|
||||
private readonly onScrollEnd = (event: Event): void => {
|
||||
if (event.target !== null) this.historyScrolling.delete(event.target)
|
||||
this.events?.history?.()
|
||||
if (event.target === this.elements?.scroller) {
|
||||
this.events?.scrollEnd(true)
|
||||
this.reclaimBelow()
|
||||
|
||||
@@ -347,7 +347,10 @@ export interface ChatViewInjected {
|
||||
/** Open one HTTP(S) message link at the selected destination, using an external tab if Sidebar Browser is unavailable. */
|
||||
openExternalLink: (url: string) => void
|
||||
openFile: (path: string, options?: OpenFileOptions) => Promise<void>
|
||||
/** Prepare one older page without publishing it. */
|
||||
loadOlder: () => void
|
||||
/** Consume the prepared page synchronously; false when none is ready. */
|
||||
consumeOlder: () => boolean
|
||||
/** Jump loader: page history back through seq; resolves when the window covers it. */
|
||||
loadThrough: (seq: SessionSeq) => Promise<void>
|
||||
loadImage: MessageImageLoader
|
||||
|
||||
@@ -92,6 +92,10 @@ export const zh = {
|
||||
'chat.loadingHistory': '载入历史…',
|
||||
'chat.loadError': '历史加载失败:{message}({code})',
|
||||
'chat.loadOlder': '加载更早',
|
||||
'chat.loadingOlder': '正在加载',
|
||||
'chat.processingOlder': '正在处理',
|
||||
'chat.retryOlder': '重新加载',
|
||||
'chat.historyStart': '已到会话开头',
|
||||
'chat.toBottom': '回到底部',
|
||||
'chat.deepDiving': '深度求索中',
|
||||
'chat.deepDivingFor': '深度求索中,用时 {duration} ···',
|
||||
@@ -302,6 +306,10 @@ export const en = {
|
||||
'chat.loadingHistory': 'Loading history…',
|
||||
'chat.loadError': 'Failed to load history: {message} ({code})',
|
||||
'chat.loadOlder': 'Load earlier',
|
||||
'chat.loadingOlder': 'Loading earlier',
|
||||
'chat.processingOlder': 'Processing',
|
||||
'chat.retryOlder': 'Retry loading',
|
||||
'chat.historyStart': 'Start of conversation',
|
||||
'chat.toBottom': 'Back to bottom',
|
||||
'chat.deepDiving': 'Deep diving',
|
||||
'chat.deepDivingFor': 'Deep diving for {duration} ···',
|
||||
@@ -509,6 +517,10 @@ export const ja = {
|
||||
'chat.loadingHistory': '履歴を読み込み中…',
|
||||
'chat.loadError': '履歴の読み込みに失敗しました: {message}({code})',
|
||||
'chat.loadOlder': '以前を読み込む',
|
||||
'chat.loadingOlder': '読み込み中',
|
||||
'chat.processingOlder': '処理中',
|
||||
'chat.retryOlder': '再読み込み',
|
||||
'chat.historyStart': '会話の先頭です',
|
||||
'chat.toBottom': '最下部へ戻る',
|
||||
'chat.deepDiving': '深掘り中',
|
||||
'chat.deepDivingFor': '{duration} の間深く分析中 ···',
|
||||
@@ -716,6 +728,10 @@ export const es = {
|
||||
'chat.loadingHistory': 'Cargando historial…',
|
||||
'chat.loadError': 'No se pudo cargar el historial: {message} ({code})',
|
||||
'chat.loadOlder': 'Cargar antes',
|
||||
'chat.loadingOlder': 'Cargando anteriores',
|
||||
'chat.processingOlder': 'Procesando',
|
||||
'chat.retryOlder': 'Reintentar carga',
|
||||
'chat.historyStart': 'Inicio de la conversación',
|
||||
'chat.toBottom': 'Volver al fondo',
|
||||
'chat.deepDiving': 'Razonamiento profundo',
|
||||
'chat.deepDivingFor': 'Razonando a fondo durante {duration} ···',
|
||||
@@ -923,6 +939,10 @@ export const fr = {
|
||||
'chat.loadingHistory': "Chargement de l'historique…",
|
||||
'chat.loadError': "Échec du chargement de l'historique : {message} ({code})",
|
||||
'chat.loadOlder': 'Charger plus tôt',
|
||||
'chat.loadingOlder': 'Chargement en cours',
|
||||
'chat.processingOlder': 'Traitement en cours',
|
||||
'chat.retryOlder': 'Réessayer',
|
||||
'chat.historyStart': 'Début de la conversation',
|
||||
'chat.toBottom': 'Retour en bas',
|
||||
'chat.deepDiving': 'Réflexion approfondie',
|
||||
'chat.deepDivingFor': 'Réflexion approfondie depuis {duration} ···',
|
||||
@@ -1130,6 +1150,10 @@ export const de = {
|
||||
'chat.loadingHistory': 'Verlauf wird geladen…',
|
||||
'chat.loadError': 'Fehler beim Laden des Verlaufs: {message} ({code})',
|
||||
'chat.loadOlder': 'Früher laden',
|
||||
'chat.loadingOlder': 'Wird geladen',
|
||||
'chat.processingOlder': 'Wird verarbeitet',
|
||||
'chat.retryOlder': 'Erneut laden',
|
||||
'chat.historyStart': 'Anfang der Unterhaltung',
|
||||
'chat.toBottom': 'Zurück nach unten',
|
||||
'chat.deepDiving': 'Gründliches Nachdenken',
|
||||
'chat.deepDivingFor': 'Gründliches Nachdenken seit {duration} ···',
|
||||
@@ -1337,6 +1361,10 @@ export const ru = {
|
||||
'chat.loadingHistory': 'Загрузка истории…',
|
||||
'chat.loadError': 'Не удалось загрузить историю: {message} ({code})',
|
||||
'chat.loadOlder': 'Загрузить раньше',
|
||||
'chat.loadingOlder': 'Загрузка истории',
|
||||
'chat.processingOlder': 'Обработка',
|
||||
'chat.retryOlder': 'Повторить загрузку',
|
||||
'chat.historyStart': 'Начало разговора',
|
||||
'chat.toBottom': 'Вернуться к низу',
|
||||
'chat.deepDiving': 'Глубокое размышление',
|
||||
'chat.deepDivingFor': 'Глубокое размышление в течение {duration} ···',
|
||||
@@ -1544,6 +1572,10 @@ export const pt = {
|
||||
'chat.loadingHistory': 'Carregando histórico…',
|
||||
'chat.loadError': 'Falha ao carregar o histórico: {message} ({code})',
|
||||
'chat.loadOlder': 'Carregar mais cedo',
|
||||
'chat.loadingOlder': 'Carregando anteriores',
|
||||
'chat.processingOlder': 'Processando',
|
||||
'chat.retryOlder': 'Tentar novamente',
|
||||
'chat.historyStart': 'Início da conversa',
|
||||
'chat.toBottom': 'De volta ao fundo',
|
||||
'chat.deepDiving': 'Raciocínio profundo',
|
||||
'chat.deepDivingFor': 'Raciocínio profundo há {duration} ···',
|
||||
|
||||
@@ -253,7 +253,7 @@ describe('Chat inject API', () => {
|
||||
const b = await bench()
|
||||
const { injected } = b.chatViewApi(b.rootReference)
|
||||
injected.loadOlder()
|
||||
expect(b.session.loadOlder).toHaveBeenCalledOnce()
|
||||
expect(b.session.loadOlder).toHaveBeenCalledExactlyOnceWith({ deferConsumption: true })
|
||||
|
||||
void injected.loadThrough(SessionSeq(42))
|
||||
expect(b.session.loadThrough).toHaveBeenCalledWith(42)
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { SessionSeq } from '@deepseek-ai/dsh-session/types'
|
||||
import { ChatNavigation, type ChatNavigationInput } from '../src/client/chat/use-chat-navigation.ts'
|
||||
import { ChatReading } from '../src/client/chat/use-chat-reading.ts'
|
||||
import { ChatViewport } from '../src/client/chat/use-chat-viewport.ts'
|
||||
import { ScrollFollow } from '../src/client/chat/use-scroll-follow.ts'
|
||||
|
||||
function fixture() {
|
||||
const viewport = new ChatViewport()
|
||||
vi.spyOn(viewport, 'confirmHistoryStart').mockReturnValue(true)
|
||||
const mode = vi.spyOn(viewport, 'historyConsumption').mockReturnValue('native')
|
||||
const reading = new ChatReading(viewport, { read: () => null, save: () => {} }, {
|
||||
initialized: true, followingTail: false, activeTurn: 1,
|
||||
}, () => {}, new ScrollFollow(false, 24))
|
||||
const loadOlder = vi.fn()
|
||||
const consumeOlder = vi.fn(() => true)
|
||||
const onIntent = vi.fn()
|
||||
let input: ChatNavigationInput = {
|
||||
isOpen: true, firstSeq: SessionSeq(20), hasMore: true, loadingOlder: false,
|
||||
historyLoadStatus: 'idle', loadOlder, consumeOlder, loadThrough: async () => {},
|
||||
}
|
||||
const navigation = new ChatNavigation(viewport, reading, input, () => {}, onIntent)
|
||||
const check = () => {
|
||||
navigation.requestHistoryCheck()
|
||||
vi.advanceTimersByTime(20)
|
||||
}
|
||||
const ready = () => {
|
||||
input = { ...input, historyLoadStatus: 'pendingConsumption' }
|
||||
navigation.setInput(input)
|
||||
}
|
||||
return { viewport, navigation, reading, mode, loadOlder, consumeOlder, onIntent, check, ready }
|
||||
}
|
||||
|
||||
beforeEach(() => { vi.useFakeTimers() })
|
||||
afterEach(() => { vi.restoreAllMocks(); vi.useRealTimers() })
|
||||
|
||||
describe('history consumption intent', () => {
|
||||
it.each(['motion', 'pointer', 'unavailable'] as const)('leaves an automatic page for a click after %s', (blocker) => {
|
||||
const h = fixture()
|
||||
h.check()
|
||||
expect(h.loadOlder).toHaveBeenCalledOnce()
|
||||
h.ready()
|
||||
h.mode.mockReturnValue(blocker)
|
||||
h.check()
|
||||
expect(h.onIntent).toHaveBeenLastCalledWith('none')
|
||||
h.mode.mockReturnValue('native')
|
||||
h.ready()
|
||||
h.check()
|
||||
h.check()
|
||||
expect(h.consumeOlder).not.toHaveBeenCalled()
|
||||
expect(h.loadOlder).toHaveBeenCalledOnce()
|
||||
h.navigation.loadEarlier()
|
||||
h.check()
|
||||
expect(h.consumeOlder).toHaveBeenCalledOnce()
|
||||
h.navigation.dispose()
|
||||
})
|
||||
|
||||
it.each(['touch', 'inner-scroll'] as const)('continues automatic consumption after %s ends', (blocker) => {
|
||||
const h = fixture()
|
||||
h.check()
|
||||
h.ready()
|
||||
h.mode.mockReturnValue(blocker)
|
||||
h.check()
|
||||
expect(h.onIntent).toHaveBeenLastCalledWith('auto')
|
||||
expect(h.consumeOlder).not.toHaveBeenCalled()
|
||||
h.mode.mockReturnValue('native')
|
||||
h.check()
|
||||
expect(h.consumeOlder).toHaveBeenCalledOnce()
|
||||
expect(h.loadOlder).toHaveBeenCalledOnce()
|
||||
h.navigation.dispose()
|
||||
})
|
||||
|
||||
it.each(['motion', 'touch', 'pointer', 'inner-scroll', 'unavailable'] as const)(
|
||||
'uses baseline manual paging for a cached page without consulting the automatic %s gate', (blocker) => {
|
||||
const h = fixture()
|
||||
h.ready()
|
||||
h.mode.mockReturnValue(blocker)
|
||||
const begin = vi.spyOn(h.viewport, 'beginManualPaging')
|
||||
h.navigation.loadEarlier()
|
||||
h.check()
|
||||
expect(h.mode).not.toHaveBeenCalled()
|
||||
expect(begin).toHaveBeenCalledOnce()
|
||||
expect(h.loadOlder).not.toHaveBeenCalled()
|
||||
expect(h.consumeOlder).toHaveBeenCalledOnce()
|
||||
expect(h.onIntent).toHaveBeenLastCalledWith('none')
|
||||
h.navigation.dispose()
|
||||
},
|
||||
)
|
||||
|
||||
it('joins a pending automatic request when clicked and consumes once after it returns', () => {
|
||||
const h = fixture()
|
||||
h.check()
|
||||
h.navigation.loadEarlier()
|
||||
h.check()
|
||||
expect(h.loadOlder).toHaveBeenCalledOnce()
|
||||
h.ready()
|
||||
h.check()
|
||||
expect(h.consumeOlder).toHaveBeenCalledOnce()
|
||||
expect(h.onIntent).toHaveBeenLastCalledWith('none')
|
||||
h.navigation.dispose()
|
||||
})
|
||||
|
||||
it.each(['touch', 'inner-scroll'] as const)('keeps %s ahead of an active motion blocker', (input) => {
|
||||
const viewport = new ChatViewport()
|
||||
const root = document.createElement('div')
|
||||
const inner = document.createElement('div')
|
||||
inner.dataset.stepProcessBody = ''
|
||||
root.append(inner)
|
||||
document.body.append(root)
|
||||
viewport.attach(root, root)
|
||||
vi.spyOn(viewport.motion, 'active').mockReturnValue(true)
|
||||
if (input === 'touch') root.dispatchEvent(new Event('touchstart', { bubbles: true }))
|
||||
else inner.dispatchEvent(new Event('scroll'))
|
||||
expect(viewport.historyConsumption()).toBe(input)
|
||||
viewport.detach()
|
||||
root.remove()
|
||||
})
|
||||
|
||||
it('does not consume a cached page on a fresh view until clicked', () => {
|
||||
const h = fixture()
|
||||
h.ready()
|
||||
h.check()
|
||||
expect(h.consumeOlder).not.toHaveBeenCalled()
|
||||
h.navigation.loadEarlier()
|
||||
h.check()
|
||||
expect(h.consumeOlder).toHaveBeenCalledOnce()
|
||||
h.navigation.dispose()
|
||||
})
|
||||
})
|
||||
@@ -36,7 +36,7 @@ function ScrollHarness({ input, onMotion }: {
|
||||
|
||||
function mountScroll(deferCompletedTurns = true, initial: Partial<ChatScrollInput> = {}) {
|
||||
let input: ChatScrollInput = {
|
||||
ready: true,
|
||||
isOpen: true,
|
||||
order: [],
|
||||
firstSeq: null,
|
||||
lastKey: null,
|
||||
@@ -49,7 +49,7 @@ function mountScroll(deferCompletedTurns = true, initial: Partial<ChatScrollInpu
|
||||
transcriptView: 'verbose',
|
||||
loadedTurns: [],
|
||||
hasMore: false,
|
||||
loadingOlder: false,
|
||||
loadingOlder: false, historyLoadStatus: 'idle', consumeOlder: vi.fn(() => false),
|
||||
loadOlder: vi.fn(),
|
||||
loadThrough: vi.fn(async () => {}),
|
||||
chatScroll: { read: () => null, save: vi.fn() },
|
||||
@@ -116,9 +116,9 @@ function mountRows(heights: readonly number[]) {
|
||||
}
|
||||
vi.stubGlobal('ResizeObserver', Observer)
|
||||
const input: ChatScrollInput = {
|
||||
ready: true, order: [], firstSeq: null, lastKey: null, lastIsUser: false,
|
||||
isOpen: true, order: [], firstSeq: null, lastKey: null, lastIsUser: false,
|
||||
steeringId: null, submissionId: null, submissionSteers: false, running: false, deferCompletedTurns: false,
|
||||
transcriptView: 'standard', loadedTurns: [], hasMore: false, loadingOlder: false,
|
||||
transcriptView: 'standard', loadedTurns: [], hasMore: false, loadingOlder: false, historyLoadStatus: 'idle', consumeOlder: vi.fn(() => false),
|
||||
loadOlder: vi.fn(), loadThrough: vi.fn(async () => {}), chatScroll: { read: () => null, save: vi.fn() },
|
||||
}
|
||||
const view = render(<ScrollHarness input={input} onMotion={(motion) => { callbacks.motion = motion }}
|
||||
@@ -287,9 +287,9 @@ function mountVisibleFold(test: typeof visibleFoldCases[number], reduced: boolea
|
||||
let motion: FlowMotionRows | undefined
|
||||
const onMotion = (value: FlowMotionRows): void => { motion = value }
|
||||
let input: ChatScrollInput = {
|
||||
ready: false, order: [], firstSeq: null, lastKey: null, lastIsUser: false,
|
||||
isOpen: false, order: [], firstSeq: null, lastKey: null, lastIsUser: false,
|
||||
steeringId: null, submissionId: null, submissionSteers: false, running: false, deferCompletedTurns: true,
|
||||
transcriptView: 'detailed', loadedTurns: [], hasMore: false, loadingOlder: false,
|
||||
transcriptView: 'detailed', loadedTurns: [], hasMore: false, loadingOlder: false, historyLoadStatus: 'idle', consumeOlder: vi.fn(() => false),
|
||||
loadOlder: vi.fn(), loadThrough: vi.fn(async () => {}), chatScroll: { read: () => null, save: vi.fn() },
|
||||
}
|
||||
vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: reduced })))
|
||||
@@ -328,7 +328,7 @@ function mountVisibleFold(test: typeof visibleFoldCases[number], reduced: boolea
|
||||
offsetHeight: { get: contentHeight },
|
||||
getBoundingClientRect: { value: () => ({ top: -top, bottom: contentHeight() - top, height: contentHeight() }) },
|
||||
})
|
||||
input = { ...input, ready: true }
|
||||
input = { ...input, isOpen: true }
|
||||
view.rerender(<ScrollHarness input={input} onMotion={onMotion} />)
|
||||
top = test.top
|
||||
scrollTo.mockClear()
|
||||
@@ -495,7 +495,7 @@ it('keeps one active fold when the local echo becomes its durable input', async
|
||||
describe('Chat scroll collapse timing', () => {
|
||||
it.each(['resize', 'scroll', 'history', 'input', 'preference'] as const)(
|
||||
'waits for initial history before initializing a running view after %s', (change) => {
|
||||
const h = mountScroll(true, { ready: false, running: true })
|
||||
const h = mountScroll(true, { isOpen: false, running: true })
|
||||
expect(h.scroller.dataset.initialized).toBe('false')
|
||||
if (change === 'resize') h.resize()
|
||||
else if (change === 'scroll') fireEvent.scroll(h.scroller)
|
||||
@@ -506,7 +506,7 @@ describe('Chat scroll collapse timing', () => {
|
||||
expect(h.scroller.scrollTop).toBe(0)
|
||||
expect(h.scrollTo).not.toHaveBeenCalled()
|
||||
|
||||
h.update({ ready: true })
|
||||
h.update({ isOpen: true })
|
||||
expect(h.scroller.dataset.initialized).toBe('true')
|
||||
expect(h.scroller.scrollTop).toBe(600)
|
||||
expect(h.scrollTo).not.toHaveBeenCalled()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// @vitest-environment jsdom
|
||||
import { contextMenuHook } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
|
||||
import { ChatViewport } from '../src/client/chat/use-chat-viewport.ts'
|
||||
import type { ProcessGroupData } from '../src/client/contract/process-groups.ts'
|
||||
import type { InboxState } from '@deepseek-ai/dsh-agent/types'
|
||||
import { MessageId } from '@deepseek-ai/dsh-llm/brand'
|
||||
@@ -107,6 +108,8 @@ function sessionSnapshot(overrides: Partial<TestSessionSnapshot> = {}): TestSess
|
||||
openError: null,
|
||||
hasMore: false,
|
||||
loadingOlder: false,
|
||||
historyLoadStatus: 'idle',
|
||||
historyWasPartial: overrides.hasMore ?? false,
|
||||
promptError: null,
|
||||
blank: false,
|
||||
subagent: null,
|
||||
@@ -421,7 +424,22 @@ function makeHarness(
|
||||
const openFile = vi.fn<(path: string) => Promise<void>>().mockResolvedValue(undefined)
|
||||
const openUserFile = vi.fn<(path: string) => void>()
|
||||
const renderUserContent = createUserContentFixture({ openFile: openUserFile })
|
||||
const loadOlder = vi.fn()
|
||||
let preparedOlder: (() => void) | null = null
|
||||
const loadOlder = vi.fn(() => {
|
||||
session.set({ loadingOlder: true, historyLoadStatus: 'loading' })
|
||||
})
|
||||
const consumeOlder = vi.fn(() => {
|
||||
if (preparedOlder === null) return false
|
||||
const consume = preparedOlder
|
||||
preparedOlder = null
|
||||
consume()
|
||||
session.set({ loadingOlder: false, historyLoadStatus: 'idle' })
|
||||
return true
|
||||
})
|
||||
const prepareOlder = (consume: () => void) => {
|
||||
preparedOlder = consume
|
||||
session.set({ loadingOlder: false, historyLoadStatus: 'pendingConsumption' })
|
||||
}
|
||||
const loadThrough = vi.fn<(seq: number) => Promise<void>>().mockResolvedValue(undefined)
|
||||
// Mutable outline holder: tests swap the value and drive a re-render via set().
|
||||
let outlineValue: unknown
|
||||
@@ -555,6 +573,7 @@ function makeHarness(
|
||||
openFile,
|
||||
openExternalLink: vi.fn(),
|
||||
loadOlder,
|
||||
consumeOlder,
|
||||
loadThrough,
|
||||
loadImage: vi.fn(() => Promise.reject(new Error('not used'))),
|
||||
chatScroll,
|
||||
@@ -584,7 +603,7 @@ function makeHarness(
|
||||
return {
|
||||
set, setSession: session.set, setChat: chatSource.set, ChatView, props,
|
||||
setConnection: (state: ConnectionState) => { connection.set(state) },
|
||||
openFile, openUserFile, loadOlder, loadThrough, openView,
|
||||
openFile, openUserFile, loadOlder, consumeOlder, prepareOlder, loadThrough, openView,
|
||||
setOutline: (value: unknown) => { outlineValue = value },
|
||||
chatScroll, forkAt, toolOwners, fenceCalls,
|
||||
setPerformanceUsage: (mode: 'compact' | 'detailed') => { performanceUsage.set(mode) },
|
||||
@@ -643,6 +662,11 @@ function readerScroll(element: HTMLElement, top: number): void {
|
||||
fireEvent(element, new Event('scrollend'))
|
||||
}
|
||||
|
||||
/** Let the public history scheduler run one frame, including React's commit of its updates. */
|
||||
async function historyFrame(): Promise<void> {
|
||||
await act(async () => { await new Promise<void>(resolve => requestAnimationFrame(() => { resolve() })) })
|
||||
}
|
||||
|
||||
function turnProcessControl(container: HTMLElement): HTMLButtonElement | null {
|
||||
return container.querySelector<HTMLButtonElement>('[data-turn-process]')
|
||||
}
|
||||
@@ -1537,6 +1561,19 @@ describe('ChatView', () => {
|
||||
await act(async () => { releaseJump?.() })
|
||||
})
|
||||
|
||||
it('keeps the history start marker across remounts from the Session fact', () => {
|
||||
const h = makeHarness({ nodes: [user(1, 'prompt'), assistant(2, 'answer')] }, {
|
||||
hasMore: false, historyWasPartial: true,
|
||||
})
|
||||
const first = render(<h.ChatView {...h.props} />)
|
||||
expect(first.getByRole('button', { name: '已到会话开头' }).hasAttribute('disabled')).toBe(true)
|
||||
first.unmount()
|
||||
const second = render(<h.ChatView {...h.props} />)
|
||||
expect(second.getByRole('button', { name: '已到会话开头' }).hasAttribute('disabled')).toBe(true)
|
||||
act(() => { h.setSession({ historyWasPartial: false }) })
|
||||
expect(second.queryByRole('button', { name: '已到会话开头' })).toBeNull()
|
||||
})
|
||||
|
||||
it('holds a jump issued while a plain pull owns the pager and resumes it when the pull settles', async () => {
|
||||
const later = [userInTurn(8, 'third prompt', 3), assistant(9, 'third response', 3)]
|
||||
const h = makeHarness({ nodes: later }, { hasMore: true, loadingOlder: true })
|
||||
@@ -1642,7 +1679,7 @@ describe('ChatView', () => {
|
||||
expect(h.toolOwners[0]).toMatchObject({ callId: 'w1', toolName: '' })
|
||||
})
|
||||
|
||||
it('prepend keeps the reader\'s latest pending-request scroll position anchored', () => {
|
||||
it('prepend keeps the reader\'s latest pending-request scroll position anchored', async () => {
|
||||
const h = makeHarness(
|
||||
{ nodes: [user(9, 'first visible'), user(10, 'next visible')] },
|
||||
{ hasMore: true },
|
||||
@@ -1666,17 +1703,19 @@ describe('ChatView', () => {
|
||||
Object.defineProperty(scroller, 'clientHeight', { value: 200, writable: true })
|
||||
readerScroll(scroller, 50)
|
||||
fireEvent.click(view.getByText('加载更早'))
|
||||
act(() => { h.set({ loadingOlder: true }) })
|
||||
await historyFrame()
|
||||
// The reader moves after the request starts; this, not the click-time
|
||||
// row, is the intent the arriving page must preserve.
|
||||
firstTop = -200
|
||||
nextTop = 60
|
||||
readerScroll(scroller, 90)
|
||||
Object.defineProperty(scroller, 'scrollHeight', { value: 1300, writable: true })
|
||||
nextTop = 560
|
||||
act(() => {
|
||||
h.set({ nodes: [assistant(2, 'older'), user(9, 'first visible'), user(10, 'next visible')], loadingOlder: false })
|
||||
})
|
||||
act(() => { h.prepareOlder(() => {
|
||||
Object.defineProperty(scroller, 'scrollHeight', { value: 1300, writable: true })
|
||||
nextTop = 560
|
||||
h.set({ nodes: [assistant(2, 'older'), user(9, 'first visible'), user(10, 'next visible')] })
|
||||
}) })
|
||||
await historyFrame()
|
||||
expect(h.consumeOlder).toHaveBeenCalledOnce()
|
||||
expect(scroller.scrollTop).toBe(590) // latest 90 + the anchored row's 500px prepend shift
|
||||
})
|
||||
|
||||
@@ -1685,7 +1724,7 @@ describe('ChatView', () => {
|
||||
{ mode: 'detailed', anchor: 'group' },
|
||||
{ mode: 'compact', anchor: 'node' }, { mode: 'standard', anchor: 'node' }, { mode: 'detailed', anchor: 'node' },
|
||||
] as const)(
|
||||
'keeps the first visible item in place when paging inserts a steering boundary ($mode, $anchor)', ({ mode, anchor }) => {
|
||||
'keeps the first visible item in place when paging inserts a steering boundary ($mode, $anchor)', async ({ mode, anchor }) => {
|
||||
const tool = (seq: number, id: string) => ({ ...toolResult(seq, id), turn: 1 })
|
||||
const initial = { nodes: [tool(4, 'retained'), assistant(6, 'answer', 1, 2)], turnEnds: new Map([[1, 7]]) }
|
||||
let source = chatSnapshotFixture(initial)
|
||||
@@ -1726,14 +1765,17 @@ describe('ChatView', () => {
|
||||
expect(pinned.hasAttribute('data-chat-paging-anchor')).toBe(true)
|
||||
expect(controlSeat.hasAttribute('data-chat-paging-anchor')).toBe(false)
|
||||
expect(group.hasAttribute('data-chat-paging-anchor')).toBe(anchor !== 'node')
|
||||
act(() => {
|
||||
await historyFrame()
|
||||
act(() => { h.prepareOlder(() => {
|
||||
paged = true
|
||||
source = chatSnapshotFixture({ ...initial, nodes: [
|
||||
tool(1, 'earlier'), steering(2, 'historical direction', 1), tool(3, 'before-retained'), ...initial.nodes,
|
||||
] }, source)
|
||||
h.set({ chat: installGroupedSnapshot(builder, state, groups, source) })
|
||||
h.setGrouped(groups)
|
||||
})
|
||||
}) })
|
||||
await historyFrame()
|
||||
expect(h.consumeOlder).toHaveBeenCalledOnce()
|
||||
|
||||
expect(pinned.getBoundingClientRect().top).toBe(expectedTop)
|
||||
expect(scroller.scrollTop).toBe(expectedScroll)
|
||||
@@ -1757,7 +1799,7 @@ describe('ChatView', () => {
|
||||
{ mode: 'compact', hasProcess: false, hasSteering: true },
|
||||
{ mode: 'standard', hasProcess: false, hasSteering: true },
|
||||
{ mode: 'detailed', hasProcess: false, hasSteering: true },
|
||||
] as const)('keeps the loaded message anchored when paging exposes steering inside a closed Turn ($mode, process=$hasProcess, steer=$hasSteering)', ({ mode, hasProcess, hasSteering }) => {
|
||||
] as const)('keeps the loaded message anchored when paging exposes steering inside a closed Turn ($mode, process=$hasProcess, steer=$hasSteering)', async ({ mode, hasProcess, hasSteering }) => {
|
||||
const tool = (seq: number, id: string) => ({ ...toolResult(seq, id), turn: 1 })
|
||||
const initial = {
|
||||
nodes: [
|
||||
@@ -1801,13 +1843,16 @@ describe('ChatView', () => {
|
||||
expect(control.hasAttribute('data-chat-paging-anchor')).toBe(false)
|
||||
fireEvent.click(view.getByText('加载更早'))
|
||||
|
||||
act(() => {
|
||||
await historyFrame()
|
||||
act(() => { h.prepareOlder(() => {
|
||||
source = chatSnapshotFixture({ ...initial, nodes: [
|
||||
tool(1, 'earlier-work'), steering(2, 'earlier direction', 1), tool(3, 'following-work'), ...initial.nodes,
|
||||
] }, source)
|
||||
h.set({ chat: installGroupedSnapshot(builder, state, groups, source) })
|
||||
h.setGrouped(groups)
|
||||
})
|
||||
}) })
|
||||
await historyFrame()
|
||||
expect(h.consumeOlder).toHaveBeenCalledOnce()
|
||||
|
||||
const steer = view.container.querySelector<HTMLElement>('[data-chat-node-key="fixture:steering:2"]')!
|
||||
const rows = visibleRows()
|
||||
@@ -1854,7 +1899,7 @@ describe('ChatView', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('anchors the first transcript row for paging regardless of the viewport reading line', () => {
|
||||
it('anchors the first transcript row for a prepared page consumed at the top without reading hit tests', async () => {
|
||||
const originalHitTest = Object.getOwnPropertyDescriptor(document, 'elementsFromPoint')
|
||||
const nodes = Array.from({ length: 16 }, (_, index) => user(20 + index, `row ${String(index)}`))
|
||||
const h = makeHarness(
|
||||
@@ -1873,13 +1918,13 @@ describe('ChatView', () => {
|
||||
vi.spyOn(row, 'getBoundingClientRect').mockImplementation(() => {
|
||||
rowRectCalls += 1
|
||||
const shift = prepended ? (index === 8 ? 400 : 500) : 0
|
||||
const top = 20 + (index - 8) * 60 + shift
|
||||
const top = 20 + index * 60 + shift - scroller.scrollTop
|
||||
return { top, bottom: top + 40 } as DOMRect
|
||||
})
|
||||
})
|
||||
Object.defineProperty(scroller, 'scrollHeight', { value: 800, writable: true })
|
||||
Object.defineProperty(scroller, 'clientHeight', { value: 200, writable: true })
|
||||
readerScroll(scroller, 50)
|
||||
readerScroll(scroller, 0)
|
||||
|
||||
const hitTest = vi.fn((_x: number, _y: number): Element[] => [])
|
||||
Object.defineProperty(document, 'elementsFromPoint', {
|
||||
@@ -1890,14 +1935,19 @@ describe('ChatView', () => {
|
||||
rowRectCalls = 0
|
||||
fireEvent.click(view.getByText('加载更早'))
|
||||
expect(hitTest).not.toHaveBeenCalled()
|
||||
expect(rowRectCalls).toBe(1)
|
||||
|
||||
Object.defineProperty(scroller, 'scrollHeight', { value: 1_300, writable: true })
|
||||
prepended = true
|
||||
act(() => {
|
||||
expect(rowRectCalls).toBe(0)
|
||||
await historyFrame()
|
||||
act(() => { h.prepareOlder(() => {
|
||||
expect(hitTest).not.toHaveBeenCalled()
|
||||
expect(rowRectCalls).toBe(1)
|
||||
Object.defineProperty(scroller, 'scrollHeight', { value: 1_300, writable: true })
|
||||
prepended = true
|
||||
h.setChat({ nodes: [assistant(2, 'older'), ...nodes] })
|
||||
})
|
||||
expect(scroller.scrollTop).toBe(550)
|
||||
}) })
|
||||
await historyFrame()
|
||||
expect(h.consumeOlder).toHaveBeenCalledOnce()
|
||||
expect(scroller.scrollTop).toBe(500)
|
||||
expect(rows[0]!.getBoundingClientRect().top).toBe(20)
|
||||
expect(h.chatScroll.read()?.anchorKey).toBe('fixture:user:20')
|
||||
} finally {
|
||||
if (originalHitTest !== undefined) {
|
||||
@@ -4060,7 +4110,7 @@ describe('ChatView', () => {
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
})
|
||||
|
||||
it('prepend preserves a semantic row; a trailing user node force-scrolls', () => {
|
||||
it('prepend preserves a semantic row; a trailing user node force-scrolls', async () => {
|
||||
const h = makeHarness(
|
||||
{ nodes: [user(5, 'later'), assistant(6, 'a')] },
|
||||
{ hasMore: true },
|
||||
@@ -4076,13 +4126,15 @@ describe('ChatView', () => {
|
||||
() => ({ top: anchoredTop, bottom: anchoredTop + 40 } as DOMRect),
|
||||
)
|
||||
readerScroll(scroller, 80)
|
||||
// Arm the paging anchor, then deliver an older page (head seq decreases).
|
||||
fireEvent.click(view.getByText('加载更早'))
|
||||
Object.defineProperty(scroller, 'scrollHeight', { value: 1600, writable: true })
|
||||
anchoredTop = 700
|
||||
act(() => {
|
||||
await historyFrame()
|
||||
act(() => { h.prepareOlder(() => {
|
||||
Object.defineProperty(scroller, 'scrollHeight', { value: 1600, writable: true })
|
||||
anchoredTop = 700
|
||||
h.setChat({ nodes: [user(1, 'old'), assistant(2, 'b'), user(5, 'later'), assistant(6, 'a')] })
|
||||
})
|
||||
}) })
|
||||
await historyFrame()
|
||||
expect(h.consumeOlder).toHaveBeenCalledOnce()
|
||||
expect(scroller.scrollTop).toBe(680) // reader offset 80 + the anchored row's 600px shift
|
||||
// A new trailing user bubble (own words) force-scrolls to the bottom.
|
||||
act(() => {
|
||||
@@ -4093,7 +4145,75 @@ describe('ChatView', () => {
|
||||
expect(scroller.scrollTop).toBe(1200)
|
||||
})
|
||||
|
||||
it('back-to-bottom cancels an in-flight paging anchor', () => {
|
||||
it('restores a one-shot prepend landing after immediate remount before scroll delivery', async () => {
|
||||
const host = document.createElement('div')
|
||||
host.dataset.conversationScroll = ''
|
||||
document.body.append(host)
|
||||
let paged = false
|
||||
Object.defineProperties(host, {
|
||||
clientHeight: { value: 200 },
|
||||
scrollHeight: { get: () => paged ? 1200 : 200 },
|
||||
})
|
||||
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
|
||||
if (this === host) return new DOMRect(0, 0, 500, 200)
|
||||
const offset = this.dataset.chatAnchorKey === 'fixture:user:5' ? 20 + (paged ? 200 : 0) : 0
|
||||
return new DOMRect(0, offset - host.scrollTop, 500, 40)
|
||||
})
|
||||
try {
|
||||
const h = makeHarness({ nodes: [user(5, 'retained prompt'), assistant(6, 'answer')] }, { hasMore: true })
|
||||
const view = render(<h.ChatView {...h.props} />, { container: host })
|
||||
expect(host.scrollTop).toBe(0)
|
||||
expect(h.chatScroll.read()).toBeNull()
|
||||
fireEvent.click(view.getByRole('button', { name: '加载更早' }))
|
||||
await historyFrame()
|
||||
act(() => { h.prepareOlder(() => {
|
||||
paged = true
|
||||
h.setChat({ nodes: [user(1, 'earlier'), assistant(2, 'old answer'), user(5, 'retained prompt'), assistant(6, 'answer')] })
|
||||
}) })
|
||||
await historyFrame()
|
||||
expect(host.scrollTop).toBe(200)
|
||||
// A navigation can remove the view before the browser delivers the scroll caused by compensation.
|
||||
view.rerender(<div />)
|
||||
host.scrollTop = 0
|
||||
view.rerender(<h.ChatView {...h.props} />)
|
||||
expect(host.scrollTop).toBe(200)
|
||||
expect(h.chatScroll.read()).toMatchObject({ anchorKey: 'fixture:user:5', anchorTop: 20, scrollTop: 200 })
|
||||
} finally {
|
||||
host.remove()
|
||||
}
|
||||
})
|
||||
|
||||
it('settles a top prepend with unchanged node order before a later unrelated append', async () => {
|
||||
const h = makeHarness({ nodes: [user(5, 'retained'), assistant(6, 'answer')] }, { hasMore: true })
|
||||
h.chatScroll.save({ anchorKey: 'fixture:user:5', anchorTop: 0, scrollTop: 0 })
|
||||
vi.spyOn(ChatViewport.prototype, 'confirmHistoryStart').mockReturnValue(true)
|
||||
const begin = vi.spyOn(ChatViewport.prototype, 'beginHistoryPrepend')
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const scroller = view.container.querySelector('[data-chat-flow]')!.parentElement as HTMLDivElement
|
||||
Object.defineProperties(scroller, {
|
||||
scrollHeight: { configurable: true, value: 1500 },
|
||||
clientHeight: { configurable: true, value: 200 },
|
||||
})
|
||||
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
|
||||
if (this === scroller) return new DOMRect(0, 0, 500, 200)
|
||||
const offset = this.dataset.chatAnchorKey === 'fixture:user:5' ? 0 : 400
|
||||
return new DOMRect(0, offset - scroller.scrollTop, 500, 40)
|
||||
})
|
||||
readerScroll(scroller, 300)
|
||||
await historyFrame()
|
||||
readerScroll(scroller, 0)
|
||||
act(() => { h.prepareOlder(() => { h.setSession({ hasMore: false }) }) })
|
||||
await historyFrame()
|
||||
expect(begin).toHaveBeenCalledOnce()
|
||||
expect(h.consumeOlder).toHaveBeenCalledOnce()
|
||||
readerScroll(scroller, 300)
|
||||
const write = vi.spyOn(scroller, 'scrollTop', 'set')
|
||||
act(() => { h.setChat({ nodes: [user(5, 'retained'), assistant(6, 'answer'), assistant(7, 'later reply')] }) })
|
||||
expect(scroller.scrollTop).toBe(300)
|
||||
expect(write).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('back-to-bottom cancels pending history consumption without reviving it when the page arrives', async () => {
|
||||
const h = makeHarness({ nodes: [user(9, 'late')] }, { hasMore: true })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const scroller = view.container.querySelector('[data-chat-flow]')!.parentElement as HTMLDivElement
|
||||
@@ -4101,10 +4221,16 @@ describe('ChatView', () => {
|
||||
Object.defineProperty(scroller, 'clientHeight', { value: 200, writable: true })
|
||||
readerScroll(scroller, 50)
|
||||
fireEvent.click(view.getByText('加载更早'))
|
||||
await historyFrame()
|
||||
fireEvent.click(view.getByLabelText('回到底部'))
|
||||
Object.defineProperty(scroller, 'scrollHeight', { value: 1_300, writable: true })
|
||||
act(() => { h.setChat({ nodes: [assistant(2, 'older'), user(9, 'late')] }) })
|
||||
expect(scroller.scrollTop).toBe(1_100)
|
||||
act(() => { h.prepareOlder(() => {
|
||||
Object.defineProperty(scroller, 'scrollHeight', { value: 1_300, writable: true })
|
||||
h.setChat({ nodes: [assistant(2, 'older'), user(9, 'late')] })
|
||||
}) })
|
||||
await historyFrame()
|
||||
expect(h.consumeOlder).not.toHaveBeenCalled()
|
||||
expect(scroller.scrollTop).toBe(600)
|
||||
expect(view.queryByText('older')).toBeNull()
|
||||
expect(h.chatScroll.read()).toBeNull()
|
||||
})
|
||||
|
||||
@@ -4598,13 +4724,19 @@ describe('ChatView', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('paging button loads older and shows its busy label', () => {
|
||||
it('paging button schedules an older request and shows its loading label', async () => {
|
||||
const h = makeHarness({ nodes: [user(5, 'later')] }, { hasMore: true })
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
fireEvent.click(view.getByText('加载更早'))
|
||||
expect(h.loadOlder).not.toHaveBeenCalled()
|
||||
expect(view.getByRole('button', { name: '正在处理' }).hasAttribute('disabled')).toBe(false)
|
||||
await historyFrame()
|
||||
expect(h.loadOlder).toHaveBeenCalledTimes(1)
|
||||
const loading = view.getByRole('button', { name: '正在加载' })
|
||||
expect(loading.hasAttribute('disabled')).toBe(false)
|
||||
fireEvent.click(loading)
|
||||
await historyFrame()
|
||||
expect(h.loadOlder).toHaveBeenCalledTimes(1)
|
||||
act(() => { h.setSession({ loadingOlder: true }) })
|
||||
expect(view.getByText('加载中…')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows open error and loading states', () => {
|
||||
|
||||
@@ -48,6 +48,100 @@ function fixture() {
|
||||
return { column, viewport, group, row, prepend: (height: number) => { prependedHeight += height } }
|
||||
}
|
||||
|
||||
it('updates history distance only from scrollport size observations before layout writes', () => {
|
||||
let resize: ResizeObserverCallback | undefined
|
||||
class Observer implements ResizeObserver {
|
||||
constructor(callback: ResizeObserverCallback) { resize = callback }
|
||||
observe(): void {}
|
||||
unobserve(): void {}
|
||||
disconnect(): void {}
|
||||
}
|
||||
vi.stubGlobal('ResizeObserver', Observer)
|
||||
onTestFinished(() => { vi.unstubAllGlobals() })
|
||||
const h = fixture()
|
||||
const edge = document.createElement('div')
|
||||
h.column.prepend(edge)
|
||||
const height = vi.fn(() => 300)
|
||||
Object.defineProperty(h.column, 'clientHeight', { configurable: true, get: height })
|
||||
h.viewport.setHistoryEdge(edge)
|
||||
expect(height).not.toHaveBeenCalled()
|
||||
let afterWrite = false
|
||||
vi.spyOn(h.viewport, 'reclaimBelow').mockImplementation(() => { afterWrite = true })
|
||||
height.mockImplementation(() => {
|
||||
if (afterWrite) throw new Error('height read after a layout writer')
|
||||
return 300
|
||||
})
|
||||
const entry: ResizeObserverEntry = { target: h.column, contentRect: new DOMRect(),
|
||||
borderBoxSize: [], contentBoxSize: [], devicePixelContentBoxSize: [] }
|
||||
const notify = resize
|
||||
const observer = new Observer(() => {})
|
||||
notify?.([entry], observer)
|
||||
height.mockClear()
|
||||
notify?.([], observer)
|
||||
vi.spyOn(edge, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, -600, 20, 10))
|
||||
expect(h.viewport.confirmHistoryStart()).toBe(true)
|
||||
vi.spyOn(edge, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, -700, 20, 10))
|
||||
expect(h.viewport.confirmHistoryStart()).toBe(false)
|
||||
expect(height).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses the first paging anchor for one-shot history prepending without reading-position lookup', () => {
|
||||
const h = fixture()
|
||||
const row = h.row(h.column, 'old', 0)
|
||||
const scrollerRect = vi.spyOn(h.column, 'getBoundingClientRect')
|
||||
const capture = vi.spyOn(h.viewport, 'capturePosition')
|
||||
const query = vi.spyOn(h.column, 'querySelector')
|
||||
expect(h.viewport.beginHistoryPrepend()).toBe(true)
|
||||
expect(capture).not.toHaveBeenCalled()
|
||||
expect(query).toHaveBeenCalledTimes(1)
|
||||
expect(scrollerRect).toHaveBeenCalledTimes(1)
|
||||
h.prepend(120)
|
||||
h.viewport.finishHistoryPrepend()
|
||||
expect(h.column.scrollTop).toBe(120)
|
||||
expect(row.getBoundingClientRect().top).toBe(0)
|
||||
h.prepend(30)
|
||||
h.viewport.finishHistoryPrepend()
|
||||
expect(h.column.scrollTop).toBe(120)
|
||||
})
|
||||
|
||||
it('uses a collapsed group paging anchor when no ordinary content row is visible', () => {
|
||||
const h = fixture()
|
||||
const group = h.row(h.column, 'group', 0)
|
||||
group.dataset.chatGroupKey = 'group'
|
||||
const body = document.createElement('div')
|
||||
body.hidden = true
|
||||
group.append(body)
|
||||
h.row(body, 'hidden-member', 20)
|
||||
expect(h.viewport.capturePosition()).toBeNull()
|
||||
expect(h.viewport.beginHistoryPrepend()).toBe(true)
|
||||
h.prepend(120)
|
||||
h.viewport.finishHistoryPrepend()
|
||||
expect(h.column.scrollTop).toBe(120)
|
||||
expect(group.getBoundingClientRect().top).toBe(0)
|
||||
expect(h.viewport.preserving).toBe(false)
|
||||
})
|
||||
|
||||
it('restores the captured screen position once without retaining paging ownership', () => {
|
||||
const h = fixture()
|
||||
const row = h.row(h.column, 'old', 0)
|
||||
expect(h.viewport.beginHistoryPrepend()).toBe(true)
|
||||
expect(h.viewport.preserving).toBe(false)
|
||||
h.column.scrollTop = 25
|
||||
h.prepend(120)
|
||||
h.viewport.finishHistoryPrepend()
|
||||
expect(h.column.scrollTop).toBe(120)
|
||||
expect(row.getBoundingClientRect().top).toBe(0)
|
||||
expect(h.viewport.preserving).toBe(false)
|
||||
})
|
||||
|
||||
it('does not prepare a correction when no paging anchor exists', () => {
|
||||
const h = fixture()
|
||||
expect(h.viewport.beginHistoryPrepend()).toBe(false)
|
||||
h.prepend(120)
|
||||
h.viewport.finishHistoryPrepend()
|
||||
expect(h.column.scrollTop).toBe(0)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ count: 64, slot: false }, { count: 4096, slot: false },
|
||||
{ count: 64, slot: true }, { count: 4096, slot: true },
|
||||
@@ -440,6 +534,71 @@ it.each([
|
||||
expect(h.row.getBoundingClientRect().top).toBe(top)
|
||||
})
|
||||
|
||||
it('restores the captured inner and outer screen position once without subscribing', () => {
|
||||
const h = nestedFixture(600, 400)
|
||||
h.scroller.scrollTop = 80
|
||||
h.body.scrollTop = 40
|
||||
const screenTop = h.row.getBoundingClientRect().top
|
||||
expect(h.viewport.beginHistoryPrepend()).toBe(true)
|
||||
expect(h.viewport.preserving).toBe(false)
|
||||
expect(h.observed.has(h.content)).toBe(false)
|
||||
h.body.scrollTop = 60
|
||||
h.scroller.scrollTop = 90
|
||||
h.prepend(200)
|
||||
h.viewport.finishHistoryPrepend()
|
||||
expect(h.body.scrollTop).toBe(240)
|
||||
expect(h.scroller.scrollTop).toBe(80)
|
||||
expect(h.row.getBoundingClientRect().top).toBe(screenTop)
|
||||
expect(h.observed.has(h.content)).toBe(false)
|
||||
h.prepend(20)
|
||||
h.resize()
|
||||
expect(h.body.scrollTop).toBe(240)
|
||||
expect(h.scroller.scrollTop).toBe(80)
|
||||
})
|
||||
|
||||
it('resolves a remounted one-shot anchor without entering retained paging', () => {
|
||||
const h = fixture()
|
||||
const row = h.row(h.column, 'old', 0)
|
||||
expect(h.viewport.beginHistoryPrepend()).toBe(true)
|
||||
row.remove()
|
||||
const replacement = h.row(h.column, 'old', 0)
|
||||
h.column.scrollTop = 25
|
||||
h.prepend(120)
|
||||
h.viewport.finishHistoryPrepend()
|
||||
expect(h.column.scrollTop).toBe(120)
|
||||
expect(replacement.getBoundingClientRect().top).toBe(0)
|
||||
expect(h.viewport.preserving).toBe(false)
|
||||
})
|
||||
|
||||
it('does not repeat native outer anchoring during a near-zero one-shot prepend', () => {
|
||||
const h = fixture()
|
||||
const row = h.row(h.column, 'old', 0)
|
||||
h.column.scrollTop = 0.5
|
||||
const top = row.getBoundingClientRect().top
|
||||
expect(h.viewport.beginHistoryPrepend()).toBe(true)
|
||||
h.prepend(120)
|
||||
// Model the browser's adjustment before the layout effect reads the new geometry.
|
||||
h.column.scrollTop = 120.5
|
||||
h.viewport.finishHistoryPrepend()
|
||||
expect(h.column.scrollTop).toBe(120.5)
|
||||
expect(row.getBoundingClientRect().top).toBe(top)
|
||||
})
|
||||
|
||||
it('does not repeat native inner anchoring when the outer scroller is at zero', () => {
|
||||
const h = nestedFixture(600, 400)
|
||||
h.body.scrollTop = 40
|
||||
const top = h.row.getBoundingClientRect().top
|
||||
expect(h.viewport.beginHistoryPrepend()).toBe(true)
|
||||
h.prepend(200)
|
||||
// Same ordering as the Chromium regression: native inner adjustment precedes our correction.
|
||||
h.body.scrollTop = 240
|
||||
h.viewport.finishHistoryPrepend()
|
||||
expect(h.body.scrollTop).toBe(240)
|
||||
expect(h.scroller.scrollTop).toBe(0)
|
||||
expect(h.row.getBoundingClientRect().top).toBe(top)
|
||||
expect(h.viewport.preserving).toBe(false)
|
||||
})
|
||||
|
||||
it('gives paging compensation priority over an active inner follow animation', () => {
|
||||
const h = nestedFixture(600, 400)
|
||||
const follow = new ScrollFollow(true, 1)
|
||||
|
||||
@@ -206,7 +206,7 @@
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.status {
|
||||
|
||||
Loaded 100 of 288 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user