Bladeren bron

chore: remove unrelated changes from Inbox PR

_Kerman 3 weken geleden
bovenliggende
commit
c8f76ca399

+ 2 - 2
.agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.md
-2026-08-06-reader-scroll-attribution-observed-top-ledger.md: d948996b10f200f7dcf5da597b0bf80b40bd801f
-2026-08-06-reader-scroll-attribution-observed-top-ledger.zh.md: 9659930a2d3571d03f4d3b02d58ea78929dcf3c6
+2026-08-06-reader-scroll-attribution-observed-top-ledger.md: b55bbc39f6e1f24bb7751b7743da23736abbd06b
+2026-08-06-reader-scroll-attribution-observed-top-ledger.zh.md: e38ead6c9b80e41b37556172bd6c1f411858b6ed

+ 3 - 5
.agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.md

@@ -10,7 +10,7 @@ ChatView's bottom-follow recognized only wheel/trackpad gestures as reader input
 
 ## Decision
 
-Reader input is no longer identified by device. ChatView keeps an observed-top ledger (`observedTopRef`): the last `scrollTop` either delivered on the main thread or written by the component, recorded synchronously at every programmatic write site — bottom follow, open restore, prepend anchoring, resize follow, and scroll delivery itself. When a scroll event arrives, a position that deviates from `min(ledger, floor)` by more than half a pixel is reader input; a position on the ledger (a delayed programmatic delivery) or exactly on the shrunken floor (a browser clamp after content shrank) preserves the current ownership state. Ownership then changes only through reader input under the existing threshold rule: within `FOLLOW_THRESHOLD` of the floor re-pins, beyond it releases follow and shows Back to bottom. Raw scroll events only schedule a sample, capped at one per 500 ms and finalized by `scrollend`. A `ResizeObserver` notification first flushes any pending sample against the resized floor; only the resulting ownership may follow the new floor. The wheel listener and its epoch bookkeeping are deleted; the component listens to `scroll` alone, so wheel, touch, scrollbar, keyboard, and any future input source are covered by one rule.
+Reader input is no longer identified by device. ChatView keeps an observed-top ledger (`observedTopRef`): the last `scrollTop` either delivered on the main thread or written by the component, recorded synchronously at every programmatic write site — bottom follow, open restore, prepend anchoring, resize follow, and scroll delivery itself. When a scroll event arrives, a position that deviates from `min(ledger, floor)` by more than half a pixel is reader input; a position on the ledger (a delayed programmatic delivery) or exactly on the shrunken floor (a browser clamp after content shrank) preserves the current ownership state. Ownership then changes only through reader input under the existing threshold rule: within `FOLLOW_THRESHOLD` of the floor re-pins, beyond it releases follow and shows Back to bottom. The wheel listener and its epoch bookkeeping are deleted; the component listens to `scroll` alone, so wheel, touch, scrollbar, keyboard, and any future input source are covered by one rule.
 
 ## Contract change: coalesced shrink-plus-regrow clamps
 
@@ -18,7 +18,7 @@ A shrink clamp whose layout regrows within the same rendering update before the
 
 ## Testing
 
-Unit specs in `packages/client/ui-chat/tests/chat-view.client.spec.tsx` pin the ledger contract directly: a `readerScroll` helper delivers a position the component never wrote, programmatic deliveries land on the ledger, the stream-finalization shrink clamp keeps following, and a resize settles queued programmatic and reader deliveries before choosing whether to follow. Two scenarios in `apps/web/tests/chat-scroll-contract.e2e.ts` extend the [browser e2e lane](../testing/2026-07-24-web-gui-browser-e2e-lane.md): keyboard paging over a settled transcript and a touch-style momentum fling against paced streaming, both red under the wheel-only implementation and green under the ledger.
+Unit specs in `packages/client/ui-chat/tests/chat-view.client.spec.tsx` pin the ledger contract directly: a `readerScroll` helper delivers a position the component never wrote, programmatic deliveries land on the ledger, and the stream-finalization shrink clamp keeps following. Two scenarios in `apps/web/tests/chat-scroll-contract.e2e.ts` extend the [browser e2e lane](../testing/2026-07-24-web-gui-browser-e2e-lane.md): keyboard paging over a settled transcript and a touch-style momentum fling against paced streaming, both red under the wheel-only implementation and green under the ledger.
 
 The lane's Chromium cannot synthesize any non-wheel device scrolling, which bounds what the e2e can drive for real: `Input.synthesizeScrollGesture` with a touch source and hand-rolled `Input.dispatchTouchEvent` sequences deliver DOM events but never move a scroller (headless and headed-under-Xvfb alike); the `default` gesture source synthesizes wheel events; and compositor scrollbars ignore synthetic mouse input entirely, with a gutter visible only when `--hide-scrollbars` is removed. Keyboard is the one working non-wheel primitive, so it carries the real-input-pipeline proof, and the fling scenario replays touch's signature — per-frame decaying displacements the component never authored — through the scrollport directly.
 
@@ -30,10 +30,8 @@ The lane's Chromium cannot synthesize any non-wheel device scrolling, which boun
 
 **Absorb the coalesced shrink-plus-regrow clamp with heuristics.** Floor-mismatch grace windows or deferred rAF re-checks could keep the raced clamp from reading as the reader. Rejected: streaming rewrites the floor at chunk pace (24 ms) against ~16 ms frames, so any grace window either swallows genuine touch input during streaming — reopening the bug this change fixes — or is too short to cover the race it targets. The mis-attribution is accepted and recoverable instead.
 
-**Drop resize follow while a scroll sample is pending.** Rejected: a delayed programmatic scroll delivery can still be queued when streaming or a tool disclosure raises the floor. Ignoring that resize can leave a pinned reader one content increment above the tail; settling the sample first distinguishes it from a queued reader move without removing throttling.
-
 **Drive real touch and scrollbar devices in e2e.** Rejected by the environment, not by preference: every synthesis path (CDP touch gestures, touch event sequences, synthetic mouse on classic scrollbars, headed under Xvfb) was probed and cannot scroll; the details live in Testing above.
 
 ## Consequences
 
-Every reader input owns bottom-follow uniformly, with less code: the wheel listener, its epoch counter, and the pre-input baseline bookkeeping are gone, and attribution rides state the component already maintained. A content resize may advance one throttled sample, making the changed floor an explicit ownership decision point. The sticky-composer note's layout, wheel chaining, and prepend-anchoring decisions are untouched and remain authoritative; its wheel-only input rule is superseded by this note. The cost is the contract change above — a coalesced non-React shrink-plus-regrow clamp now pauses follow until the reader returns to the floor or presses Back to bottom — traded for touch, scrollbar, and keyboard correctness during streaming. The e2e lane gains non-wheel coverage only within what its browser can synthesize; if gesture synthesis starts working in a future Chromium, the fling emulation can be replaced by real touch strokes without changing the asserted contract.
+Every reader input owns bottom-follow uniformly, with less code: the wheel listener, its epoch counter, and the pre-input baseline bookkeeping are gone, and attribution rides state the component already maintained. The sticky-composer note's layout, wheel chaining, and prepend-anchoring decisions are untouched and remain authoritative; its wheel-only input rule is superseded by this note. The cost is the contract change above — a coalesced non-React shrink-plus-regrow clamp now pauses follow until the reader returns to the floor or presses Back to bottom — traded for touch, scrollbar, and keyboard correctness during streaming. The e2e lane gains non-wheel coverage only within what its browser can synthesize; if gesture synthesis starts working in a future Chromium, the fling emulation can be replaced by real touch strokes without changing the asserted contract.

+ 3 - 5
.agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.zh.md

@@ -10,7 +10,7 @@ ChatView 的贴底跟随此前只把滚轮/触控板手势识别为读者输
 
 ## 决策
 
-读者输入不再依据设备来识别。ChatView 维护一份 observed-top ledger(`observedTopRef`):即最近一次由主线程交付、或由组件自身写入的 `scrollTop`,并在每一个程序化写入点(贴底跟随、打开时恢复、前置锚定、尺寸变化跟随以及滚动交付本身)同步记录。滚动事件到达时,偏离 `min(ledger, floor)` 超过半像素的位置即为读者输入;落在 ledger 上的位置(迟到的程序化交付),或恰好落在收缩后底部上的位置(内容收缩后的浏览器钳制),则维持当前的所有权状态。此后所有权只经由读者输入、按既有阈值规则变化:位置距底部在 `FOLLOW_THRESHOLD` 以内则重新贴底,超出则释放跟随并显示「回到底部」。原始 `scroll` 事件只安排采样,采样频率上限为每 500 ms 一次,并由 `scrollend` 完成最终采样。`ResizeObserver` 通知会先根据尺寸变化后的底部刷新任何待处理采样;只有结算后的所有权才能跟随新底部。滚轮监听器及其 epoch 簿记已删除;组件只监听 `scroll`,因此滚轮、触控、滚动条、键盘以及未来任何输入来源都由同一条规则覆盖。
+读者输入不再依据设备来识别。ChatView 维护一份 observed-top ledger(`observedTopRef`):即最近一次由主线程交付、或由组件自身写入的 `scrollTop`,并在每一个程序化写入点(贴底跟随、打开时恢复、前置锚定、尺寸变化跟随以及滚动交付本身)同步记录。滚动事件到达时,偏离 `min(ledger, floor)` 超过半像素的位置即为读者输入;落在 ledger 上的位置(迟到的程序化交付),或恰好落在收缩后底部上的位置(内容收缩后的浏览器钳制),则维持当前的所有权状态。此后所有权只经由读者输入、按既有阈值规则变化:位置距底部在 `FOLLOW_THRESHOLD` 以内则重新贴底,超出则释放跟随并显示「回到底部」。滚轮监听器及其 epoch 簿记已删除;组件只监听 `scroll`,因此滚轮、触控、滚动条、键盘以及未来任何输入来源都由同一条规则覆盖。
 
 ## 约定变更:收缩与重新增长被合并的钳制
 
@@ -18,7 +18,7 @@ ChatView 的贴底跟随此前只把滚轮/触控板手势识别为读者输
 
 ## 测试
 
-`packages/client/ui-chat/tests/chat-view.client.spec.tsx` 中的单元测试直接钉住 ledger 约定:`readerScroll` 辅助函数交付一个组件从未写入过的位置,程序化交付落在 ledger 上,流收尾阶段的收缩钳制保持跟随,而尺寸变化会在决定是否跟随前先结算排队中的程序化交付和读者交付。`apps/web/tests/chat-scroll-contract.e2e.ts` 中的两个场景扩展了[浏览器 e2e 车道](../testing/2026-07-24-web-gui-browser-e2e-lane.zh.md):在已停稳的 transcript 上做键盘翻页,以及对着按节奏推进的流式输出做一次触控式惯性快滑(momentum fling);两者在仅认滚轮的实现下均为红、在 ledger 下均为绿。
+`packages/client/ui-chat/tests/chat-view.client.spec.tsx` 中的单元测试直接钉住 ledger 约定:`readerScroll` 辅助函数交付一个组件从未写入过的位置,程序化交付落在 ledger 上,流收尾阶段的收缩钳制保持跟随。`apps/web/tests/chat-scroll-contract.e2e.ts` 中的两个场景扩展了[浏览器 e2e 车道](../testing/2026-07-24-web-gui-browser-e2e-lane.zh.md):在已停稳的 transcript 上做键盘翻页,以及对着按节奏推进的流式输出做一次触控式惯性快滑(momentum fling);两者在仅认滚轮的实现下均为红、在 ledger 下均为绿。
 
 该车道的 Chromium 无法合成任何非滚轮的设备滚动,这限定了 e2e 能真实驱动的范围:触控来源的 `Input.synthesizeScrollGesture` 与手工构造的 `Input.dispatchTouchEvent` 序列都能交付 DOM 事件,却从不移动滚动容器(无头模式与 Xvfb 下的有头模式皆然);`default` 手势来源合成的是滚轮事件;合成器滚动条则完全无视合成的鼠标输入,且只有移除 `--hide-scrollbars` 后才能看到滚动条槽。键盘是唯一可用的非滚轮原语,因此由它承担真实输入流水线的证明;快滑场景则把触控的特征(组件从未写入过的逐帧衰减位移)直接回放进滚动容器。
 
@@ -30,10 +30,8 @@ ChatView 的贴底跟随此前只把滚轮/触控板手势识别为读者输
 
 **用启发式吸收收缩与重新增长被合并的钳制。** 针对底部失配的宽限窗口,或推迟到 rAF 的复查,本可让这种竞态下的钳制不被判读为读者。否决:流式输出以分片节奏(24 ms)改写底部,而帧间隔约 16 ms,因此任何宽限窗口要么会在流式输出期间吞掉真实的触控输入(重新打开本次变更所修复的缺陷),要么短到盖不住它想针对的竞态。转而接受这一误归因,它是可恢复的。
 
-**有滚动采样待处理时放弃尺寸变化跟随。** 否决:当流式输出或 Tool 披露抬高底部时,延迟的程序化滚动交付可能仍在排队。忽略这次尺寸变化会让已贴底的读者停在尾部上方一段内容处;先结算采样既可以把它与排队中的读者移动区分开,又无需移除节流。
-
 **在 e2e 中驱动真实的触控与滚动条设备。** 否决来自环境,而非偏好取舍:每条合成路径(CDP 触控手势、触控事件序列、经典滚动条上的合成鼠标、Xvfb 下的有头模式)都逐一试过,均无法滚动;细节见上文「测试」一节。
 
 ## 后果
 
-每种读者输入现在都以同一方式拥有贴底跟随,而代码更少:滚轮监听器、它的 epoch 计数器以及输入前基线簿记均已移除,归因搭载在组件本就维护的状态之上。一次内容尺寸变化可以提前执行一次被节流的采样,使变化后的底部成为明确的所有权决策点。sticky-composer 笔记中的布局、滚轮链式处理与前置锚定决策原样保留,仍为权威;其窄范围的输入来源规则由本笔记取代。代价就是上文的约定变更:一次收缩与重新增长被合并的非 React 钳制现在会暂停跟随,直到读者回到底部或按下「回到底部」;以此换来流式输出期间触控、滚动条与键盘的正确性。e2e 车道获得的非滚轮覆盖仅限其浏览器能够合成的范围;若手势合成在未来某个 Chromium 版本中开始可用,可以在不改变所断言约定的前提下,把快滑模拟替换为真实的触控划动。
+每种读者输入现在都以同一方式拥有贴底跟随,而代码更少:滚轮监听器、它的 epoch 计数器以及输入前基线簿记均已移除,归因搭载在组件本就维护的状态之上。sticky-composer 笔记中的布局、滚轮链式处理与前置锚定决策原样保留,仍为权威;其窄范围的输入来源规则由本笔记取代。代价就是上文的约定变更:一次收缩与重新增长被合并的非 React 钳制现在会暂停跟随,直到读者回到底部或按下「回到底部」;以此换来流式输出期间触控、滚动条与键盘的正确性。e2e 车道获得的非滚轮覆盖仅限其浏览器能够合成的范围;若手势合成在未来某个 Chromium 版本中开始可用,可以在不改变所断言约定的前提下,把快滑模拟替换为真实的触控划动。

+ 1 - 1
apps/cli/tests/profiles/headless/tests/ptc.e2e.ts

@@ -6,13 +6,13 @@ import { Context } from '@deepseek-ai/cordis'
 import LlmRuntime, { createUserMessage, ToolCallId, HarnessError  } from '@deepseek-ai/dsh-llm'
 import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
 import type { SessionEvent } from '@deepseek-ai/dsh-session'
-import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
 import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
 import ToolRuntime, { RUN_CODE_NAME, defineTool } from '@deepseek-ai/dsh-tools'
 import type { ToolExecutionResult } from '@deepseek-ai/dsh-tools'
 import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
 
 import AgentLoop from '@deepseek-ai/dsh-agent-loop'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
 import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
 import * as BashEnvPlugin from '@deepseek-ai/dsh-shell-env'
 import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'

+ 2 - 2
packages/client/ui-chat/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-chat/README.md
-README.md: 1bdb036a7f1ce35970f3010bcec7d2bdef973545
-README.zh.md: c7270071dbb2b3a82343f84890c5eb28e1096169
+README.md: 79214bf2feb8384cecbb152aa31af162dc6dfdde
+README.zh.md: 674959e1974c74abba75f179eff50fe8488458fc

+ 1 - 1
packages/client/ui-chat/README.md

@@ -46,7 +46,7 @@ Settings → General exposes a persisted `Normal` / `Compact` conversation-displ
 <a id="scroll-ownership"></a>
 ## Scroll ownership
 
-Chat restores semantic anchors across history prepend and renderer remounts. Raw scroll events are throttled; a content resize first settles any queued sample against the new floor, then `ResizeObserver` follows only while the observed-top ledger still assigns bottom ownership. Pinned resize follow selects the latest loaded Turn without reading row geometry. Once the reader moves away, flow-height changes preserve the top position and the reading-line geometry selects the active Turn. Turn-rail previews paint above sticky Markdown code-block banners, while the rail frame remains inside the transcript band above the composer ([reader scroll attribution](../../../.agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.md), [loaded-Turn navigation](../../../.agents/notes/implemented/feature/2026-08-25-loaded-turn-chat-navigation.md)).
+Chat restores semantic anchors across history prepend and renderer remounts. While the reader is pinned to the floor, `ResizeObserver` follows the new floor and selects the latest loaded Turn without reading row geometry. Once the reader moves away, flow-height changes preserve the top position and the reading-line geometry selects the active Turn. Turn-rail previews paint above sticky Markdown code-block banners, while the rail frame remains inside the transcript band above the composer ([loaded-Turn navigation](../../../.agents/notes/implemented/feature/2026-08-25-loaded-turn-chat-navigation.md)).
 
 -----
 

+ 1 - 1
packages/client/ui-chat/README.zh.md

@@ -46,7 +46,7 @@ Chat 会为每个非空的初始或恢复请求、显式消息序列起点或真
 <a id="scroll-ownership"></a>
 ## 滚动归属
 
-Chat 会在历史前插与 renderer 重新挂载时恢复语义锚点。原始滚动事件会被节流;内容尺寸变化会先根据新底部结算任何待处理采样,然后 `ResizeObserver` 仅在 observed-top ledger 仍将底部归给跟随时追随它。已贴底的尺寸变化跟随无需读取行几何就选中最后一个已加载 Turn。读者离开底部后,高度变化会保持顶部位置,再由阅读线几何选择活跃 Turn。轮次导航预览位于 Markdown 代码块粘性头栏上方,而导航外框始终处于 composer 上方的 transcript 区域内([读者滚动归因](../../../.agents/notes/implemented/bug-fix/2026-08-06-reader-scroll-attribution-observed-top-ledger.zh.md)、[已加载 Turn 导航](../../../.agents/notes/implemented/feature/2026-08-25-loaded-turn-chat-navigation.zh.md))。
+Chat 会在历史前插与 renderer 重新挂载时恢复语义锚点。读者跟随底部时,`ResizeObserver` 追随新的底部,并且无需读取行几何就选中最后一个已加载 Turn;读者离开底部后,高度变化会保持顶部位置,再由阅读线几何选择活跃 Turn。轮次导航预览位于 Markdown 代码块粘性头栏上方,而导航外框始终处于 composer 上方的 transcript 区域内([已加载 Turn 导航](../../../.agents/notes/implemented/feature/2026-08-25-loaded-turn-chat-navigation.zh.md))。
 
 -----
 

+ 1 - 9
packages/client/ui-chat/src/client/chat/ChatView.tsx

@@ -309,7 +309,6 @@ export function ChatView({
   const [atBottom, setAtBottom] = useState(() => chatScroll.read() === null)
   const atBottomRef = useRef(atBottom)
   const scrollSamplePendingRef = useRef(false)
-  const flushScrollSampleRef = useRef<(() => void) | null>(null)
   const [, setScrollSampleTick] = useState(0)
   const [activeTurn, setActiveTurn] = useState<number | null>(
     () => turnNavigationItems.at(-1)?.turn ?? null,
@@ -600,14 +599,12 @@ export function ChatView({
       scrollSamplePendingRef.current = true
       sampleTimer ??= window.setTimeout(sample, SCROLL_SAMPLE_INTERVAL_MS)
     }
-    flushScrollSampleRef.current = sample
     el.addEventListener('scroll', onScroll, { passive: true })
     el.addEventListener('scrollend', sample, { passive: true })
     return () => {
       el.removeEventListener('scroll', onScroll)
       el.removeEventListener('scrollend', sample)
       if (sampleTimer !== undefined) window.clearTimeout(sampleTimer)
-      if (flushScrollSampleRef.current === sample) flushScrollSampleRef.current = null
       scrollSamplePendingRef.current = false
     }
   }, [])
@@ -616,12 +613,7 @@ export function ChatView({
   // initializer a function initial value would need never exists.
   const followRef = useRef<(() => void) | null>(null)
   followRef.current = () => {
-    // A resize changes the floor used for ownership. Settle a queued scroll
-    // against that geometry before deciding whether the new floor may follow.
-    if (scrollSamplePendingRef.current) {
-      flushScrollSampleRef.current?.()
-      return
-    }
+    if (scrollSamplePendingRef.current) return
     const local = listRef.current
     if (local !== null && atBottomRef.current) {
       const el = scrollerOf(local)

+ 2 - 9
packages/client/ui-chat/tests/chat-view.client.spec.tsx

@@ -2343,7 +2343,7 @@ describe('ChatView', () => {
     expect(view.getByLabelText('回到底部')).toBeTruthy()
   })
 
-  it('one ResizeObserver settles scroll ownership before following dynamic-height growth', () => {
+  it('one ResizeObserver owns pinned dynamic-height follow and ignores growth while away', () => {
     let notify: (() => void) | undefined
     const observe = vi.fn()
     class ResizeObserverStub {
@@ -2363,20 +2363,13 @@ describe('ChatView', () => {
     scroller.scrollTop = 700
     fireEvent.scroll(scroller)
     fireEvent(scroller, new Event('scrollend'))
-    // A delayed programmatic delivery may still be queued when the floor
-    // grows. The resize must settle it before preserving pinned ownership.
-    fireEvent.scroll(scroller)
     Object.defineProperty(scroller, 'scrollHeight', { value: 1_200, writable: true })
     act(() => { notify?.() })
     expect(scroller.scrollTop).toBe(1_200)
-    // A queued reader movement must win the same decision instead of being
-    // mistaken for a programmatic delivery and pulled to the new floor.
-    scroller.scrollTop = 200
-    fireEvent.scroll(scroller)
+    readerScroll(scroller, 200)
     Object.defineProperty(scroller, 'scrollHeight', { value: 1_400, writable: true })
     act(() => { notify?.() })
     expect(scroller.scrollTop).toBe(200)
-    expect(view.getByLabelText('回到底部')).toBeTruthy()
     expect(observe).toHaveBeenCalledTimes(1)
   })