Merge pull request #4494 from deepseek-harness/perf/chat-navigation-history

perf(chat): 虚拟化轮次导航并聚合历史跳转
This commit is contained in:
imccyu authored and GitHub committed 2026-09-18 14:21:32 +08:00
commit 21e31ac05e
28 files changed
+1886 -877

No files matched your search

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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/architecture/2026-09-18-chat-navigation-performance.md
2026-09-18-chat-navigation-performance.md: d990c0cb9a351ea3b3224ce5feec912c176a5fc9
2026-09-18-chat-navigation-performance.zh.md: 1a330d3b6bf6357199eb09ffc1cdb434cda1c1af
@@ -0,0 +1,59 @@
# Agent Note: Bound turn navigation work and batch historical jumps
Status: implemented
English | [中文](2026-09-18-chat-navigation-performance.zh.md)
## Problem
A long Session makes three independent costs visible: a turn rail with one DOM mark per known turn, transcript navigation that rediscovers an already known landing, and historical jumps that publish every fetched page. Each intermediate prepend rebuilds growing conversation indexes and exposes another transcript update before the requested turn is available. Combining these concerns into one controller obscures which operations require DOM geometry and which only apply reading or paging policy.
## Decision
### Fixed-pitch virtual rail
[TurnNavigator](../../../../packages/client/ui-chat/src/client/chat/TurnNavigator.tsx) uses TanStack React Virtual with a fixed 10px pitch. Only the visible range, overscan, and the focused mark with its neighbors remain mounted. The library owns the size container and absolute mark transforms. The full turn metadata remains resident; this is rail virtualization, not transcript virtualization.
The rail uses ResizeObserver-delivered viewport dimensions and fixed item sizes. Its offset calculation clamps against cached geometry and invokes the supported `scrollBy` operation, avoiding the library's DOM-based maximum-offset calculation used by `scrollToIndex`. The first usable size places the latest committed active turn directly after the body has restored its reading state. A ref exposes independent activation and rail-only scrolling. Native pointer targets replace coordinate hit testing, and mark length changes use transforms. Layout containment belongs to the floating rail frame; it does not promise that browser geometry reads flush only that subtree.
### Two-layer transcript navigation
[ChatViewport](../../../../packages/client/ui-chat/src/client/chat/use-chat-viewport.ts) owns DOM reads, clamped writes, native events, and turn anchors. It returns the actual landing with available turn and semantic-position facts. Reading policy consumes those facts without another hit test; the viewport ignores its own unchanged scroll echoes and events from the nested rail.
[ChatReading](../../../../packages/client/ui-chat/src/client/chat/use-chat-reading.ts) owns follow-tail policy, semantic restoration, and sampled reader movement. [ChatNavigation](../../../../packages/client/ui-chat/src/client/chat/use-chat-navigation.ts) owns loaded or unloaded jumps, paging anchors, replacement tasks, and fallback. [useChatScroll](../../../../packages/client/ui-chat/src/client/chat/use-chat-scroll.ts) supplies committed inputs and coordinates these owners. Timer and animation-frame completion invoke the owners directly instead of incrementing React tick state. The [pinned-delivery rule](../../../../packages/client/ui-chat/README.md#scroll-ownership) remains independently necessary: small reader gestures cannot be erased by intervening layout growth.
### One publication per historical jump
[Session.loadThrough](../../../../packages/api/session-controller/src/client/sessions/session.ts) retains accepted pages and a private paging cursor until its shared target is covered or loading ends. It reverses the page list and flattens it once, then publishes one ordered prepend. Live events continue through their normal publication path. A later page failure publishes the successful prefix once; stream or window replacement discards obsolete buffered pages. Single-page `loadOlder` remains immediate, and repeated jump requests retain the existing lowest-target policy.
### Theoretical work model
Let `T` be known turns, `V` mounted virtual marks, `P` fetched pages, `B` entries per page, and `W0` entries already loaded. These are work-count estimates, not timing measurements.
| Operation | Previous work | Current work |
|---|---|---|
| Rail DOM and React mark traversal | `O(T)` | `O(V)`; turn metadata and initial index/cache construction remain `O(T)` |
| Active-turn index lookup with unchanged items | Linear search, `O(T)` | Map lookup, expected `O(1)` |
| Rail target-offset calculation | Synchronous DOM extent/viewport reads | Fixed arithmetic and cached sizes; no DOM extent read on this path |
| Known transcript jump | Landing followed by position rediscovery | Reuse the landing; no hit test, but existing anchor-selector and turn lookup costs remain |
| History-driven linear index passes | `O(P * W0 + B * P²)` | `O(W0 + B * P)` for one final pass, plus `O(B * P)` buffer flattening |
| History-driven sorting | Repeated sorting of each growing window | One final-window sort, `O((W0 + B * P) log(W0 + B * P))` |
| Historical prepend publications | `P` | One; live-event and loading-state updates remain independent |
For illustration, 10,000 turns and about 50 mounted marks imply roughly 99.5% fewer rail marks, not a 200× end-to-end speedup. With 20 equal pages and a negligible initial window, a full linear pass after each page visits `210B` entries in aggregate; one final pass visits `20B`, about 10.5× less work for that pass. Page requests remain 20, and the final transcript still needs assembly, rendering, and layout.
## Alternatives considered
**Containment or React batching alone.** Containment does not remove repeated JavaScript work. Suppressing React renders after each published page still leaves conversation assembly and index rebuilds upstream.
**Patch the virtualizer.** The supported callbacks and relative-scroll operation cover fixed-height placement without maintaining a dependency fork. The native scroll container still needs a total extent; removing that extent would remove native scrolling, not virtualize it.
**One large scrolling controller.** It centralizes state but couples DOM mechanics to follow and paging policy. Separate owners allow business changes without copying geometry operations.
**Virtualize the whole transcript.** That introduces renderer lifetime and semantic-anchor constraints outside this change. Batching the requested historical jump removes intermediate publications without changing transcript residency.
## Consequences
Loading a distant turn retains the current transcript until the batch lands, instead of revealing intermediate pages. Buffered pages add references proportional to newly fetched entries. Main-transcript geometry reads, semantic-anchor queries, ordinary native-scroll metrics, final layout, and center-width reflow during Sidebar animation remain; none is claimed eliminated by this change. Transform and color changes may still repaint, and assigning `scrollTop` may still deliver a native scroll event.
[Rail tests](../../../../packages/client/ui-chat/tests/turn-navigator.client.spec.tsx) cover bounded mounting, first placement, fixed-size controls, and focus retention. [Chat tests](../../../../packages/client/ui-chat/tests/chat-view.client.spec.tsx) cover semantic restoration, follow attribution, own-scroll echoes, and navigation without hit testing. [Session tests](../../../../packages/api/session-controller/tests/session.client.spec.ts) cover atomic prepend publication with live delivery, failure, and replacement. [Browser navigation](../../../../apps/web/tests/chat-scroll-contract.e2e.ts) covers real scrolling and unloaded-turn landing. Functional evidence is separate from the theoretical estimates above.
@@ -0,0 +1,59 @@
# Agent Note: 限制轮次导航工作量并聚合历史跳转
Status: implemented
[English](2026-09-18-chat-navigation-performance.md) | 中文
## Problem
长会话会放大三类独立成本:轮次轨道为每个已知轮次挂载一个 DOM 刻度,正文导航重新查找已知落点,以及历史跳转逐页发布数据。每次中间前插都会重建不断增大的对话索引,并在目标轮次可用前再次更新正文。把这些职责放进一个控制器,会使需要 DOM 几何的操作与只负责阅读、分页策略的操作难以区分。
## Decision
### 固定间距虚拟轨道
[TurnNavigator](../../../../packages/client/ui-chat/src/client/chat/TurnNavigator.tsx) 使用 TanStack React Virtual,间距固定为 10px。只保留可见范围、预加载项,以及键盘焦点刻度及其相邻项。库负责尺寸容器和绝对定位刻度的变换。全部轮次元数据仍驻留内存;这里虚拟化的是轨道,不是正文。
轨道采用 ResizeObserver 提供的视口尺寸和固定条目高度。偏移计算根据缓存几何限制范围,再调用库支持的 `scrollBy`,避开 `scrollToIndex` 使用的基于 DOM 的最大偏移计算。正文恢复阅读状态后,轨道在首次获得可用尺寸时直接定位到最新已提交的活跃轮次。ref 分别提供激活轮次和仅滚动轨道的操作。原生指针目标替代坐标命中测试,刻度长度通过变换改变。布局包含作用于悬浮轨道外框,但不承诺浏览器几何读取只刷新该子树。
### 两层正文导航
[ChatViewport](../../../../packages/client/ui-chat/src/client/chat/use-chat-viewport.ts) 负责 DOM 读取、限制范围后的写入、原生事件和轮次锚点。它返回实际落点及可用的轮次、语义位置信息。阅读策略直接消费这些信息,无需再次命中测试;视口会忽略自身位置未变的滚动回声,以及内嵌轨道发出的事件。
[ChatReading](../../../../packages/client/ui-chat/src/client/chat/use-chat-reading.ts) 负责跟随底部、语义恢复和读者移动采样。[ChatNavigation](../../../../packages/client/ui-chat/src/client/chat/use-chat-navigation.ts) 负责已加载或未加载轮次跳转、分页锚点、任务替换和回退。[useChatScroll](../../../../packages/client/ui-chat/src/client/chat/use-chat-scroll.ts) 提供已提交输入并协调各对象。定时器与动画帧完成时直接调用负责对象,不再递增 React tick 状态。[贴底事件规则](../../../../packages/client/ui-chat/README.zh.md#scroll-ownership) 仍有独立必要性:期间发生的布局增长不能抵消读者的小幅操作。
### 每次历史跳转统一发布
[Session.loadThrough](../../../../packages/api/session-controller/src/client/sessions/session.ts) 在共享目标被覆盖或加载结束前,保留已接受的页面和私有分页游标。它只反转一次页面列表并展开一次,然后发布一次有序前插。实时事件继续走正常发布路径。后续页面失败时,已成功取得的部分统一发布一次;流或窗口被替换时丢弃失效的暂存页面。单页 `loadOlder` 仍立即发布,重复跳转请求保留既有的最低目标策略。
### 理论工作量模型
设 `T` 为已知轮次数,`V` 为已挂载虚拟刻度数,`P` 为获取页数,`B` 为每页条目数,`W0` 为原先已加载条目数。以下估算工作量,不代表耗时测量。
| 操作 | 原有工作量 | 当前工作量 |
|---|---|---|
| 轨道 DOM 与 React 刻度遍历 | `O(T)` | `O(V)`;轮次元数据与初始索引、缓存构建仍为 `O(T)` |
| 条目未变时查找活跃轮次索引 | 线性搜索,`O(T)` | Map 查找,期望 `O(1)` |
| 轨道目标偏移计算 | 同步读取 DOM 总高度、视口尺寸 | 固定算术与缓存尺寸;此路径不读取 DOM 滚动总高度 |
| 已知正文轮次跳转 | 定位后重新查找位置 | 复用落点;不做命中测试,但既有锚点选择器与轮次查找成本仍在 |
| 历史驱动的线性索引遍历 | `O(P * W0 + B * P²)` | 最终一次遍历为 `O(W0 + B * P)`,另有 `O(B * P)` 暂存展开 |
| 历史驱动的排序 | 对每个不断增大的窗口重复排序 | 对最终窗口排序一次,`O((W0 + B * P) log(W0 + B * P))` |
| 历史前插发布次数 | `P` | 一次;实时事件与加载状态更新仍独立发生 |
例如,10,000 个轮次只挂载约 50 个刻度,意味着轨道刻度约减少 99.5%,不代表端到端加速 200 倍。若获取 20 个等大页面且初始窗口可忽略,每页到达后做一次全量线性遍历,累计访问 `210B` 个条目;最终只遍历一次访问 `20B`,该遍历工作量约减少到原来的 1/10.5。分页请求仍为 20 次,最终正文仍需装配、渲染和布局。
## Alternatives considered
**只加布局包含或 React 批处理。** 布局包含不会删除重复的 JavaScript 工作。仅抑制每页发布后的 React 渲染,也会保留上游对话装配与索引重建。
**修改虚拟化库。** 现有回调和相对滚动操作已能完成固定高度定位,无需维护依赖分支。原生滚动容器仍需总高度;删除总高度会使原生滚动失效,而不是实现虚拟化。
**单一大滚动控制器。** 它能集中状态,却把 DOM 操作与跟随、分页策略耦合。拆分负责对象后,业务变更无需复制几何操作。
**虚拟化整个正文。** 这会引入本次范围之外的 renderer 生命周期与语义锚点约束。聚合目标历史跳转即可删除中间发布,无需改变正文驻留方式。
## Consequences
跳转远处轮次时,当前正文会保持到整批数据落地,不再逐页展示中间结果。暂存页面增加与新获取条目数成正比的引用。正文几何读取、语义锚点查询、普通原生滚动指标、最终布局,以及侧栏动画改变中间区域宽度时的重排仍然存在;本次不声称消除了这些成本。变换与颜色变化仍可能重绘,赋值 `scrollTop` 仍可能产生原生滚动事件。
[轨道测试](../../../../packages/client/ui-chat/tests/turn-navigator.client.spec.tsx) 覆盖有界挂载、首次定位、固定尺寸控制与焦点保留。[Chat 测试](../../../../packages/client/ui-chat/tests/chat-view.client.spec.tsx) 覆盖语义恢复、跟随归因、自身滚动回声与不做命中测试的导航。[Session 测试](../../../../packages/api/session-controller/tests/session.client.spec.ts) 覆盖实时事件并发下的原子前插发布、失败与替换。[浏览器导航](../../../../apps/web/tests/chat-scroll-contract.e2e.ts) 覆盖真实滚动与未加载轮次落点。功能证据与上述理论估算相互独立。
+18 -16
View File
@@ -198,35 +198,37 @@ describe('web e2e: long Chat interaction contract', () => {
const turnNavigation = page.getByRole('navigation', { name: 'Turn navigation' })
await turnNavigation.waitFor({ state: 'visible', timeout: 15_000 })
// The whole-log outline offers every fixture turn before any paging, with
// the live tail mark current.
const marks = turnNavigation.getByRole('button')
await expect.poll(() => marks.count(), { timeout: 15_000 }).toBe(FIXTURE_TURNS)
expect(await marks.last().getAttribute('aria-current')).toBe('true')
await expect.poll(() => marks.last().getAttribute('aria-current'), { timeout: 15_000 }).toBe('true')
expect(await marks.count()).toBeLessThan(FIXTURE_TURNS)
// The oldest turn is an unloaded mark whose outline preview already
// carries both the prompt and the settled response.
const firstTurnButton = turnNavigation
.getByRole('button', { name: 'Load and jump to turn 1', exact: true })
const railScroller = turnNavigation.locator('[class*="scroller"]')
await railScroller.hover()
await page.mouse.wheel(0, -FIXTURE_TURNS * 10)
await expect.poll(() => railScroller.evaluate(element => element.scrollTop)).toBe(0)
await firstTurnButton.focus()
const preview = page.getByRole('tooltip')
await preview.waitFor({ state: 'visible', timeout: 5_000 })
expect(await preview.textContent()).toContain(FIXTURE.markers.user(1))
await expect.poll(() => preview.textContent(), { timeout: 5_000 }).toContain(FIXTURE.markers.user(1))
expect(await preview.textContent()).toContain(FIXTURE.markers.assistant(1))
const firstTurnPosition = await firstTurnButton.evaluate(button => (
button.parentElement?.style.getPropertyValue('--turn-natural-position') ?? ''
const markPitch = () => turnNavigation.getByRole('button').evaluateAll(buttons => (
buttons[1]!.getBoundingClientRect().top - buttons[0]!.getBoundingClientRect().top
))
expect(firstTurnPosition).toBe('0px')
expect(await markPitch()).toBe(10)
const loadEarlier = page.getByRole('button', { name: 'Load earlier', exact: true })
const loadedMarks = turnNavigation.getByRole('button', { name: /^Jump to turn / })
const loadedBefore = await loadedMarks.count()
const loadedRows = page.locator('[data-chat-flow-key]')
const loadedBefore = await loadedRows.count()
await loadEarlier.click()
// Paging converts marks to their loaded form without moving the
// fixed-pitch ladder.
await expect.poll(() => loadedMarks.count(), { timeout: 15_000 }).toBeGreaterThan(loadedBefore)
expect(await firstTurnButton.evaluate(button => (
button.parentElement?.style.getPropertyValue('--turn-natural-position') ?? ''
))).toBe(firstTurnPosition)
await expect.poll(() => loadedRows.count(), { timeout: 15_000 }).toBeGreaterThan(loadedBefore)
await railScroller.hover()
await page.mouse.wheel(0, -FIXTURE_TURNS * 10)
await expect.poll(() => railScroller.evaluate(element => element.scrollTop)).toBe(0)
await firstTurnButton.waitFor({ state: 'visible' })
expect(await markPitch()).toBe(10)
// Activating the still-unloaded oldest mark pages the rest in and lands
// on the turn's own row.
await firstTurnButton.focus()
+25 -15
View File
@@ -587,7 +587,7 @@ describe('web e2e: long Chat scroll contract', () => {
})
}, 180_000)
it.skipIf(MODE === 'record')('offers every outline turn on the rail and jumps to an unloaded one', async () => {
it.skipIf(MODE === 'record')('virtualizes the outline rail and jumps to an unloaded turn', async () => {
await withScrollWorld({
failureShot: 'web-e2e-turn-rail-jump',
seeds: [{ fixture: HISTORY_FIXTURE, id: RAIL_SESSION_ID }],
@@ -595,31 +595,41 @@ describe('web e2e: long Chat scroll contract', () => {
await openSeed(world.page, HISTORY_FIXTURE, HISTORY_FIXTURE.markers.assistant(HISTORY_FIXTURE.turns))
await expectBottom(world.page)
// The whole-log outline reaches the rail before any paging: one mark
// per fixture turn, the oldest still in its load-and-jump form.
const rail = world.page.getByRole('navigation', { name: 'Turn navigation' })
await expect.poll(() => rail.getByRole('button').count(), { timeout: 15_000 })
.toBe(HISTORY_FIXTURE.turns)
const latest = rail.getByRole('button', { name: `Jump to turn ${String(HISTORY_FIXTURE.turns)}`, exact: true })
await expect.poll(() => latest.getAttribute('aria-current'), { timeout: 15_000 }).toBe('true')
expect(await rail.getByRole('button').count()).toBeLessThan(HISTORY_FIXTURE.turns)
const firstUnloaded = rail.getByRole('button', { name: 'Load and jump to turn 1', exact: true })
expect(await firstUnloaded.count()).toBe(1)
// Fixed pitch: the ladder keeps its natural height, scrolls inside the
// frame, and (following the active tail mark) fades its upper end.
expect(await rail.evaluate(nav => nav.style.getPropertyValue('--turn-natural-height')))
.toBe(`${String((HISTORY_FIXTURE.turns - 1) * 10 + 12)}px`)
expect(await firstUnloaded.count()).toBe(0)
const railScroller = rail.locator('[class*="scroller"]')
await expect.poll(() => railScroller.evaluate(el => el.scrollHeight > el.clientHeight)).toBe(true)
await expect.poll(() => rail.locator('[class*="fadeTop"]').count(), { timeout: 15_000 }).toBe(1)
const bodyBeforeRailScroll = await scrollGeometry(world.page)
await railScroller.hover()
await world.page.mouse.wheel(0, -HISTORY_FIXTURE.turns * 10)
await expect.poll(() => railScroller.evaluate(element => element.scrollTop)).toBe(0)
await firstUnloaded.waitFor({ state: 'visible' })
expect((await scrollGeometry(world.page)).scrollTop).toBe(bodyBeforeRailScroll.scrollTop)
// Activate the unloaded mark by keyboard: pointer input belongs to the
// rail frame, while each mark is the keyboard/AT destination. Focus
// first shows the outline-backed preview: prompt and settled response
// both travel ahead of the events.
const beforeRows = await loadedFlowRows(world.page)
await firstUnloaded.focus()
const tooltip = world.page.getByRole('tooltip')
await expect.poll(() => tooltip.count(), { timeout: 15_000 }).toBe(1)
expect(await tooltip.textContent()).toContain(HISTORY_FIXTURE.markers.user(1))
await expect.poll(() => tooltip.textContent(), { timeout: 5_000 }).toContain(HISTORY_FIXTURE.markers.user(1))
expect(await tooltip.textContent()).toContain(HISTORY_FIXTURE.markers.assistant(1))
const previewId = await tooltip.getAttribute('id')
const hoveredMark = () => rail.evaluate(element => element.querySelector<HTMLButtonElement>('button:hover')?.dataset.index ?? null)
await expect.poll(hoveredMark).not.toBeNull()
const hoveredBefore = await hoveredMark()
await world.page.mouse.wheel(0, 80)
await expect.poll(() => railScroller.evaluate(element => element.scrollTop)).toBe(80)
await expect.poll(hoveredMark).toBe(String(Number(hoveredBefore) + 8))
await nextPaint(world.page)
expect(await firstUnloaded.evaluate(element => document.activeElement === element)).toBe(true)
expect(await firstUnloaded.getAttribute('aria-describedby')).toBe(previewId)
expect(await tooltip.textContent()).toContain(HISTORY_FIXTURE.markers.user(1))
await world.page.mouse.wheel(0, -80)
await expect.poll(() => railScroller.evaluate(element => element.scrollTop)).toBe(0)
const transcriptLayers = await tooltip.evaluate((preview) => {
const railSlot = preview.closest('nav')?.parentElement
const codeBanner = document.querySelector<HTMLElement>('.md-code-block > :first-child')
+9 -9
View File
@@ -177,16 +177,16 @@ describe('web e2e: plugin configuration pages', () => {
const save = panel.getByRole('button', { name: '保存', exact: true })
await save.click()
await expect.poll(async () => (await settingsDocument()).includes('subagent-model-selection:'), { timeout: 10_000 })
.toBe(true)
expect(await settingsDocument()).toContain('maxDepth: 2')
expect(await settingsDocument()).toContain('enabled: true')
expect(await settingsDocument()).toContain('allowedModels:')
expect(await settingsDocument()).toContain('provider:')
expect(await settingsDocument()).toContain('model:')
// The page stays open once the save landed; a settled form offers no save to repeat.
// The Save label returns only after both namespace controllers settle.
await expect.poll(() => save.isDisabled(), { timeout: 10_000 }).toBe(true)
const saved = await settingsDocument()
expect(saved).toContain('subagent-model-selection:')
expect(saved).toContain('maxDepth: 2')
expect(saved).toContain('enabled: true')
expect(saved).toContain('allowedModels:')
expect(saved).toContain('provider:')
expect(saved).toContain('model:')
await expect.poll(() => toggle.getAttribute('aria-checked'), { timeout: 5_000 }).toBe('true')
await expect.poll(() => save.isDisabled(), { timeout: 5_000 }).toBe(true)
await toggle.click()
await save.click()
+17
View File
@@ -458,6 +458,23 @@ describe('web e2e: seeded history renders through cold resume', () => {
await expect.poll(() => disclosure.getAttribute('aria-expanded')).toBe('false')
})
it.skipIf(MODE === 'record')('restores the active turn rail mark across Chat and Trajectory', async () => {
const rail = page.getByRole('navigation', { name: 'Turn navigation' })
const current = rail.locator('[aria-current="true"]')
await current.waitFor({ state: 'visible' })
const active = await current.getAttribute('aria-label')
const scroller = page.locator('[data-conversation-scroll]')
const top = await scroller.evaluate(element => element.scrollTop)
await page.getByRole('tab', { name: 'Trajectory', exact: true }).click()
await page.getByLabel('Trajectory timeline', { exact: true }).waitFor({ state: 'visible' })
await page.getByRole('tab', { name: 'Chat', exact: true }).click()
await expect.poll(() => current.getAttribute('aria-label')).toBe(active)
await expect.poll(async () => Math.abs(await scroller.evaluate(element => element.scrollTop) - top)).toBeLessThanOrEqual(2)
expect(tripwire.pageErrors).toEqual([])
})
it.skipIf(MODE === 'record')('file-path tool rows rebuilt from the cold log open the right Sidebar', async () => {
onTestFailed(async () => {
await mkdir(fileURLToPath(new URL('../../../.artifacts/screenshots/0907-2205-sidebar', import.meta.url)), { recursive: true })
@@ -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/api/session-controller/README.md
README.md: f67355be0cb9737ebc67bf589528878bccc26e6d
README.zh.md: e52501c145c3789213aec517b0801e5e7ee2a4cb
README.md: fe6ed9200d520dee41945b1fc946a5a44472bdc8
README.zh.md: 0c2fc764eb20fcdf37c84f7d3b37ca8543e86b7d
@@ -44,6 +44,8 @@ Fork copies history through the selected completed turn, including its `turn/end
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()` still publishes its single page directly.
<a id="client-references"></a>
## Client references
@@ -44,6 +44,8 @@ Session 对象还承载本地提交回显:`session.beginSubmission` 在调用
恢复会话时若已有写句柄占用,返回 `session/writer-held`,并携带会话 id;其他恢复失败仍返回 `gateway/internal`。
`loadThrough(seq)` 在共享目标被覆盖或加载结束前私下保留较早页面,随后把成功取得的页面按顺序作为一次前插发布。历史加载期间实时事件仍然可见。后续页面失败时保留已成功取得的部分;历史窗口被替换时丢弃被替换窗口的暂存页面。普通 `loadOlder()` 仍直接发布其单页结果。
<a id="client-references"></a>
## Client 引用
@@ -127,6 +127,8 @@ export interface ISession {
* turn-jump loader. Repeated calls while a jump is paging lower its shared
* target and return the in-flight completion; `snapshot.loadingOlder` is
* the busy signal for the whole jump.
* Older pages publish together at completion, including successful pages
* before a later failure; live events remain independently visible.
* @param seq - durable event seq the window must reach (a turn's `turn/start` seq).
* @returns completion once covered, exhausted, superseded, or failed soft.
*/
@@ -53,6 +53,12 @@ export const PAGE_MESSAGES = 50
/** Messages requested per page while a turn jump loops backwards (fewer, larger round trips). */
export const JUMP_PAGE_MESSAGES = 200
interface PendingHistory {
beforeSeq: SessionLogOffset
hasMore: boolean
readonly pages: (readonly SessionEventLikeEntry[])[]
}
/** Manager-owned observers of a Session object's local state edges. */
export interface SessionOptions {
/** Catalog-discovered address selecting non-activating subagent transport. */
@@ -97,6 +103,7 @@ export class Session implements SessionFace {
private jumpTargetSeq: SessionSeq | null = null
/** The running jump loop's completion, shared by retargeting callers. */
private jumpPromise: Promise<void> | null = null
private pendingHistory: PendingHistory | null = null
private readonly stopObservingInbox: () => void
private readonly assistantStream = new ClientAssistantStream()
private running = false
@@ -419,6 +426,14 @@ export class Session implements SessionFace {
// target behind — only the loop's finally clears that field, and no
// loop starts here.
if (this.loadingOlder) return Promise.resolve()
const events = this.events
if (events === undefined) return Promise.resolve()
const pending: PendingHistory = {
beforeSeq: this.baseSeq,
hasMore: this.hasMore,
pages: [],
}
this.pendingHistory = pending
this.jumpTargetSeq = seq
this.loadingOlder = true
this.notifier.markDirty()
@@ -428,15 +443,13 @@ export class Session implements SessionFace {
const generation = this.openGeneration
this.jumpPromise = (async () => {
try {
while (this.hasMore && this.jumpTargetSeq !== null && this.baseSeq > this.jumpTargetSeq) {
while (pending.hasMore && this.jumpTargetSeq !== null && pending.beforeSeq > this.jumpTargetSeq) {
if (generation !== this.openGeneration) return
const events = this.events
if (events === undefined) return
const before = this.baseSeq
await events.prepend({ beforeSeq: this.baseSeq, maxMessages: JUMP_PAGE_MESSAGES })
const before = pending.beforeSeq
await events.prepend({ beforeSeq: before, maxMessages: JUMP_PAGE_MESSAGES })
// No-progress guard: an empty or dropped page that still claims more
// history must end the loop, not spin it.
if (this.baseSeq >= before) return
if (pending.beforeSeq >= before) return
}
} catch (error) {
if (!isRemoteFailure(error)) {
@@ -445,7 +458,11 @@ export class Session implements SessionFace {
} 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)
}
this.notifier.markDirty()
}
})()
@@ -648,6 +665,11 @@ export class Session implements SessionFace {
const visible = this.assistantStream.replace(entries, assistantStream)
this.baseSeq = SessionLogOffset(entries[0]?.event.seq ?? 0)
this.hasMore = hasMore
if (this.pendingHistory !== null) {
this.pendingHistory.beforeSeq = this.baseSeq
this.pendingHistory.hasMore = hasMore
this.pendingHistory.pages.length = 0
}
if (visible.some(entry => entry.event.type === 'turn/start')) this.firstPromptPendingTurn = false
if (projections !== undefined) this.projections.seed(projections)
this.eventSource.replace(visible, hasMore)
@@ -684,6 +706,13 @@ export class Session implements SessionFace {
/** Prepend one stream-validated history page. */
private prependWindow(entries: readonly SessionEventLikeEntry[], hasMore: boolean): void {
if (this.pendingHistory !== null) {
const pending = this.pendingHistory
pending.beforeSeq = entries[0] === undefined ? pending.beforeSeq : SessionLogOffset(entries[0].event.seq)
pending.hasMore = hasMore
pending.pages.push(entries)
return
}
this.baseSeq = entries[0] === undefined ? this.baseSeq : SessionLogOffset(entries[0].event.seq)
this.hasMore = hasMore
this.eventSource.prepend(entries, hasMore)
@@ -262,6 +262,88 @@ describe('paging', () => {
expect(mock.log.requests()).toHaveLength(sent)
})
it('publishes jump pages together while live events remain visible', async ({ mock, start }) => {
const oldest = plainTurn(SessionSeq(0), 0, 'old', 'answer')
const middle = plainTurn(SessionSeq(6), 1, 'middle', 'answer')
const newest = plainTurn(SessionSeq(12), 2, 'new', 'answer')
const session = await sessionBench(mock, start, SID)
mock.stream(FOLLOW, followScript(history(newest, true)))
await session.open()
const secondRequested = Promise.withResolvers<undefined>()
const lastPage = Promise.withResolvers<RemoteResult<SessionPage>>()
mock.remote.session.page.mockImplementation(pageRule((request) => {
if (request.beforeSeq === 12) return history(middle, true)
secondRequested.resolve(undefined)
return lastPage.promise
}))
const changes: string[] = []
onTestFinished(session.eventSource.subscribe(() => { changes.push(session.eventSource.getSnapshot().change.kind) }))
const jump = session.loadThrough(SessionSeq(0))
await secondRequested.promise
expect(changes).toEqual([])
expect(eventSeqs(session)).toEqual(newest.map(event => event.seq))
expect(session.getSnapshot().loadingOlder).toBe(true)
await pushEvent(mock, ev.turnStart(SessionSeq(18), 3))
expect(changes).toEqual(['append'])
expect(eventSeqs(session)).toEqual([...newest.map(event => event.seq), 18])
const nearerJump = session.loadThrough(SessionSeq(6))
expect(nearerJump).toBe(jump)
lastPage.resolve(history(oldest))
await jump
expect(changes).toEqual(['append', 'prepend'])
expect(eventSeqs(session)).toEqual(Array.from({ length: 19 }, (_, index) => index))
expect(session.eventSource.getSnapshot().change).toMatchObject({
kind: 'prepend', entries: entries([...oldest, ...middle]),
})
expect(session.getSnapshot()).toMatchObject({ loadingOlder: false, hasMore: false })
})
it('publishes the successful jump prefix once when a later page fails', async ({ mock, start }) => {
const middle = plainTurn(SessionSeq(6), 1, 'middle', 'answer')
const newest = plainTurn(SessionSeq(12), 2, 'new', 'answer')
const session = await sessionBench(mock, start, SID)
mock.stream(FOLLOW, followScript(history(newest, true)))
await session.open()
const changed = vi.fn()
onTestFinished(session.eventSource.subscribe(changed))
mock.remote.session.page.mockImplementation(pageRule(request => request.beforeSeq === 12
? history(middle, true)
: err(new RemoteError('gateway/internal', 'page unavailable', {}))))
await session.loadThrough(SessionSeq(0))
expect(changed).toHaveBeenCalledOnce()
expect(eventSeqs(session)).toEqual([...middle, ...newest].map(event => event.seq))
expect(session.getSnapshot()).toMatchObject({ loadingOlder: false, hasMore: true })
})
it('discards buffered jump pages when the Session replaces its stream', async ({ mock, start }) => {
const newest = plainTurn(SessionSeq(12), 2, 'new', 'answer')
const session = await sessionBench(mock, start, SID)
mock.stream(FOLLOW, followScript(history(newest, true)))
await session.open()
const secondRequested = Promise.withResolvers<undefined>()
const lastPage = Promise.withResolvers<RemoteResult<SessionPage>>()
mock.remote.session.page.mockImplementation(pageRule((request) => {
if (request.beforeSeq === 12) return history(plainTurn(SessionSeq(6), 1, 'middle', 'answer'), true)
secondRequested.resolve(undefined)
return lastPage.promise
}))
const changes: string[] = []
onTestFinished(session.eventSource.subscribe(() => { changes.push(session.eventSource.getSnapshot().change.kind) }))
const jump = session.loadThrough(SessionSeq(0))
await secondRequested.promise
const replacement = session.resync()
lastPage.resolve(history(plainTurn(SessionSeq(0), 0, 'old', 'answer')))
await Promise.all([jump, replacement])
expect(changes).toEqual(['replace'])
expect(eventSeqs(session)).toEqual(newest.map(event => event.seq))
expect(session.getSnapshot().loadingOlder).toBe(false)
})
it('loadThrough retargets a running jump to the lowest requested seq and shares its completion', async ({ mock, start }) => {
const oldest = plainTurn(SessionSeq(0), 0, 'a', 'b')
const middle = plainTurn(SessionSeq(6), 1, 'c', 'd')
+2 -2
View File
@@ -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: 92c71663e9193e24657a31e57204a9bf076e19c3
README.zh.md: 702042384b81d028b7db40275e5ee705dc949fb2
README.md: 95066b18f125df32a63d4ba6336bd0e83e0518a2
README.zh.md: f4697aac4f8ef9c19fe05449affb142ac4e84060
+12 -1
View File
@@ -72,7 +72,18 @@ Settings → General exposes a persisted, localized `Normal` / `Compact` convers
<a id="scroll-ownership"></a>
## Scroll ownership
Chat restores semantic anchors across history prepend and renderer remounts. Pinned scroll deliveries without reader movement update follow ownership immediately, before subsequent layout changes can invalidate their floor. Reader movement remains pending until the sampling interval or `scrollend`, even inside the follow threshold, so layout growth cannot erase small scroll gestures. 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.
Chat restores semantic anchors across history prepend and renderer remounts. Pinned scroll deliveries without reader movement update follow ownership immediately, before subsequent layout changes can invalidate their floor. Reader movement remains pending until the sampling interval or `scrollend`, even inside the follow threshold, so layout growth cannot erase small scroll gestures. Submitting transcript input or steering immediately restores tail following and clears an older pending reader sample. 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.
The turn rail mounts only visible marks, overscan, and the focused mark's neighbors. Its fixed pitch and observed viewport size determine scroll offsets without reading the DOM scroll extent. Initial placement waits for the body's restored active Turn and the rail's first usable viewport size. The ref controls activate a Turn or scroll the rail independently; the transcript itself remains fully mounted.
While the pointer is outside the rail, automatic follow keeps the rail still when the active mark's center is inside the fade-free band and centers it after it leaves that band. Previews follow pointer movement or focus; marks scrolling under a stationary pointer do not select another preview.
<details>
<summary>Scroll implementation — click to expand</summary>
`useChatViewport` owns turn-aware DOM reads, clamped writes, and native events. `useChatReading` owns follow policy, sampled reader input, and semantic memory; `useChatNavigation` owns turn jumps and history-prepend preservation. `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.
</details>
-----
+12 -1
View File
@@ -72,7 +72,18 @@ Assistant 回复结算后,已完成轮次的计时对话框不显示 TTFT 和
<a id="scroll-ownership"></a>
## 滚动归属
Chat 会在历史前插与 renderer 重新挂载时恢复语义锚点。没有读者移动的贴底滚动事件会立即更新跟随归属,避免后续布局变化使其底部位置失效。读者移动即使位于跟随阈值内,也保持待处理直到采样周期或 `scrollend`,防止布局增长抵消小幅滚动操作。读者跟随底部时,`ResizeObserver` 追随新的底部,并且无需读取行几何就选中最后一个已加载轮次;读者离开底部后,高度变化会保持顶部位置,再由阅读线几何选择活跃轮次。轮次导航预览位于 Markdown 代码块粘性头栏上方,而导航外框始终处于 composer 上方的 transcript 区域内。
Chat 会在历史前插与 renderer 重新挂载时恢复语义锚点。没有读者移动的贴底滚动事件会立即更新跟随归属,避免后续布局变化使其底部位置失效。读者移动即使位于跟随阈值内,也保持待处理直到采样周期或 `scrollend`,防止布局增长抵消小幅滚动操作。提交正文输入或 steering 时立即恢复跟随底部,并清除先前待处理的读者滚动采样。读者跟随底部时,`ResizeObserver` 追随新的底部,并且无需读取行几何就选中最后一个已加载轮次;读者离开底部后,高度变化会保持顶部位置,再由阅读线几何选择活跃轮次。轮次导航预览位于 Markdown 代码块粘性头栏上方,而导航外框始终处于 composer 上方的 transcript 区域内。
轮次轨道只挂载可见刻度、预加载范围及键盘焦点刻度的相邻项。固定间距和观察到的视口尺寸决定滚动偏移,不读取 DOM 滚动总高度。初始定位等待正文恢复活跃轮次,以及轨道首次获得可用视口尺寸。ref 控制接口分别支持激活轮次和仅滚动轨道;正文自身仍完整挂载。
指针位于轨道外时,自动跟随在活跃刻度中心处于非渐隐区域内时保持轨道不动,越界后才将其居中。预览随指针移动或焦点切换;刻度在静止指针下滚动不会选中另一个预览。
<details>
<summary>滚动实现——点击展开</summary>
`useChatViewport` 负责识别轮次的 DOM 读取、限制范围后的写入与原生事件。`useChatReading` 负责跟随策略、读者输入采样和语义位置记忆;`useChatNavigation` 负责轮次跳转与历史前插时的位置保持。`useChatScroll` 协调已提交的输入。显式导航把测得的落点交给阅读策略,因此无需通过命中测试重新寻找已知目标。
</details>
-----
+1
View File
@@ -86,6 +86,7 @@
"@deepseek-ai/dsh-token-meter": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-util-workspace-path": "workspace:^",
"@tanstack/react-virtual": "^3.14.9",
"@types/react": "~18.3.1",
"@types/react-dom": "~18.3.0",
"react": "^18.2.0",
@@ -1,11 +1,10 @@
// An enclosing `[data-conversation-scroll]` owns scrolling when present;
// otherwise this view owns it. Each row subscribes to one stable node key.
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ComponentProps } from 'react'
import { memo, useCallback, useEffect, useMemo, useRef, useState, type ComponentProps } from 'react'
import type {
ConversationTimelineSnapshot, RenderMessageImages,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { SessionSeq } from '@deepseek-ai/dsh-session/types'
import type { InboxState } from '@deepseek-ai/dsh-agent/types'
import {
Button, IconChevronDownOutlineRegular, MarkdownDelegateProvider, Modal,
@@ -15,117 +14,11 @@ import type { ChatSnapshot } from '../contract/snapshot.ts'
import { PendingSteeringBubble, PendingSubmissionBubble } from './MessageItem.tsx'
import { ChatNodeSeat } from './ChatNodeSeat.tsx'
import { TurnNavigator } from './TurnNavigator.tsx'
import { mergeTurnRailItems, type TurnRailItem } from './turn-rail-items.ts'
import { mergeTurnRailItems } from './turn-rail-items.ts'
import { useChatScroll } from './use-chat-scroll.ts'
import { formatRunDuration } from './message-chrome.ts'
import css from './ChatView.module.css'
const FOLLOW_THRESHOLD = 24
const SCROLL_SAMPLE_INTERVAL_MS = 500
/** Active column host when present; otherwise the view-local scroller. */
function scrollerOf(from: HTMLElement): HTMLElement {
return (from.closest('[data-conversation-scroll]')) ?? from
}
/** Browser shrink clamps and recorded writes do not transfer scroll ownership. */
function readerMovedScroll(top: number, floor: number, observedTop: number): boolean {
return Math.abs(top - Math.min(observedTop, floor)) > 0.5
}
interface PagingAnchor {
/** Stable node/call identity, independent of boundary-spanning group keys. */
key: string
/** Row top relative to the scrollport after the latest user scroll. */
top: number
}
/** Find an already-rendered row without interpolating a selector. */
function anchorElement(list: HTMLElement, key: string): HTMLElement | null {
for (const row of list.querySelectorAll<HTMLElement>('[data-chat-anchor-key]:not([hidden])')) {
if (row.dataset.chatAnchorKey === key) return row
}
return null
}
/**
* Turn owning the row at a scrollport line. Scroll frames are hot, so this
* hit-tests the line first and falls back to one row scan when layout cannot
* answer (jsdom, pre-paint); neither path queries per navigation item.
* @param list - the ChatView list element.
* @param line - viewport y of the reading line.
* @returns the Turn number, or null when no loaded row covers the line.
*/
function turnAtLine(list: HTMLElement, line: number): number | null {
const content = list.getBoundingClientRect()
if (typeof document.elementsFromPoint === 'function' && content.width > 0) {
for (const element of document.elementsFromPoint(content.left + content.width / 2, line)) {
const row = element instanceof HTMLElement ? element.closest<HTMLElement>('[data-chat-turn]') : null
const turn = Number(row?.dataset.chatTurn)
if (row !== null && list.contains(row) && Number.isSafeInteger(turn)) return turn
}
}
let found: number | null = null
for (const row of list.querySelectorAll<HTMLElement>('[data-chat-turn]')) {
if (row.getBoundingClientRect().top > line) break
const turn = Number(row.dataset.chatTurn)
if (Number.isSafeInteger(turn)) found = turn
}
return found
}
/** Row position in scrollport coordinates (viewport-independent). */
function flowTop(row: HTMLElement, scrollport: HTMLElement): number {
return row.getBoundingClientRect().top - scrollport.getBoundingClientRect().top
}
/** Select a visible stable node/call identity, falling back only when layout
* has not exposed a visible box yet. */
function pagingAnchor(list: HTMLElement, scrollport: HTMLElement): HTMLElement | null {
const viewport = scrollport.getBoundingClientRect()
const composer = scrollport.querySelector<HTMLElement>('[data-composer-seat]')
const visibleBottom = composer?.getBoundingClientRect().top ?? viewport.bottom
// The leading edge preserves nested call identity when it hits a row.
// Chrome/gap misses use logarithmic layout reads over the ordered flex rows.
if (typeof document.elementsFromPoint === 'function' && visibleBottom > viewport.top) {
const content = list.getBoundingClientRect()
const left = Math.max(viewport.left, content.left)
const right = Math.min(viewport.right, content.right)
const x = left + Math.max(0, right - left) / 2
for (const element of document.elementsFromPoint(x, viewport.top + 1)) {
const row = element instanceof HTMLElement
? element.closest<HTMLElement>('[data-chat-anchor-key]')
: null
if (row !== null && list.contains(row)) return row
}
}
const rows = list.querySelectorAll<HTMLElement>(
'[data-chat-flow] > [data-chat-flow-key]:not(:empty):not([hidden])',
)
let low = 0
let high = rows.length
while (low < high) {
const middle = (low + high) >>> 1
if (rows.item(middle).getBoundingClientRect().bottom > viewport.top) high = middle
else low = middle + 1
}
const row = rows[low]
return row !== undefined && row.getBoundingClientRect().top < visibleBottom ? row : rows[0] ?? null
}
type ChatScrollPosition = NonNullable<ReturnType<ChatViewSlotProps['chatScroll']['read']>>
/** Capture a reflow-resistant reader position from the current rendered window. */
function scrollPosition(list: HTMLElement, scrollport: HTMLElement): ChatScrollPosition | null {
const row = pagingAnchor(list, scrollport)
const anchorKey = row?.dataset.chatAnchorKey
if (row === null || anchorKey === undefined) return null
return {
anchorKey,
anchorTop: flowTop(row, scrollport),
scrollTop: scrollport.scrollTop,
}
}
/** Host/OS refusal text for the file-open dialog; empty throws keep a locale fallback. */
function openFailureMessage(error: unknown, fallback: string): string {
const message = error instanceof Error ? error.message : String(error)
@@ -301,473 +194,31 @@ export function ChatView({
)
const runningTurnStart = useMemo(() => runningTurnStartTime(timeline), [timeline])
const listRef = useRef<HTMLDivElement | null>(null)
const columnRef = useRef<HTMLDivElement | null>(null)
// A saved position starts disarmed; the first layout effect synchronously
// restores it and normalizes a floor-clamped position back to following.
const [atBottom, setAtBottom] = useState(() => chatScroll.read() === null)
const atBottomRef = useRef(atBottom)
const scrollSamplePendingRef = useRef(false)
const [, setScrollSampleTick] = useState(0)
const [activeTurn, setActiveTurn] = useState<number | null>(
() => turnNavigationItems.at(-1)?.turn ?? null,
)
/** Last position delivered or written on the main thread. */
const observedTopRef = useRef(0)
/** Paging anchor: semantic row/position at click, updated by reader scrolls
* while the request is pending and restored after the prepend lands. */
const anchorRef = useRef<PagingAnchor | null>(null)
/** Unloaded-turn jump in flight: target turn plus its load-through seq. */
const pendingJumpRef = useRef<{ turn: number; seq: SessionSeq } | null>(null)
/** Whether the in-flight jump already landed mid-paging (settle then only corrects an untouched landing). */
const jumpLandedRef = useRef(false)
const [busyJumpTurn, setBusyJumpTurn] = useState<number | null>(null)
/** Bumped when a loadThrough completion settles, after its last page's commit. */
const [jumpSettleTick, setJumpSettleTick] = useState(0)
/** Window head at the last settle-time repage; an unmoved head falls back instead of repaging forever. */
const jumpRepageHeadRef = useRef<number | null>(null)
const firstSeqRef = useRef<number | null>(null)
const openedRef = useRef(false)
const lastKeyRef = useRef<string | null>(null)
const lastSteeringIdRef = useRef<string | null>(null)
const lastSubmissionIdRef = useRef<string | null>(null)
/** Flow tip signature — follow-scroll only when this moves, never on a
* scroll-driven at-bottom chrome re-render (which would snap inertial
* scrolls the rest of the way to the floor). */
const followSigRef = useRef<string | null>(null)
const firstKey = order[0]
const firstSeq = firstKey === undefined ? null : nodeStore.get(firstKey)?.anchorSeq ?? null
const lastKey = order.at(-1) ?? null
const lastNode = lastKey === null ? undefined : nodeStore.get(lastKey)
const lastSteeringId = pendingSteering[pendingSteering.length - 1]?.id ?? null
const lastSubmissionId = visibleSubmissions[visibleSubmissions.length - 1]?.requestId ?? null
const followSig = `${openState}:${firstSeq}:${lastKey}:${order.length}:${running ? 1 : 0}:${lastSteeringId ?? ''}:${lastSubmissionId ?? ''}`
const syncActiveTurn = useCallback((): void => {
if (scrollSamplePendingRef.current) return
const local = listRef.current
const first = turnNavigationItems[0]
if (local === null || first === undefined) {
setActiveTurn(null)
return
}
const el = scrollerOf(local)
if (el.scrollHeight - el.scrollTop - el.clientHeight <= FOLLOW_THRESHOLD + 1) {
const latest = turnNavigationItems.at(-1)?.turn ?? first.turn
setActiveTurn(current => current === latest ? current : latest)
return
}
const readingLine = el.getBoundingClientRect().top + Math.min(96, el.clientHeight * 0.2)
const reading = turnAtLine(local, readingLine)
// No row reaches the line yet: the flow head still owns the mark. Otherwise
// the row's Turn may be one the rail does not offer (all its nodes hidden),
// so the newest offered Turn at or above it owns the mark.
let next = first.turn
if (reading !== null) {
for (const item of turnNavigationItems) {
if (item.turn > reading) break
next = item.turn
}
}
setActiveTurn(current => current === next ? current : next)
}, [turnNavigationItems])
const activeTurnRef = useRef<(() => void) | null>(null)
const activeFrameRef = useRef<number | null>(null)
const scheduleActiveTurn = useCallback((): void => {
if (activeFrameRef.current !== null) return
if (typeof requestAnimationFrame === 'undefined') {
syncActiveTurn()
return
}
activeFrameRef.current = requestAnimationFrame(() => {
activeFrameRef.current = null
syncActiveTurn()
})
}, [syncActiveTurn])
useEffect(() => () => {
if (activeFrameRef.current !== null && typeof cancelAnimationFrame !== 'undefined') {
cancelAnimationFrame(activeFrameRef.current)
}
}, [])
activeTurnRef.current = scheduleActiveTurn
useLayoutEffect(() => {
scheduleActiveTurn()
}, [scheduleActiveTurn])
const toBottom = (el: HTMLElement): void => {
anchorRef.current = null
// Returning to the live tail supersedes a jump still landing.
pendingJumpRef.current = null
setBusyJumpTurn(current => current === null ? current : null)
el.scrollTop = el.scrollHeight
observedTopRef.current = el.scrollTop
atBottomRef.current = true
setAtBottom(true)
chatScroll.save(null)
setActiveTurn(turnNavigationItems.at(-1)?.turn ?? null)
}
// Land a row at the reading line and republish scroll-derived state. A
// latest-ref, so navigateToTurn's identity stays stable for the memoized rail.
const landOnRowRef = useRef<(local: HTMLElement, el: HTMLElement, row: HTMLElement, turn: number) => void>(
() => {},
)
landOnRowRef.current = (local, el, row, turn) => {
el.scrollTop += flowTop(row, el) - 24
observedTopRef.current = el.scrollTop
const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= FOLLOW_THRESHOLD + 1
atBottomRef.current = isAtBottom
setAtBottom(isAtBottom)
setActiveTurn(turn)
const position = isAtBottom ? null : scrollPosition(local, el)
if (isAtBottom) chatScroll.save(null)
else if (position !== null) chatScroll.save(position)
}
/**
* Land the pending jump once its Turn has a rendered anchor row; false
* while it must keep waiting. Mid-jump landings (`settle` false) keep the
* jump armed with the target row as the paging anchor, so later chunks and
* the load-earlier button's unmount re-land on the same row; the settling
* call clears the jump.
*/
const realizePendingJump = (local: HTMLElement, el: HTMLElement, settle: boolean): boolean => {
const pending = pendingJumpRef.current
if (pending === null) return true
const item = railItems.find(candidate => candidate.turn === pending.turn)
if (item === undefined || item.anchor.kind !== 'loaded') return false
const row = anchorElement(local, item.anchor.key)
if (row === null) return false
if (settle) {
pendingJumpRef.current = null
setBusyJumpTurn(null)
const held = anchorRef.current
const landedEarlier = jumpLandedRef.current
jumpLandedRef.current = false
anchorRef.current = null
// A reader who moved off an already-landed target mid-jump keeps their
// place; a first landing, or an untouched one, takes the correction.
if (!landedEarlier || held?.key === item.anchor.key) {
landOnRowRef.current(local, el, row, pending.turn)
}
return true
}
landOnRowRef.current(local, el, row, pending.turn)
jumpLandedRef.current = true
anchorRef.current = { key: item.anchor.key, top: flowTop(row, el) }
return true
}
useLayoutEffect(() => {
if (scrollSamplePendingRef.current) return
const local = listRef.current
/* v8 ignore next -- ref-null guard: React attaches the ref before layout effects run. */
if (local === null) return
const el = scrollerOf(local)
// Open completed: jump to the bottom once — unless a scroll position
// survives from a previous mount (view-tab switch away and back), which
// is restored instead of snapping the reader back to the floor.
if (openState === 'open' && !openedRef.current) {
openedRef.current = true
const saved = chatScroll.read()
if (saved === null) {
toBottom(el)
} else {
el.scrollTop = saved.scrollTop
const row = anchorElement(local, saved.anchorKey)
if (row !== null) el.scrollTop += flowTop(row, el) - saved.anchorTop
observedTopRef.current = el.scrollTop
const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= FOLLOW_THRESHOLD + 1
atBottomRef.current = isAtBottom
setAtBottom(isAtBottom)
const normalized = isAtBottom ? null : scrollPosition(local, el)
if (isAtBottom) chatScroll.save(null)
else if (normalized !== null) chatScroll.save(normalized)
}
firstSeqRef.current = firstSeq
lastKeyRef.current = lastKey
lastSteeringIdRef.current = lastSteeringId
lastSubmissionIdRef.current = lastSubmissionId
followSigRef.current = followSig
return
}
// Prepend (head seq decreased): preserve the same settled row at the
// position established by the reader's latest scroll. This excludes
// unrelated tail/composer growth while the request was in flight.
if (anchorRef.current !== null && firstSeq !== null && firstSeqRef.current !== null && firstSeq < firstSeqRef.current) {
const anchor = anchorRef.current
anchorRef.current = null
const row = anchorElement(local, anchor.key)
if (row !== null) el.scrollTop += flowTop(row, el) - anchor.top
observedTopRef.current = el.scrollTop
// A jump chunk lands here: scroll to the target once its rows exist;
// until then keep holding the reader's row for the next chunk.
if (!realizePendingJump(local, el, false) && row !== null) {
anchorRef.current = { key: anchor.key, top: flowTop(row, el) }
}
firstSeqRef.current = firstSeq
/* v8 ignore next -- ?? arm: a prepend adds nodes, so the flow list here is never empty. */
lastKeyRef.current = lastKey
lastSteeringIdRef.current = lastSteeringId
lastSubmissionIdRef.current = lastSubmissionId
followSigRef.current = followSig
return
}
firstSeqRef.current = firstSeq
// Own words must be visible: a new trailing user node force-scrolls
// (send lives in the composer, so arrival is detected here, not armed there).
const appendedUser = lastKey !== lastKeyRef.current && lastNode?.kind === 'user'
const appendedSteering = lastSteeringId !== null && lastSteeringId !== lastSteeringIdRef.current
const appendedSubmission = lastSubmissionId !== null && lastSubmissionId !== lastSubmissionIdRef.current
const tipMoved = followSigRef.current !== followSig
lastKeyRef.current = lastKey
lastSteeringIdRef.current = lastSteeringId
lastSubmissionIdRef.current = lastSubmissionId
followSigRef.current = followSig
// Follow new flow content while pinned; do NOT re-pin on every render
// merely because atBottomRef is true (scroll threshold → setState → snap).
if (appendedUser || appendedSteering || appendedSubmission || (tipMoved && atBottomRef.current)) {
toBottom(el)
return
}
// A jump whose target committed outside the anchored-prepend path (for
// example after a mid-jump toBottom dropped the held anchor) lands here.
if (pendingJumpRef.current !== null) realizePendingJump(local, el, false)
const scroll = useChatScroll({
ready: openState === 'open',
order, firstSeq, lastKey, running, loadingOlder, hasMore, chatScroll, loadOlder, loadThrough,
lastIsUser: lastKey !== null && nodeStore.get(lastKey)?.kind === 'user',
steeringId: pendingSteering.at(-1)?.id ?? null,
submissionId: visibleSubmissions.at(-1)?.requestId ?? null,
loadedTurns: turnNavigationItems,
})
const onScrollRef = useRef(() => {})
onScrollRef.current = () => {
const local = listRef.current
/* v8 ignore next -- ref-null guard: the handler only fires while mounted. */
if (local === null) return
const el = scrollerOf(local)
// Only reader input may make raw scroll geometry change follow ownership:
// a delivered position that deviates from the observed-top ledger (every
// programmatic write records itself there synchronously). This covers
// wheel, touch, scrollbar, and keyboard alike without naming devices.
// Browser shrink-clamps land exactly on the floor min and delayed
// programmatic deliveries land on the ledger itself, so both preserve
// the current ownership state.
const floor = Math.max(0, el.scrollHeight - el.clientHeight)
const movedByReader = readerMovedScroll(el.scrollTop, floor, observedTopRef.current)
const isAtBottom = movedByReader
? floor - el.scrollTop <= FOLLOW_THRESHOLD + 1
: atBottomRef.current
if (!movedByReader && isAtBottom) {
toBottom(el)
return
}
atBottomRef.current = isAtBottom
setAtBottom(isAtBottom)
const position = isAtBottom ? null : scrollPosition(local, el)
if (isAtBottom) {
anchorRef.current = null
} else if (anchorRef.current !== null && position !== null) {
anchorRef.current = { key: position.anchorKey, top: position.anchorTop }
}
// Continuous save (unmount happens after ref detach, so saving there is
// too late); pinned-to-bottom clears so a remount keeps following.
if (isAtBottom) chatScroll.save(null)
else if (position !== null) chatScroll.save(position)
observedTopRef.current = el.scrollTop
scheduleActiveTurn()
}
// Non-reader pinned deliveries must settle before layout growth invalidates
// their floor. Reader movement stays pending even inside the follow threshold,
// so growth cannot erase small gestures before they accumulate off the floor.
useEffect(() => {
const local = listRef.current
/* v8 ignore next -- ref-null guard: effect runs after the list node commits. */
if (local === null) return
const el = scrollerOf(local)
let sampleTimer: number | undefined
const sample = (): void => {
if (!scrollSamplePendingRef.current) return
scrollSamplePendingRef.current = false
if (sampleTimer !== undefined) window.clearTimeout(sampleTimer)
sampleTimer = undefined
onScrollRef.current()
setScrollSampleTick(tick => tick + 1)
}
const onScroll = (): void => {
scrollSamplePendingRef.current = true
if (atBottomRef.current) {
const floor = Math.max(0, el.scrollHeight - el.clientHeight)
if (!readerMovedScroll(el.scrollTop, floor, observedTopRef.current)) {
sample()
return
}
}
sampleTimer ??= window.setTimeout(sample, SCROLL_SAMPLE_INTERVAL_MS)
}
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)
scrollSamplePendingRef.current = false
}
}, [])
// The ref starts null and is assigned every render, so the placeholder
// initializer a function initial value would need never exists.
const followRef = useRef<(() => void) | null>(null)
followRef.current = () => {
if (scrollSamplePendingRef.current) return
const local = listRef.current
if (local !== null && atBottomRef.current) {
const el = scrollerOf(local)
el.scrollTop = el.scrollHeight
observedTopRef.current = el.scrollTop
chatScroll.save(null)
}
}
// Streaming, tool disclosures, and other flow changes resize the column;
// the sticky composer resizes outside it. This observer owns ChatView's
// dynamic-height follow decisions and writes only while the reader is pinned.
useEffect(() => {
const column = columnRef.current
const local = listRef.current
if (column === null || local === null || typeof ResizeObserver === 'undefined') return
const scrollport = scrollerOf(local)
const composer = scrollport.querySelector<HTMLElement>('[data-composer-seat]')
// Flow-height changes (image loads, tool disclosures) move rows across the
// reading line without a scroll event, so the active mark resyncs here too.
const observer = new ResizeObserver(() => {
followRef.current?.()
activeTurnRef.current?.()
})
observer.observe(column)
if (composer !== null) observer.observe(composer)
return () => { observer.disconnect() }
}, [])
// A failed/empty page leaves the head unchanged. Once the request leaves
// its busy state there is no future prepend for the saved anchor to own.
useEffect(() => {
if (!loadingOlder) anchorRef.current = null
}, [loadingOlder])
// Jump settlement: every loadThrough completion bumps the tick after its
// last page's commit, and a plain pull's loadingOlder flip re-settles a
// jump it made wait. A still-pending jump is realized now, held while a
// plain load-earlier pull owns the pager (its completion retries below),
// repaged once per head movement, or landed on the nearest rendered Turn
// at or after the target (failure, exhausted history, or a Turn with no
// visible row).
useEffect(() => {
const pending = pendingJumpRef.current
const local = listRef.current
if (pending === null || local === null) return
const el = scrollerOf(local)
// The settling landing runs after the load-earlier button's unmount
// commit, so the target row cannot drift once the jump clears.
if (realizePendingJump(local, el, true)) return
const uncovered = firstSeq === null || firstSeq > pending.seq
if (uncovered && hasMore) {
// A plain pull owns the pager right now: hold the jump (busy stays)
// instead of degrading to a wrong landing.
if (loadingOlder) return
if (jumpRepageHeadRef.current !== firstSeq) {
jumpRepageHeadRef.current = firstSeq
const held = pagingAnchor(local, el)
if (held !== null && held.dataset.chatAnchorKey !== undefined) {
anchorRef.current = { key: held.dataset.chatAnchorKey, top: flowTop(held, el) }
}
void loadThrough(pending.seq).finally(() => { setJumpSettleTick(tick => tick + 1) })
return
}
}
for (const row of local.querySelectorAll<HTMLElement>('[data-chat-turn]:not([hidden])')) {
const turn = Number(row.dataset.chatTurn)
if (!Number.isSafeInteger(turn) || turn < pending.turn) continue
landOnRowRef.current(local, el, row, turn)
break
}
pendingJumpRef.current = null
setBusyJumpTurn(null)
// Snapshot values are read at settle time; the completion tick is the trigger.
}, [jumpSettleTick])
// A jump held while a plain pull owned the pager waits in the effect
// above; the pull's completion is its retry signal.
useEffect(() => {
if (!loadingOlder && pendingJumpRef.current !== null) setJumpSettleTick(tick => tick + 1)
}, [loadingOlder])
const loadOlderAnchored = (): void => {
const local = listRef.current
/* v8 ignore next -- ref-null guard: the paging button renders inside the list tree. */
if (local !== null) {
const el = scrollerOf(local)
const row = pagingAnchor(local, el)
if (row !== null && row.dataset.chatAnchorKey !== undefined) {
anchorRef.current = {
key: row.dataset.chatAnchorKey,
top: flowTop(row, el),
}
}
}
loadOlder()
}
// Identity feeds the memoized rail; a fresh closure per render would defeat it.
const navigateToTurn = useCallback((item: TurnRailItem): void => {
const local = listRef.current
if (local === null) return
const el = scrollerOf(local)
if (item.anchor.kind === 'unloaded') {
// Jumping into history is leaving the live tail: release bottom
// ownership on the click itself, or the pinned-scroll snap (a
// non-reader scroll delivery during the first prepend's compensation)
// would call toBottom and cancel the jump.
atBottomRef.current = false
setAtBottom(false)
// Hold the reader's place through the paging chunks; the layout effect
// lands on the target once its rows commit.
const held = pagingAnchor(local, el)
if (held !== null && held.dataset.chatAnchorKey !== undefined) {
anchorRef.current = { key: held.dataset.chatAnchorKey, top: flowTop(held, el) }
}
pendingJumpRef.current = { turn: item.turn, seq: item.anchor.seq }
jumpRepageHeadRef.current = null
jumpLandedRef.current = false
setBusyJumpTurn(item.turn)
void loadThrough(item.anchor.seq).finally(() => { setJumpSettleTick(tick => tick + 1) })
return
}
const row = anchorElement(local, item.anchor.key)
if (row === null) return
// A loaded-mark click supersedes any jump still landing.
pendingJumpRef.current = null
setBusyJumpTurn(current => current === null ? current : null)
landOnRowRef.current(local, el, row, item.turn)
// A pending older page still has to compensate the prepended height, so
// navigation moves that anchor to the new position instead of dropping it.
const landed = loadingOlder ? pagingAnchor(local, el) : null
anchorRef.current = landed === null || landed.dataset.chatAnchorKey === undefined
? null
: { key: landed.dataset.chatAnchorKey, top: flowTop(landed, el) }
}, [loadingOlder, loadThrough])
return (
<div className={css.root}>
<div ref={listRef} className={css.scroll}>
<TurnNavigator
items={railItems}
activeTurn={activeTurn}
busyTurn={busyJumpTurn}
onNavigate={navigateToTurn}
t={t}
/>
<div ref={columnRef} className={css.column} data-chat-flow="">
<div ref={scroll.listRef} className={css.scroll}>
{scroll.initialized && (
<TurnNavigator
items={railItems}
activeTurn={scroll.activeTurn}
busyTurn={scroll.busyTurn}
onNavigate={scroll.navigateToTurn}
t={t}
/>
)}
<div ref={scroll.columnRef} className={css.column} data-chat-flow="">
{openState === 'loading' && <div className={css.hint}>{t('chat.loadingHistory')}</div>}
{openState === 'error' && openError !== null && (
<div className={css.openError}>
@@ -776,7 +227,7 @@ export function ChatView({
)}
{hasMore && (
<div className={css.older}>
<button type="button" disabled={loadingOlder} onClick={loadOlderAnchored}>
<button type="button" disabled={loadingOlder} onClick={scroll.loadEarlier}>
{loadingOlder ? t('loading') : t('chat.loadOlder')}
</button>
</div>
@@ -825,17 +276,13 @@ export function ChatView({
/>
))}
</div>
{!atBottom && (
{!scroll.followingTail && (
<div className={css.toBottomSlot}>
<button
type="button"
className={css.toBottom}
aria-label={t('chat.toBottom')}
onClick={() => {
const local = listRef.current
/* v8 ignore next -- ref-null guard: the button only renders alongside the mounted list. */
if (local !== null) toBottom(scrollerOf(local))
}}
onClick={scroll.returnToBottom}
>
<IconChevronDownOutlineRegular />
</button>
@@ -29,22 +29,21 @@
/* Fixed-pitch marks never compress: a ladder taller than the band scrolls
inside this frame instead of pushing marks under the composer or above
the scrollport. */
height: min(
var(--turn-natural-height),
max-height: min(
max(0px, calc(var(--turn-rail-band) - 64px)),
420px
);
contain: layout;
cursor: pointer;
pointer-events: auto;
transform: translateY(-50%);
transition: height 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
/* The frame's inner scroller: no visible scrollbar, no scroll chaining into
the transcript, and gradient fades over the ends that can still scroll. */
.scroller {
position: absolute;
inset: 0;
position: relative;
max-height: inherit;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: none;
@@ -74,57 +73,43 @@
.marks {
position: relative;
height: var(--turn-natural-height);
}
.markPosition {
.mark {
position: absolute;
top: calc(var(--turn-natural-position) + var(--turn-rail-inset));
top: 0;
right: 0;
left: 0;
height: 10px;
transform: translateY(-50%);
transition: top 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
animation: dsh-turn-mark-enter 150ms ease-out;
}
/* The rail owns pointer input for the whole column, so a mark is a keyboard
destination that paints one tick — never a mouse target of its own. */
.mark {
position: absolute;
/* As wide as the longest tick, right-aligned with it: the focus ring below
then wraps the tick instead of the rail's full pointer column. */
inset: 0 0 0 auto;
width: 20px;
padding: 0;
border: 0;
border-radius: 8px;
background: transparent;
cursor: pointer;
pointer-events: none;
}
.mark::before {
position: absolute;
top: 50%;
right: 0;
width: 12px;
width: 20px;
height: 2px;
border-radius: 2px;
background: var(--dsw-alias-border-l4);
content: '';
transform: translateY(-50%);
transition: width 140ms ease, background-color 140ms ease;
transform: translateY(-50%) scaleX(0.6);
transform-origin: right center;
transition: transform 140ms ease, background-color 140ms ease;
}
/* Ordered before hover/active so those states keep their stronger tick. */
.markUnloaded::before {
width: 8px;
transform: translateY(-50%) scaleX(0.4);
opacity: 0.6;
}
.markPreview::before {
width: 18px;
transform: translateY(-50%) scaleX(0.9);
background: var(--dsw-alias-label-tertiary);
}
@@ -133,7 +118,7 @@
}
.markActive::before {
width: 20px;
transform: translateY(-50%) scaleX(1);
background: var(--dsw-alias-label-primary);
}
@@ -141,26 +126,29 @@
the rest of the rail never uses, and a hairline ring keeps it legible
against a busy transcript. */
.mark:focus-visible::before {
width: 20px;
transform: translateY(-50%) scaleX(1);
background: var(--dsw-alias-state-business-primary);
}
.mark:focus-visible {
outline: none;
}
.mark:focus-visible::after {
position: absolute;
inset: 0 0 0 auto;
width: 20px;
border-radius: inherit;
outline: 1px solid var(--dsw-alias-state-business-primary);
outline-offset: 2px;
content: '';
}
.preview {
position: absolute;
/* Centered on its mark. Mark positions live in the scrolled ladder, so the
frame-level preview subtracts the scroller's offset, then holds clear of
both frame ends. */
top: clamp(
0px,
calc(
var(--turn-natural-position) + var(--turn-rail-inset)
- var(--turn-scroll-top, 0px) - var(--turn-preview-height) / 2
),
calc(var(--turn-preview-center) - var(--turn-preview-height) / 2),
calc(100% - var(--turn-preview-height))
);
right: calc(100% + 10px);
@@ -198,11 +186,6 @@
-webkit-line-clamp: 3;
}
@keyframes dsh-turn-mark-enter {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes dsh-turn-preview-enter {
from { opacity: 0; transform: translateX(4px); }
to { opacity: 1; transform: translateX(0); }
@@ -223,7 +206,6 @@
@media (prefers-reduced-motion: reduce) {
.frame,
.scroller,
.markPosition,
.mark::before,
.markBusy::before,
.preview {
@@ -1,7 +1,9 @@
/** Fixed-pitch virtual turn rail with independent activation and scroll controls. */
import {
memo, useEffect, useId, useRef, useState,
type CSSProperties, type MouseEvent, type PointerEvent,
forwardRef, memo, useCallback, useEffect, useId, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState,
type CSSProperties, type ForwardedRef,
} from 'react'
import { defaultRangeExtractor, elementScroll, observeElementOffset, useVirtualizer, type Range } from '@tanstack/react-virtual'
import type { ChatViewSlotProps } from '../contract/slots.ts'
import type { TurnRailItem } from './turn-rail-items.ts'
import css from './TurnNavigator.module.css'
@@ -15,6 +17,14 @@ interface TurnNavigatorProps {
readonly t: ChatViewSlotProps['t']
}
/** Imperative controls for known turns; unknown turn numbers are ignored. */
export interface TurnNavigatorHandle {
/** @param turn - turn to activate through the navigation callback. */
activateTurn(turn: number): void
/** @param turn - turn to center in the rail without navigating the transcript. */
scrollToTurn(turn: number): void
}
/** Fixed pitch between neighbouring marks; overflow scrolls inside the frame. */
const TURN_SPACING_PX = 10
/** Rail padding above the first mark and below the last one, per end. */
@@ -22,180 +32,247 @@ const RAIL_INSET_PX = 6
/** Fade band the mask reserves at a scrollable end. */
const FADE_PX = 24
type TurnPositionStyle = CSSProperties & {
readonly '--turn-natural-position': string
function preferredScrollBehavior(): 'auto' | 'smooth' {
return typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches
? 'auto'
: 'smooth'
}
type TurnFrameStyle = CSSProperties & {
readonly '--turn-natural-height': string
readonly '--turn-rail-inset': string
readonly '--turn-scroll-top': string
interface TurnMarkProps extends Pick<TurnNavigatorProps, 'onNavigate' | 't'> {
readonly item: TurnRailItem
readonly index: number
readonly active: boolean
readonly busy: boolean
readonly previewId: string | undefined
readonly registerElement: (element: HTMLButtonElement | null) => void
readonly onPreview: (turn: number | null) => void
readonly onFocusChange: (turn: number | null) => void
}
function itemPosition(index: number): TurnPositionStyle {
return { '--turn-natural-position': `${String(index * TURN_SPACING_PX)}px` }
}
const TurnMark = memo(function TurnMark({
item, index, active, busy, previewId, registerElement, onNavigate, onPreview, onFocusChange, t,
}: TurnMarkProps) {
const classes = [css.mark]
if (item.anchor.kind === 'unloaded') classes.push(css.markUnloaded)
if (active) classes.push(css.markActive)
else if (previewId !== undefined) classes.push(css.markPreview)
if (busy) classes.push(css.markBusy)
return (
<button
ref={registerElement}
data-index={index}
type="button"
className={classes.join(' ')}
aria-label={t(
item.anchor.kind === 'loaded' ? 'chat.turnNavigation.jump' : 'chat.turnNavigation.jumpLoad',
{ turn: item.turn },
)}
aria-current={active ? 'true' : undefined}
aria-busy={busy ? 'true' : undefined}
aria-describedby={previewId}
onPointerMove={() => { onPreview(item.turn) }}
onClick={() => { onNavigate(item) }}
onFocus={() => { onFocusChange(item.turn) }}
onBlur={() => { onFocusChange(null) }}
/>
)
})
function frameStyle(count: number, scrollTop: number): TurnFrameStyle {
return {
'--turn-natural-height': `${String((count - 1) * TURN_SPACING_PX + 2 * RAIL_INSET_PX)}px`,
'--turn-rail-inset': `${String(RAIL_INSET_PX)}px`,
'--turn-scroll-top': `${String(scrollTop)}px`,
}
}
function itemAtPointer(
items: readonly TurnRailItem[],
frame: HTMLElement,
scrollTop: number,
clientY: number,
): TurnRailItem | undefined {
const rect = frame.getBoundingClientRect()
const offset = clientY - rect.top + scrollTop - RAIL_INSET_PX
const index = Math.max(0, Math.min(items.length - 1, Math.round(offset / TURN_SPACING_PX)))
return items[index]
}
/** Scroll state the mask fades and follow logic read together. */
interface RailScrollState {
readonly top: number
readonly canScrollUp: boolean
readonly canScrollDown: boolean
}
const RAIL_AT_REST: RailScrollState = { top: 0, canScrollUp: false, canScrollDown: false }
function railScrollState(scroller: HTMLElement): RailScrollState {
const top = scroller.scrollTop
return {
top,
canScrollUp: top > 1,
canScrollDown: top < scroller.scrollHeight - scroller.clientHeight - 1,
}
}
function sameRailScrollState(left: RailScrollState, right: RailScrollState): boolean {
return left.top === right.top
&& left.canScrollUp === right.canScrollUp
&& left.canScrollDown === right.canScrollDown
}
function TurnNavigatorRail({ items, activeTurn, busyTurn, onNavigate, t }: TurnNavigatorProps) {
function TurnNavigatorRail(
{ items, activeTurn, busyTurn, onNavigate, t }: TurnNavigatorProps,
ref: ForwardedRef<TurnNavigatorHandle>,
) {
const [previewTurn, setPreviewTurn] = useState<number | null>(null)
const [scrollState, setScrollState] = useState<RailScrollState>(RAIL_AT_REST)
const [focusedTurn, setFocusedTurn] = useState<number | null>(null)
const scrollerRef = useRef<HTMLDivElement | null>(null)
const initialization = useRef({
placed: false,
index: 0,
follow: null as { index: number; count: number; height: number } | null,
publishOffset: null as ((offset: number, scrolling: boolean) => void) | null,
})
/** While the pointer works the rail, follow must not move it under the hand. */
const pointerInsideRef = useRef(false)
const previewId = useId()
const syncScrollState = (): void => {
const scroller = scrollerRef.current
if (scroller === null) return
const next = railScrollState(scroller)
setScrollState(current => sameRailScrollState(current, next) ? current : next)
}
// Frame resizes (band/composer changes) move the overflow edges without a
// scroll event; item count changes move the content height the same way.
useEffect(() => {
const scroller = scrollerRef.current
if (scroller === null || typeof ResizeObserver === 'undefined') return
const observer = new ResizeObserver(syncScrollState)
observer.observe(scroller)
return () => { observer.disconnect() }
const turnIndexes = useMemo(() => {
const indexes = new Map<number, number>()
items.forEach((item, index) => { indexes.set(item.turn, index) })
return indexes
}, [items])
const activeIndex = activeTurn === null ? undefined : turnIndexes.get(activeTurn)
useLayoutEffect(() => { initialization.current.index = activeIndex ?? 0 }, [activeIndex])
const focusedIndex = focusedTurn === null ? undefined : turnIndexes.get(focusedTurn)
const previewIndex = previewTurn === null ? undefined : turnIndexes.get(previewTurn)
const onFocusChange = useCallback((turn: number | null) => {
setFocusedTurn(turn)
setPreviewTurn(turn)
}, [])
useEffect(syncScrollState, [items.length])
const virtualizer = useVirtualizer<HTMLDivElement, HTMLButtonElement>({
count: items.length,
enabled: items.length >= 2,
directDomUpdates: true,
directDomUpdatesMode: 'transform',
useScrollendEvent: true,
getScrollElement: useCallback(() => scrollerRef.current, []),
getItemKey: useCallback((index: number) => items[index]?.turn ?? index, [items]),
estimateSize: () => TURN_SPACING_PX,
measureElement: () => TURN_SPACING_PX,
initialRect: { width: 0, height: 0 },
initialOffset: 0,
scrollToFn: (offset, options, instance) => {
if (initialization.current.placed) elementScroll(offset, options, instance)
},
observeElementOffset: (instance, notify) => {
initialization.current.publishOffset = notify
const dispose = observeElementOffset(instance, notify)
return () => {
dispose?.()
initialization.current.placed = false
initialization.current.follow = null
initialization.current.publishOffset = null
}
},
observeElementRect: (instance, notify) => {
const element = instance.scrollElement
const Observer = instance.targetWindow?.ResizeObserver
if (element === null || Observer === undefined) return
const observer = new Observer(([entry]) => {
if (entry === undefined) return
const box = entry.borderBoxSize[0]
const rect = {
width: Math.round(box?.inlineSize ?? entry.contentRect.width),
height: Math.round(box?.blockSize ?? entry.contentRect.height),
}
const initial = initialization.current
if (!initial.placed && rect.height > 0) {
const max = Math.max(0, instance.getTotalSize() - rect.height)
const center = initial.index * TURN_SPACING_PX + RAIL_INSET_PX
const target = Math.max(0, Math.min(max, center - rect.height / 2))
initial.placed = true
initial.follow = { index: initial.index, count: instance.options.count, height: rect.height }
element.scrollTop = target
initial.publishOffset?.(target, false)
}
notify(rect)
})
observer.observe(element, { box: 'border-box' })
return () => { observer.disconnect() }
},
paddingStart: RAIL_INSET_PX - TURN_SPACING_PX / 2,
paddingEnd: RAIL_INSET_PX - TURN_SPACING_PX / 2,
scrollPaddingStart: FADE_PX,
scrollPaddingEnd: FADE_PX,
overscan: 3,
rangeExtractor: useCallback((range: Range) => {
const indexes = defaultRangeExtractor(range)
if (focusedIndex !== undefined) {
const last = Math.min(range.count - 1, focusedIndex + 1)
for (let index = Math.max(0, focusedIndex - 1); index <= last; index++) {
if (!indexes.includes(index)) indexes.push(index)
}
indexes.sort((left, right) => left - right)
}
return indexes
}, [focusedIndex]),
})
const scrollTop = virtualizer.scrollOffset ?? 0
const viewHeight = virtualizer.scrollRect?.height ?? 0
const virtualItems = virtualizer.getVirtualItems()
// Keep the active mark visible: centre it whenever it leaves the scrollport,
// unless the reader's pointer is working the rail.
useEffect(() => {
const scroller = scrollerRef.current
const index = items.findIndex(item => item.turn === activeTurn)
if (scroller === null || index < 0 || pointerInsideRef.current) return
const markTop = index * TURN_SPACING_PX + RAIL_INSET_PX
const viewTop = scroller.scrollTop
const viewHeight = scroller.clientHeight
if (viewHeight <= 0 || (markTop >= viewTop + FADE_PX && markTop <= viewTop + viewHeight - FADE_PX)) return
const target = Math.max(0, markTop - viewHeight / 2)
const reduced = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches
if (typeof scroller.scrollTo === 'function') {
scroller.scrollTo({ top: target, behavior: reduced ? 'auto' : 'smooth' })
} else {
scroller.scrollTop = target
const scrollToIndex = useCallback((
index: number, reveal: 'if-needed' | 'always', behavior: 'auto' | 'smooth' | 'instant' = preferredScrollBehavior(),
): void => {
const item = virtualizer.measurementsCache[index]
const height = virtualizer.scrollRect?.height ?? 0
if (item === undefined || height <= 0) return
const current = virtualizer.scrollOffset ?? 0
const center = item.start + item.size / 2
if (reveal === 'if-needed') {
const { scrollPaddingStart, scrollPaddingEnd } = virtualizer.options
if (center >= current + scrollPaddingStart && center <= current + height - scrollPaddingEnd) return
}
syncScrollState()
}, [activeTurn, items])
const target = center - height / 2
const max = Math.max(0, virtualizer.getTotalSize() - height)
const delta = Math.max(0, Math.min(max, target)) - current
if (delta !== 0) virtualizer.scrollBy(delta, { behavior })
}, [virtualizer])
useImperativeHandle(ref, (): TurnNavigatorHandle => ({
activateTurn(turn) {
const index = turnIndexes.get(turn)
const item = index === undefined ? undefined : items[index]
if (item !== undefined) onNavigate(item)
},
scrollToTurn(turn) {
const index = turnIndexes.get(turn)
if (index !== undefined) scrollToIndex(index, 'always')
},
}), [items, turnIndexes, onNavigate, scrollToIndex])
useEffect(() => {
if (viewHeight <= 0) {
initialization.current.follow = null
return
}
if (activeIndex === undefined || pointerInsideRef.current) return
const previous = initialization.current.follow
if (previous?.index === activeIndex && previous.count === items.length && previous.height === viewHeight) return
initialization.current.follow = { index: activeIndex, count: items.length, height: viewHeight }
const behavior = previous?.count === items.length && previous.height === viewHeight
? preferredScrollBehavior()
: 'instant'
scrollToIndex(activeIndex, 'if-needed', behavior)
}, [activeIndex, items.length, viewHeight, scrollToIndex])
if (items.length < 2) return null
const previewIndex = items.findIndex(item => item.turn === previewTurn)
const preview = previewIndex < 0 ? undefined : items[previewIndex]
const previewPosition = previewIndex < 0 ? undefined : itemPosition(previewIndex)
const previewAtPointer = (event: PointerEvent<HTMLElement>): void => {
const scrollTop = scrollerRef.current?.scrollTop ?? 0
setPreviewTurn(itemAtPointer(items, event.currentTarget, scrollTop, event.clientY)?.turn ?? null)
}
const navigateAtPointer = (event: MouseEvent<HTMLElement>): void => {
const scrollTop = scrollerRef.current?.scrollTop ?? 0
const item = itemAtPointer(items, event.currentTarget, scrollTop, event.clientY)
if (item !== undefined) onNavigate(item)
}
const preview = previewIndex === undefined ? undefined : items[previewIndex]
const previewPosition = virtualItems.find(item => item.index === previewIndex)
const fadeClasses = [css.scroller]
if (scrollState.canScrollUp) fadeClasses.push(css.fadeTop)
if (scrollState.canScrollDown) fadeClasses.push(css.fadeBottom)
if (scrollTop > 1) fadeClasses.push(css.fadeTop)
if (scrollTop < virtualizer.getTotalSize() - viewHeight - 1) fadeClasses.push(css.fadeBottom)
return (
<div className={css.slot}>
<nav
className={css.frame}
style={frameStyle(items.length, scrollState.top)}
aria-label={t('chat.turnNavigation.label')}
onClick={navigateAtPointer}
onPointerMove={previewAtPointer}
onPointerEnter={() => { pointerInsideRef.current = true }}
onPointerLeave={() => {
pointerInsideRef.current = false
setPreviewTurn(null)
}}
>
<div
ref={scrollerRef}
className={fadeClasses.join(' ')}
onScroll={() => { syncScrollState() }}
>
<div className={css.marks}>
{items.map((item, index) => {
const active = item.turn === activeTurn
const showingPreview = item.turn === previewTurn
const classes = [css.mark]
if (item.anchor.kind === 'unloaded') classes.push(css.markUnloaded)
if (active) classes.push(css.markActive)
else if (showingPreview) classes.push(css.markPreview)
if (item.turn === busyTurn) classes.push(css.markBusy)
<div ref={scrollerRef} className={fadeClasses.join(' ')}>
<div ref={virtualizer.containerRef} className={css.marks}>
{virtualItems.map(({ index, key }) => {
const item = items[index]
if (item === undefined) return null
return (
<div key={item.turn} className={css.markPosition} style={itemPosition(index)}>
<button
type="button"
className={classes.join(' ')}
aria-label={t(
item.anchor.kind === 'loaded' ? 'chat.turnNavigation.jump' : 'chat.turnNavigation.jumpLoad',
{ turn: item.turn },
)}
aria-current={active ? 'true' : undefined}
aria-busy={item.turn === busyTurn ? 'true' : undefined}
aria-describedby={showingPreview ? previewId : undefined}
onClick={(event) => {
event.stopPropagation()
onNavigate(item)
}}
onFocus={() => { setPreviewTurn(item.turn) }}
onBlur={() => { setPreviewTurn(null) }}
/>
</div>
<TurnMark
key={key}
item={item}
index={index}
active={item.turn === activeTurn}
busy={item.turn === busyTurn}
previewId={item.turn === previewTurn ? previewId : undefined}
registerElement={virtualizer.measureElement}
onNavigate={onNavigate}
onPreview={setPreviewTurn}
onFocusChange={onFocusChange}
t={t}
/>
)
})}
</div>
</div>
{preview !== undefined && previewPosition !== undefined && (
<div id={previewId} role="tooltip" className={css.preview} style={previewPosition}>
<div
id={previewId}
role="tooltip"
className={css.preview}
style={{
'--turn-preview-center': `${String(previewPosition.start + previewPosition.size / 2 - scrollTop)}px`,
} as CSSProperties}
>
<div className={css.previewPrompt}>
{preview.prompt || t('chat.turnNavigation.turn', { turn: preview.turn })}
</div>
@@ -211,12 +288,8 @@ function TurnNavigatorRail({ items, activeTurn, busyTurn, onNavigate, t }: TurnN
* Fixed-pitch rail of every known Turn — loaded marks scroll, unloaded marks
* page history in first — with hover and focus previews. Overflow scrolls
* inside the frame, gradient fades marking each scrollable end, and the
* active mark keeps itself in view while the pointer is elsewhere.
*
* Memoized because it renders two host elements per Turn while the
* enclosing view re-renders on every streaming delta: without the guard a long
* session rebuilds hundreds of marks per commit for a rail that only changes
* when a Turn is added, removed, or becomes active. Its props must therefore
* stay referentially stable across those commits.
* active mark centers only outside the fade-free band while the pointer is
* elsewhere. Previews follow pointer movement or focus, not scrolling under
* a stationary pointer.
*/
export const TurnNavigator = memo(TurnNavigatorRail)
export const TurnNavigator = memo(forwardRef(TurnNavigatorRail))
@@ -0,0 +1,209 @@
/** Turn jumps and history-prepend anchoring, independent of DOM measurement. */
import { useLayoutEffect, useState } from 'react'
import type { SessionSeq } from '@deepseek-ai/dsh-session/types'
import type { ChatNode } from '../contract/chat-nodes.ts'
import type { ChatScrollPosition, ChatViewSlotProps } from '../contract/slots.ts'
import type { TurnRailItem } from './turn-rail-items.ts'
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'> {
readonly firstSeq: ChatNode['anchorSeq'] | null
readonly hasMore: boolean
readonly loadingOlder: boolean
}
interface TurnJump {
readonly turn: number
readonly seq: SessionSeq
phase: 'loading' | 'settled'
landing: 'pending' | 'landed' | 'interrupted'
repageHead: ChatNavigationInput['firstSeq']
}
/** Owns one replaceable turn jump and the anchor retained while history loads. */
export class ChatNavigation {
private jump: TurnJump | null = null
private anchor: ChatScrollPosition | null = null
private committed: Pick<ChatNavigationInput, 'firstSeq' | 'loadingOlder'>
private settleFrame: number | null = null
constructor(
private readonly viewport: ChatViewport,
private readonly reading: ChatReading,
private input: ChatNavigationInput,
private readonly onBusyTurn: (turn: number | null) => void,
) {
this.committed = input
}
/**
* Adopt committed history availability without starting a request.
* @param input - history state from the latest committed render.
*/
setInput(input: ChatNavigationInput): void { this.input = input }
/** Cancel navigation and establish the current history window as the baseline. */
reset(): void {
this.cancel()
this.committed = this.input
}
/** Cancel local callbacks; late history completions cannot revive a task. */
dispose(): void {
this.clearTask()
}
/** Release the jump, paging anchor, and busy indicator without cancelling shared history I/O. */
cancel(): void {
this.clearTask()
this.onBusyTurn(null)
}
private clearTask(): void {
this.cancelFrame()
this.jump = null
this.anchor = null
}
/**
* Replace the current jump with an explicit turn selection.
* @param item - loaded anchor or unloaded turn to fetch before landing.
*/
readonly navigateToTurn = (item: TurnRailItem): void => {
if (item.anchor.kind === 'loaded') {
const landing = this.viewport.scrollToTurn(item.turn)
if (landing === null) return
this.cancel()
this.reading.acceptNavigation(landing)
this.anchor = this.input.loadingOlder ? landing.position : null
return
}
this.cancelFrame()
this.anchor = this.viewport.capturePosition()
this.reading.pauseFollowing()
const jump: TurnJump = {
turn: item.turn,
seq: item.anchor.seq,
phase: 'loading',
landing: 'pending',
repageHead: null,
}
this.jump = jump
this.onBusyTurn(jump.turn)
this.request(jump)
}
/** Request one older page while retaining the current semantic position. */
readonly loadEarlier = (): void => {
this.anchor = this.viewport.capturePosition()
this.input.loadOlder()
}
/**
* Preserve reader ownership across pending history work.
* @param sample - settled reader movement that can update or interrupt an anchor.
*/
readerSampled(sample: ReadingSample): void {
if (sample.movedByReader && this.jump?.landing === 'landed') this.jump.landing = 'interrupted'
if (sample.followingTail) this.anchor = null
else if (this.anchor !== null && sample.position !== null) this.anchor = sample.position
}
/**
* Reconcile a newly committed history head with any retained anchor.
* @returns whether a prepended window consumed the current position-preservation operation.
*/
contentCommitted(): boolean {
const previous = this.committed
this.committed = this.input
if (this.anchor !== null && this.input.firstSeq !== null && previous.firstSeq !== null
&& this.input.firstSeq < previous.firstSeq) {
if (this.landJump(false)) return true
const landing = this.viewport.preserve(this.anchor)
this.anchor = this.jump === null ? null : landing?.position ?? null
if (landing !== null) this.reading.preservePosition(landing)
return true
}
if (previous.loadingOlder && !this.input.loadingOlder && this.jump === null) this.anchor = null
return false
}
/** Land, retry, or complete the current jump against the committed window. */
reconcile(): void {
const jump = this.jump
if (jump === null || this.reading.pending) return
if (jump.phase === 'loading') {
if (jump.landing === 'pending') this.landJump(false)
return
}
if (this.input.loadingOlder) return
if (this.landJump(true)) return
const uncovered = this.input.firstSeq === null || this.input.firstSeq > jump.seq
if (uncovered && this.input.hasMore && jump.repageHead !== this.input.firstSeq) {
jump.repageHead = this.input.firstSeq
this.anchor = this.viewport.capturePosition()
this.request(jump)
return
}
const fallback = this.viewport.scrollToTurnAtOrAfter(jump.turn)
this.cancel()
if (fallback !== null) this.reading.acceptNavigation(fallback)
}
private landJump(settle: boolean): boolean {
const jump = this.jump
if (jump === null) return false
if (jump.landing === 'interrupted') {
if (settle) { this.cancel(); return true }
return false
}
const landing = this.viewport.scrollToTurn(jump.turn)
if (landing === null) return false
this.reading.acceptNavigation(landing)
if (settle) this.cancel()
else {
this.anchor = landing.position
jump.landing = 'landed'
}
return true
}
private request(jump: TurnJump): void {
jump.phase = 'loading'
const settled = (): void => {
if (this.jump !== jump) return
jump.phase = 'settled'
this.cancelFrame()
if (typeof requestAnimationFrame !== 'function') this.reconcile()
else this.settleFrame = requestAnimationFrame(() => {
this.settleFrame = null
if (this.jump === jump) this.reconcile()
})
}
void this.input.loadThrough(jump.seq).then(settled, settled)
}
private cancelFrame(): void {
if (this.settleFrame !== null && typeof cancelAnimationFrame === 'function') cancelAnimationFrame(this.settleFrame)
this.settleFrame = null
}
}
/**
* Retain one navigation owner for the component's lifetime.
* @param viewport - turn-aware DOM operations.
* @param reading - reading and follow policy receiving navigation landings.
* @param input - committed history state and load operations.
* @returns the navigation owner and its visible busy turn.
*/
export function useChatNavigation(
viewport: ChatViewport, reading: ChatReading, input: ChatNavigationInput,
): { navigation: ChatNavigation; busyTurn: number | null } {
const [busyTurn, setBusyTurn] = useState<number | null>(null)
const [navigation] = useState(() => new ChatNavigation(viewport, reading, input, setBusyTurn))
useLayoutEffect(() => { navigation.setInput(input) }, [navigation, input])
useLayoutEffect(() => () => { navigation.dispose() }, [navigation])
return { navigation, busyTurn }
}
@@ -0,0 +1,228 @@
/** Follow-tail ownership, saved-position restoration, and sampled reader movement. */
import { useLayoutEffect, useState } from 'react'
import type { ChatScrollPosition, ChatViewSlotProps } from '../contract/slots.ts'
import type { ChatViewport, ViewportLanding, ViewportMetrics, ViewportScroll } from './use-chat-viewport.ts'
const FOLLOW_THRESHOLD = 24
const SCROLL_SAMPLE_INTERVAL_MS = 500
/** Reading facts that affect Chat chrome and initial rail placement. */
export interface ChatReadingState {
readonly initialized: boolean
readonly followingTail: boolean
readonly activeTurn: number | null
}
/** Settled reader position delivered to history-navigation policy. */
export interface ReadingSample {
readonly position: ChatScrollPosition | null
readonly movedByReader: boolean
readonly followingTail: boolean
}
type PositionStore = ChatViewSlotProps['chatScroll']
/** Owns reading policy and its cancellable sampling work, without DOM access. */
export class ChatReading {
private sampleTimer: number | null = null
private probeFrame: number | null = null
private sampled: ((sample: ReadingSample) => void) | null = null
constructor(
private readonly viewport: ChatViewport,
private store: PositionStore,
private state: ChatReadingState,
private readonly onChange: (state: ChatReadingState) => void,
) {}
/**
* Expose pending reader ownership to navigation and resize handlers.
* @returns whether reader input still awaits interval or scrollend sampling.
*/
get pending(): boolean { return this.sampleTimer !== null }
/**
* Expose the active follow policy.
* @returns whether content growth retains bottom-follow ownership.
*/
get followingTail(): boolean { return this.state.followingTail }
/**
* Adopt the committed Session's scroll memory.
* @param store - scroll memory for the current Session.
*/
setStore(store: PositionStore): void { this.store = store }
/**
* Connect history policy to settled reading observations.
* @param sampled - receives settled reader positions.
* @returns a disposer that disconnects only this listener.
*/
connect(sampled: (sample: ReadingSample) => void): () => void {
this.sampled = sampled
return () => { if (this.sampled === sampled) this.sampled = null }
}
/** Cancel timers and animation frames and detach the sample listener. */
dispose(): void {
this.cancelPending()
this.sampled = null
}
/** Release bottom follow and pending sampling for an explicit navigation. */
pauseFollowing(): void {
this.cancelPending()
this.publish({ ...this.state, followingTail: false })
}
/** Land at the current floor and clear saved reader position. */
followTail(): void {
const landing = this.viewport.scrollToBottom()
if (landing === null) return
this.cancelPending()
this.commit(landing, true, this.viewport.latestTurn)
}
/** Restore the Session's semantic position, or follow the tail when none is saved. */
restore(): void {
const saved = this.store.read()
if (saved === null) { this.followTail(); return }
const landing = this.viewport.restore(saved)
if (landing === null) return
this.cancelPending()
const following = this.nearBottom(landing.metrics)
this.commit(landing, following, following ? this.viewport.latestTurn : this.state.activeTurn, following)
if (!this.state.followingTail && landing.position === null) {
const position = this.viewport.capturePosition()
if (position !== null) this.store.save(position)
}
this.refreshActiveTurn()
}
/**
* Adopt a known landing without rediscovering its anchor.
* @param landing - measured navigation result that replaces pending reader input.
*/
acceptNavigation(landing: ViewportLanding): void {
this.cancelPending()
const following = this.nearBottom(landing.metrics)
this.commit(landing, following, landing.turn ?? (following ? this.viewport.latestTurn : this.state.activeTurn))
}
/**
* Retain reading policy while history changes the anchor's geometry.
* @param landing - compensated position that retains the current reading policy.
*/
preservePosition(landing: ViewportLanding): void {
this.cancelPending()
this.commit(landing, this.state.followingTail, this.state.activeTurn)
}
/**
* Handle pinned layout movement immediately and defer genuine reader sampling.
* @param scroll - attributed scroll delivery; reader movement remains pending until sampled.
*/
readonly onScroll = (scroll: ViewportScroll): void => {
if (this.state.followingTail && !scroll.movedByReader) {
this.followTail()
this.sampled?.({ position: null, movedByReader: false, followingTail: true })
return
}
this.sampleTimer ??= window.setTimeout(this.flushSample, SCROLL_SAMPLE_INTERVAL_MS)
}
/** Settle pending reader movement at the browser's scrollend. */
readonly onScrollEnd = (): void => { this.flushSample() }
/** Reconcile a layout change without overriding unsampled reader input. */
onResize(): void {
if (this.pending) return
if (this.state.followingTail) this.followTail()
else this.refreshActiveTurn()
}
/** Resolve the active turn from tail ownership or a coalesced reading-line probe. */
refreshActiveTurn(): void {
if (this.pending) return
if (this.state.followingTail) {
this.publish({ ...this.state, initialized: true, activeTurn: this.viewport.latestTurn })
return
}
if (this.probeFrame !== null) return
if (typeof requestAnimationFrame !== 'function') this.probe()
else this.probeFrame = requestAnimationFrame(this.probe)
}
private nearBottom(metrics: ViewportMetrics): boolean {
return metrics.floor - metrics.top <= FOLLOW_THRESHOLD + 1
}
private commit(
landing: ViewportLanding, followingTail: boolean, activeTurn: number | null, initialized = true,
): void {
if (followingTail) this.store.save(null)
else if (landing.position !== null) this.store.save(landing.position)
this.publish({ initialized, followingTail, activeTurn })
}
private publish(state: ChatReadingState): void {
if (state.initialized === this.state.initialized && state.followingTail === this.state.followingTail
&& state.activeTurn === this.state.activeTurn) return
this.state = state
this.onChange(state)
}
private cancelPending(): void {
if (this.sampleTimer !== null) window.clearTimeout(this.sampleTimer)
if (this.probeFrame !== null && typeof cancelAnimationFrame === 'function') cancelAnimationFrame(this.probeFrame)
this.sampleTimer = null
this.probeFrame = null
}
private readonly probe = (): void => {
this.probeFrame = null
if (this.pending) return
const scroll = this.viewport.readScroll()
if (scroll === null) return
const activeTurn = this.nearBottom(scroll.metrics) ? this.viewport.latestTurn : this.viewport.readVisibleTurn(scroll.metrics)
this.publish({ ...this.state, initialized: true, activeTurn })
}
private readonly flushSample = (): void => {
if (!this.pending) return
this.cancelPending()
const scroll = this.viewport.readScroll()
if (scroll === null) return
const followingTail = scroll.movedByReader ? this.nearBottom(scroll.metrics) : this.state.followingTail
let position: ChatScrollPosition | null = null
if (!scroll.movedByReader && followingTail) this.followTail()
else {
position = followingTail ? null : this.viewport.capturePosition()
this.viewport.acknowledge(scroll.metrics)
if (followingTail || position !== null) this.store.save(position)
const activeTurn = this.nearBottom(scroll.metrics) ? this.viewport.latestTurn : this.viewport.readVisibleTurn(scroll.metrics)
this.publish({ initialized: true, followingTail, activeTurn })
}
this.sampled?.({ position, movedByReader: scroll.movedByReader, followingTail })
}
}
/**
* Retain reading policy and expose only changes in visible reading state.
* @param viewport - turn-aware DOM operations.
* @param store - Session-owned semantic scroll memory.
* @param initialTurn - latest loaded turn before the first landing.
* @returns the reading owner and its React-visible state.
*/
export function useChatReading(
viewport: ChatViewport, store: PositionStore, initialTurn: number | null,
): { reading: ChatReading; state: ChatReadingState } {
const [state, setState] = useState<ChatReadingState>(() => ({
initialized: false,
followingTail: store.read() === null,
activeTurn: initialTurn,
}))
const [reading] = useState(() => new ChatReading(viewport, store, state, setState))
useLayoutEffect(() => { reading.setStore(store) }, [reading, store])
useLayoutEffect(() => () => { reading.dispose() }, [reading])
return { reading, state }
}
@@ -0,0 +1,127 @@
/** Composes viewport operations, reading policy, and history navigation for Chat. */
import { useCallback, useLayoutEffect, useMemo, useRef, type RefObject } from 'react'
import type { ChatSnapshot } from '../contract/snapshot.ts'
import type { ChatViewSlotProps } from '../contract/slots.ts'
import { useChatNavigation, type ChatNavigation, type ChatNavigationInput } from './use-chat-navigation.ts'
import { useChatReading, type ChatReadingState } from './use-chat-reading.ts'
import { useChatViewport } from './use-chat-viewport.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
readonly steeringId: string | null
readonly submissionId: string | null
readonly running: boolean
readonly loadedTurns: ReturnType<ChatSnapshot['navigation']['items']>
}
interface ChatScrollState extends ChatReadingState {
readonly listRef: RefObject<HTMLDivElement>
readonly columnRef: RefObject<HTMLDivElement>
readonly busyTurn: number | null
readonly navigateToTurn: ChatNavigation['navigateToTurn']
readonly loadEarlier: ChatNavigation['loadEarlier']
readonly returnToBottom: () => void
}
/**
* Coordinate scroll policy after Chat content commits.
* New submitted input supersedes pending reader sampling.
* @param input - current Chat content, scroll memory, and history operations.
* @returns element refs, visible reading state, and navigation callbacks.
*/
export function useChatScroll(input: ChatScrollInput): ChatScrollState {
const {
ready, order, firstSeq, lastKey, lastIsUser, steeringId, submissionId, running,
loadedTurns, chatScroll, hasMore, loadingOlder, loadOlder, loadThrough,
} = input
const { viewport, listRef, columnRef } = useChatViewport()
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)
const content = useRef<{ input: ChatScrollInput; applied: ChatScrollInput | null; opened: boolean }>({
input, applied: null, opened: false,
})
const processContent = useCallback(() => {
const current = content.current.input
const previous = content.current.applied
const ownInput = (current.lastIsUser && current.lastKey !== previous?.lastKey)
|| (current.steeringId !== null && current.steeringId !== previous?.steeringId)
|| (current.submissionId !== null && current.submissionId !== previous?.submissionId)
if (reading.pending && !ownInput) return
content.current.applied = current
if (current.ready && !content.current.opened) {
content.current.opened = true
navigation.reset()
reading.restore()
return
}
if (navigation.contentCommitted()) {
navigation.reconcile()
return
}
const tipChanged = previous === null || current.ready !== previous.ready
|| 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
if (ownInput || (tipChanged && reading.followingTail)) {
navigation.cancel()
reading.followTail()
} else navigation.reconcile()
}, [reading, navigation])
useLayoutEffect(() => {
const disconnectViewport = viewport.connect({
scroll: reading.onScroll,
scrollEnd: reading.onScrollEnd,
resize: () => {
reading.onResize()
navigation.reconcile()
},
})
const disconnectReading = reading.connect((sample) => {
navigation.readerSampled(sample)
processContent()
})
return () => {
disconnectViewport()
disconnectReading()
content.current.opened = false
content.current.applied = null
}
}, [viewport, reading, navigation, processContent])
useLayoutEffect(() => {
const previous = content.current.input
content.current.input = {
ready, order, lastKey, lastIsUser, steeringId, submissionId, running, loadedTurns, chatScroll, ...navigationInput,
}
viewport.updateTurns(loadedTurns)
const layoutChanged = previous.order !== order || previous.ready !== ready
if (layoutChanged) viewport.invalidate()
processContent()
if (layoutChanged) reading.refreshActiveTurn()
}, [
viewport, reading, processContent, navigationInput, ready, order, lastKey, lastIsUser,
steeringId, submissionId, running, loadedTurns, chatScroll,
])
const returnToBottom = useCallback(() => {
navigation.cancel()
reading.followTail()
}, [navigation, reading])
return {
listRef, columnRef, ...state, busyTurn,
navigateToTurn: navigation.navigateToTurn,
loadEarlier: navigation.loadEarlier,
returnToBottom,
}
}
@@ -0,0 +1,346 @@
/** Turn-aware DOM scrolling and geometry, without history-loading or follow policy. */
import { useLayoutEffect, useRef, useState, type RefObject } from 'react'
import type { ChatScrollPosition } from '../contract/slots.ts'
import type { ChatSnapshot } from '../contract/snapshot.ts'
/** Scroll position, maximum top, and viewport height from one geometry read. */
export interface ViewportMetrics {
readonly top: number
readonly floor: number
readonly height: number
}
/** Scroll geometry attributed against the last acknowledged position. */
export interface ViewportScroll {
readonly metrics: ViewportMetrics
readonly movedByReader: boolean
}
/** Actual clamped scroll result, including any known semantic anchor and turn. */
export interface ViewportLanding {
readonly metrics: ViewportMetrics
readonly position: ChatScrollPosition | null
readonly turn: number | null
}
interface ViewportEvents {
scroll: (event: ViewportScroll) => void
scrollEnd: () => void
resize: () => void
}
interface ViewportElements {
readonly list: HTMLElement
readonly scroller: HTMLElement
readonly composer: HTMLElement | null
}
/** Owns one Chat scrollport's DOM operations, event listeners, and size observer. */
export class ChatViewport {
private elements: ViewportElements | null = null
private observer: ResizeObserver | null = null
private events: ViewportEvents | null = null
private turns: ReturnType<ChatSnapshot['navigation']['items']> = []
private observation: { top: number; landing: ViewportLanding | null } = { top: 0, landing: null }
/**
* Bind to the containing scrollport and observe content and viewport sizes.
* @param list - Chat root inside an optional shared conversation scrollport.
* @param column - content column whose size changes invalidate cached landings.
*/
attach(list: HTMLElement, column: HTMLElement): void {
this.detach()
const scroller = list.closest<HTMLElement>('[data-conversation-scroll]') ?? list
const composer = scroller.querySelector<HTMLElement>('[data-composer-seat]')
const elements = { list, scroller, composer }
this.elements = elements
scroller.addEventListener('scroll', this.onScroll, { passive: true })
scroller.addEventListener('scrollend', this.onScrollEnd, { passive: true })
if (typeof ResizeObserver !== 'undefined') {
this.observer = new ResizeObserver(() => {
if (this.elements !== elements) return
this.invalidate()
this.events?.resize()
})
this.observer.observe(column)
this.observer.observe(scroller)
if (composer !== null) this.observer.observe(composer)
}
}
/** Disconnect DOM resources and clear observations for the detached view. */
detach(): void {
this.elements?.scroller.removeEventListener('scroll', this.onScroll)
this.elements?.scroller.removeEventListener('scrollend', this.onScrollEnd)
this.observer?.disconnect()
this.observer = null
this.elements = null
this.events = null
this.turns = []
this.observation = { top: 0, landing: null }
}
/**
* Connect business policy without changing DOM listener ownership.
* @param events - business handlers for scroll and layout changes.
* @returns a disposer that disconnects only these handlers.
*/
connect(events: ViewportEvents): () => void {
this.events = events
return () => { if (this.events === events) this.events = null }
}
/**
* Adopt the loaded turn anchors without querying the DOM.
* @param turns - ordered loaded turns from the committed Chat snapshot.
*/
updateTurns(turns: ReturnType<ChatSnapshot['navigation']['items']>): void {
this.turns = turns
}
/**
* Resolve the tail from the committed turn index.
* @returns the latest loaded turn, or null for an empty window.
*/
get latestTurn(): number | null {
return this.turns.at(-1)?.turn ?? null
}
/** Discard geometry-dependent landing knowledge while retaining scroll attribution. */
invalidate(): void {
this.observation.landing = null
}
/**
* Accept a sampled reader position without retaining a known landing.
* @param metrics - settled reader position used as the next attribution baseline.
*/
acknowledge(metrics: ViewportMetrics): void {
this.observation = { top: metrics.top, landing: null }
}
/**
* Compare the current scroll geometry with the last acknowledged position.
* @returns current metrics and movement attribution, or null while detached.
*/
readScroll(): ViewportScroll | null {
const metrics = this.metrics()
if (metrics === null) return null
return {
metrics,
movedByReader: Math.abs(metrics.top - Math.min(this.observation.top, metrics.floor)) > 0.5,
}
}
private metrics(): ViewportMetrics | null {
const scroller = this.elements?.scroller
if (scroller === undefined) return null
const height = scroller.clientHeight
return { top: scroller.scrollTop, height, floor: Math.max(0, scroller.scrollHeight - height) }
}
private anchor(key: string): HTMLElement | null {
if (this.elements === null) return null
for (const row of this.elements.list.querySelectorAll<HTMLElement>('[data-chat-anchor-key]:not([hidden])')) {
if (row.dataset.chatAnchorKey === key) return row
}
return null
}
/**
* Capture the reader's position using visible transcript rows.
* @returns a visible semantic anchor, or null when no anchor can be resolved.
*/
capturePosition(): ChatScrollPosition | null {
const elements = this.elements
if (elements === null) return null
const { list, scroller, composer } = elements
const viewport = scroller.getBoundingClientRect()
const bottom = composer?.getBoundingClientRect().top ?? viewport.bottom
let anchor: HTMLElement | null = null
if (typeof document.elementsFromPoint === 'function' && bottom > viewport.top) {
const content = list.getBoundingClientRect()
const left = Math.max(viewport.left, content.left)
const right = Math.min(viewport.right, content.right)
for (const element of document.elementsFromPoint(left + Math.max(0, right - left) / 2, viewport.top + 1)) {
const row = element instanceof HTMLElement ? element.closest<HTMLElement>('[data-chat-anchor-key]') : null
if (row !== null && list.contains(row)) { anchor = row; break }
}
}
if (anchor === null) {
const rows = list.querySelectorAll<HTMLElement>(
'[data-chat-flow] > [data-chat-flow-key]:not(:empty):not([hidden])',
)
let low = 0
let high = rows.length
while (low < high) {
const middle = (low + high) >>> 1
if (rows.item(middle).getBoundingClientRect().bottom > viewport.top) high = middle
else low = middle + 1
}
const row = rows[low]
anchor = row !== undefined && row.getBoundingClientRect().top < bottom ? row : rows[0] ?? null
}
const key = anchor?.dataset.chatAnchorKey
return anchor === null || key === undefined ? null : {
anchorKey: key,
anchorTop: anchor.getBoundingClientRect().top - viewport.top,
scrollTop: scroller.scrollTop,
}
}
/**
* Resolve the active turn, reusing a known landing when its position is unchanged.
* @param metrics - reusable scroll metrics; omitted callers request a fresh read.
* @returns the loaded turn at the reading line, or null while detached or empty.
*/
readVisibleTurn(metrics = this.metrics()): number | null {
const knownTurn = this.observation.landing?.turn
if (knownTurn != null && metrics?.top === this.observation.top) return knownTurn
const elements = this.elements
const first = this.turns[0]
if (elements === null || metrics === null || first === undefined) return null
const line = elements.scroller.getBoundingClientRect().top + Math.min(96, metrics.height * 0.2)
const content = elements.list.getBoundingClientRect()
let reading: number | null = null
if (typeof document.elementsFromPoint === 'function' && content.width > 0) {
for (const element of document.elementsFromPoint(content.left + content.width / 2, line)) {
const row = element instanceof HTMLElement ? element.closest<HTMLElement>('[data-chat-turn]') : null
const turn = Number(row?.dataset.chatTurn)
if (row !== null && elements.list.contains(row) && Number.isSafeInteger(turn)) { reading = turn; break }
}
}
if (reading === null) {
for (const row of elements.list.querySelectorAll<HTMLElement>('[data-chat-turn]')) {
if (row.getBoundingClientRect().top > line) break
const turn = Number(row.dataset.chatTurn)
if (Number.isSafeInteger(turn)) reading = turn
}
}
let result = first.turn
if (reading !== null) {
for (const item of this.turns) {
if (item.turn > reading) break
result = item.turn
}
}
return result
}
/**
* Align a known loaded turn and return its actual clamped position.
* @param turn - loaded turn to align below the scrollport's top edge.
* @returns the actual landing, or null when its anchor is unavailable.
*/
scrollToTurn(turn: number): ViewportLanding | null {
const item = this.turns.find(candidate => candidate.turn === turn)
if (item === undefined) return null
const row = this.anchor(item.anchorKey)
return row === null ? null : this.align(row, 24, turn)
}
/**
* Align the nearest available fallback for an unavailable turn anchor.
* @param turn - minimum turn number for a mounted fallback row.
* @returns the fallback landing, or null when no eligible row exists.
*/
scrollToTurnAtOrAfter(turn: number): ViewportLanding | null {
if (this.elements === null) return null
for (const row of this.elements.list.querySelectorAll<HTMLElement>('[data-chat-turn]:not([hidden])')) {
const candidate = Number(row.dataset.chatTurn)
if (Number.isSafeInteger(candidate) && candidate >= turn) return this.align(row, 24, candidate)
}
return null
}
/**
* Restore a semantic anchor with a raw-position fallback.
* @param position - semantic scroll memory; raw top is used only if its row is absent.
* @returns the actual landing, or null while detached.
*/
restore(position: ChatScrollPosition): ViewportLanding | null {
const row = this.anchor(position.anchorKey)
if (row !== null) return this.align(row, position.anchorTop, null)
const metrics = this.metrics()
return metrics === null ? null : this.write(position.scrollTop, metrics, null)
}
/**
* Preserve an existing anchor without falling back to an unrelated row.
* @param position - semantic anchor to retain after content changes.
* @returns the compensated landing, or null if the anchor is unavailable.
*/
preserve(position: ChatScrollPosition): ViewportLanding | null {
const row = this.anchor(position.anchorKey)
return row === null ? null : this.align(row, position.anchorTop, null)
}
/**
* Align the scrollport with its current floor.
* @returns the actual floor landing, or null while detached.
*/
scrollToBottom(): ViewportLanding | null {
const metrics = this.metrics()
return metrics === null ? null : this.write(metrics.floor, metrics, this.latestTurn)
}
private align(row: HTMLElement, offset: number, turn: number | null): ViewportLanding | null {
const metrics = this.metrics()
if (metrics === null || this.elements === null) return null
const top = row.getBoundingClientRect().top - this.elements.scroller.getBoundingClientRect().top
return this.write(metrics.top + top - offset, metrics, turn, { key: row.dataset.chatAnchorKey, top })
}
private write(
target: number, metrics: ViewportMetrics, turn: number | null,
anchor?: { key: string | undefined; top: number },
): ViewportLanding | null {
if (this.elements === null) return null
const top = Math.max(0, Math.min(metrics.floor, target))
if (top !== metrics.top) this.elements.scroller.scrollTop = top
const actual = this.elements.scroller.scrollTop
const landing: ViewportLanding = {
metrics: { ...metrics, top: actual },
turn,
position: anchor?.key === undefined ? null : {
anchorKey: anchor.key,
anchorTop: anchor.top - (actual - metrics.top),
scrollTop: actual,
},
}
this.observation = { top: actual, landing }
return landing
}
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
this.invalidate()
const scroll = this.readScroll()
if (scroll !== null) this.events?.scroll(scroll)
}
private readonly onScrollEnd = (event: Event): void => {
if (event.target === this.elements?.scroller) this.events?.scrollEnd()
}
}
/**
* Bind viewport resource ownership to the component's layout lifetime.
* @returns one viewport owner and the element refs attached for this mount.
*/
export function useChatViewport(): {
viewport: ChatViewport
listRef: RefObject<HTMLDivElement>
columnRef: RefObject<HTMLDivElement>
} {
const listRef = useRef<HTMLDivElement | null>(null)
const columnRef = useRef<HTMLDivElement | null>(null)
const [viewport] = useState(() => new ChatViewport())
useLayoutEffect(() => {
if (listRef.current === null || columnRef.current === null) return
viewport.attach(listRef.current, columnRef.current)
return () => { viewport.detach() }
}, [viewport])
return { viewport, listRef, columnRef }
}
@@ -45,18 +45,21 @@ import { formatRunDuration } from '../src/client/chat/message-chrome.ts'
import { ChatSnapshotBuilder } from '../src/client/conversation-nodes/chat-snapshot-builder.ts'
import type { TurnProcessSpec } from '../src/client/contract/turn-process.ts'
import { chatSnapshotFixture } from './chat-snapshot-fixture.client.ts'
import { installTurnNavigatorObserver } from './turn-navigator-fixture.ts'
// Every session-scope fixture carries the resource hook the resources plugin merges into GlobalStandardProps.
const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined })) as GlobalStandardProps['useResource']
afterEach(() => {
cleanup()
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
// Keyless create() persists under the bare declared key; clear between cases
// so one harness's selection cannot rehydrate into the next.
beforeEach(() => {
localStorage.clear()
installTurnNavigatorObserver()
})
const SID = 's1' as SessionId
@@ -601,7 +604,7 @@ describe('ChatView', () => {
expect(railRenders).toBe(afterMount)
})
it('projects loaded turns into prompt and response navigation previews', () => {
it('projects loaded turns into prompt and response navigation previews', async () => {
const snapshot = chatSnapshotFixture({
nodes: [
userInTurn(1, 'first prompt', 1),
@@ -618,11 +621,11 @@ describe('ChatView', () => {
const h = makeHarness({}, {}, snapshot)
const view = render(<h.ChatView {...h.props} />)
const navigation = view.getByRole('navigation', { name: '轮次导航' })
expect(navigation.style.getPropertyValue('--turn-natural-height')).toBe('22px')
const first = view.getByRole('button', { name: '跳转到第 1 轮' })
const first = await view.findByRole('button', { name: '跳转到第 1 轮' })
expect(within(navigation).getAllByRole('button').map(mark => mark.getAttribute('aria-label'))).toEqual([
'跳转到第 1 轮', '跳转到第 2 轮',
])
const second = view.getByRole('button', { name: '跳转到第 2 轮' })
expect(first.parentElement?.style.getPropertyValue('--turn-natural-position')).toBe('0px')
expect(second.parentElement?.style.getPropertyValue('--turn-natural-position')).toBe('10px')
expect(second.getAttribute('aria-current')).toBe('true')
fireEvent.focus(first)
const preview = view.getByRole('tooltip')
@@ -630,16 +633,14 @@ describe('ChatView', () => {
expect(preview.textContent).toContain('first response')
})
it('jumps to a turn anchor and reflows stable marks after an older page arrives', () => {
it('jumps to a turn anchor and reflows stable marks after an older page arrives', async () => {
const later = [
userInTurn(4, 'second prompt', 2), assistant(5, 'second response', 2),
userInTurn(7, 'third prompt', 3), assistant(8, 'third response', 3),
]
const h = makeHarness({ nodes: later }, { hasMore: true })
const view = render(<h.ChatView {...h.props} />)
const second = view.getByRole('button', { name: '跳转到第 2 轮' })
const secondPosition = second.parentElement as HTMLElement
expect(secondPosition.style.getPropertyValue('--turn-natural-position')).toBe('0px')
const second = await view.findByRole('button', { name: '跳转到第 2 轮' })
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
const metrics = installScrollMetrics(scroller, 1_000, 300)
@@ -658,9 +659,10 @@ describe('ChatView', () => {
})
})
const movedSecond = view.getByRole('button', { name: '跳转到第 2 轮' })
expect(movedSecond.parentElement).toBe(secondPosition)
// Fixed pitch: the mark moves one slot down and never compresses.
expect(secondPosition.style.getPropertyValue('--turn-natural-position')).toBe('10px')
expect(movedSecond).toBe(second)
expect(within(view.getByRole('navigation')).getAllByRole('button').map(mark => mark.getAttribute('aria-label'))).toEqual([
'跳转到第 1 轮', '跳转到第 2 轮', '跳转到第 3 轮',
])
})
it('extends the rail with unloaded outline turns, pages on click, and falls back when nothing lands', async () => {
@@ -672,7 +674,7 @@ describe('ChatView', () => {
{ turn: 3, seq: 8, prompt: 'third prompt', response: 'third response' },
])
const view = render(<h.ChatView {...h.props} />)
const first = view.getByRole('button', { name: '加载并跳转到第 1 轮' })
const first = await view.findByRole('button', { name: '加载并跳转到第 1 轮' })
view.getByRole('button', { name: '加载并跳转到第 2 轮' })
const third = view.getByRole('button', { name: '跳转到第 3 轮' })
expect(third.getAttribute('aria-current')).toBe('true')
@@ -687,12 +689,48 @@ describe('ChatView', () => {
// The fake loader never delivers rows: settlement repages once for the
// unmoved head, then lands on the nearest rendered turn and un-busies.
await act(async () => {})
await waitFor(() => { expect(first.getAttribute('aria-busy')).toBeNull() })
expect(h.loadThrough.mock.calls).toEqual([[0], [0]])
expect(first.getAttribute('aria-busy')).toBeNull()
expect(view.getByRole('button', { name: '跳转到第 3 轮' }).getAttribute('aria-current')).toBe('true')
})
it('uses a known turn landing without hit testing or consuming rail scroll as transcript input', async () => {
const original = Object.getOwnPropertyDescriptor(document, 'elementsFromPoint')
const hitTest = vi.fn((): Element[] => [])
Object.defineProperty(document, 'elementsFromPoint', { configurable: true, value: hitTest })
try {
const h = makeHarness({ nodes: [
userInTurn(1, 'first', 1), assistant(2, 'first answer', 1),
userInTurn(4, 'second', 2), assistant(5, 'second answer', 2),
] })
const view = render(<h.ChatView {...h.props} />)
const first = await view.findByRole('button', { name: '跳转到第 1 轮' })
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
installScrollMetrics(scroller, 1_500, 300)
const row = view.container.querySelector('[data-chat-anchor-key="fixture:user:1"]') as HTMLElement
vi.spyOn(scroller, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 0, 800, 300))
vi.spyOn(row, 'getBoundingClientRect').mockImplementation(() => new DOMRect(0, 100 - scroller.scrollTop, 800, 40))
readerScroll(scroller, 600)
scroller.scrollTop = 500
fireEvent.scroll(scroller)
hitTest.mockClear()
fireEvent.click(first)
fireEvent.scroll(scroller)
fireEvent(scroller, new Event('scrollend'))
const rail = view.getByRole('navigation').firstElementChild as HTMLElement
fireEvent.scroll(rail)
fireEvent(rail, new Event('scrollend', { bubbles: true }))
expect(scroller.scrollTop).toBe(76)
expect(first.getAttribute('aria-current')).toBe('true')
expect(hitTest).not.toHaveBeenCalled()
} finally {
if (original === undefined) Reflect.deleteProperty(document, 'elementsFromPoint')
else Object.defineProperty(document, 'elementsFromPoint', original)
}
})
it('a jump from the pinned tail releases bottom ownership so the follow snap cannot cancel it', async () => {
const later = [userInTurn(8, 'third prompt', 3), assistant(9, 'third response', 3)]
const h = makeHarness({ nodes: later }, { hasMore: true })
@@ -706,7 +744,7 @@ describe('ChatView', () => {
// Pinned to the tail on open: the back-to-bottom control is absent.
expect(view.queryByRole('button', { name: '回到底部' })).toBeNull()
const first = view.getByRole('button', { name: '加载并跳转到第 1 轮' })
const first = await view.findByRole('button', { name: '加载并跳转到第 1 轮' })
fireEvent.click(first)
// The click itself leaves the tail...
expect(view.getByRole('button', { name: '回到底部' })).toBeTruthy()
@@ -726,7 +764,7 @@ describe('ChatView', () => {
{ turn: 3, seq: 8, prompt: 'third prompt', response: '' },
])
const view = render(<h.ChatView {...h.props} />)
const first = view.getByRole('button', { name: '加载并跳转到第 1 轮' })
const first = await view.findByRole('button', { name: '加载并跳转到第 1 轮' })
fireEvent.click(first)
// The session-side guard refuses the busy-pager jump instantly, yet the
// mark stays busy instead of degrading to the nearest loaded turn.
@@ -736,12 +774,11 @@ describe('ChatView', () => {
// The plain pull settles: the flip re-settles the jump, which repages.
act(() => { h.setSession({ loadingOlder: false }) })
await act(async () => {})
await waitFor(() => { expect(first.getAttribute('aria-busy')).toBeNull() })
expect(h.loadThrough.mock.calls).toEqual([[0], [0]])
expect(first.getAttribute('aria-busy')).toBeNull()
})
it('scrolls the fixed-pitch rail inside its frame with gradient fades at the scrollable ends', () => {
it('scrolls the fixed-pitch rail inside its frame with gradient fades at the scrollable ends', async () => {
const h = makeHarness(
{ nodes: [userInTurn(8, 'latest prompt', 60), assistant(9, 'latest response', 60)] },
{ hasMore: true },
@@ -754,11 +791,11 @@ describe('ChatView', () => {
})))
const view = render(<h.ChatView {...h.props} />)
const nav = view.getByRole('navigation', { name: '轮次导航' })
// 60 marks at the fixed 10px pitch: the ladder keeps its natural height.
expect(nav.style.getPropertyValue('--turn-natural-height')).toBe('602px')
const scroller = nav.querySelector('[class*="scroller"]') as HTMLElement
Object.defineProperty(scroller, 'scrollHeight', { value: 602, configurable: true })
Object.defineProperty(scroller, 'clientHeight', { value: 300, configurable: true })
const latest = await within(nav).findByRole('button', { name: '跳转到第 60 轮' })
expect(scroller.scrollTop).toBe(302)
expect(within(nav).getAllByRole('button').length).toBeLessThan(60)
expect(latest.getAttribute('aria-current')).toBe('true')
scroller.scrollTop = 0
fireEvent.scroll(scroller)
expect(scroller.className).toContain('fadeBottom')
@@ -768,12 +805,7 @@ describe('ChatView', () => {
fireEvent.scroll(scroller)
expect(scroller.className).toContain('fadeTop')
expect(scroller.className).toContain('fadeBottom')
expect(nav.style.getPropertyValue('--turn-scroll-top')).toBe('150px')
// Pointer mapping subtracts the rail scroll: y=94 with scrollTop 150 is
// natural offset 238px → the 25th mark.
vi.spyOn(nav, 'getBoundingClientRect').mockReturnValue({ top: 0 } as DOMRect)
fireEvent.pointerMove(nav, { clientY: 94 })
fireEvent.pointerMove(within(nav).getByRole('button', { name: '加载并跳转到第 25 轮' }))
expect(view.getByRole('tooltip').textContent).toContain('p25')
})
@@ -787,8 +819,18 @@ describe('ChatView', () => {
let releaseJump: (() => void) | undefined
h.loadThrough.mockImplementation(() => new Promise<void>((resolve) => { releaseJump = resolve }))
const view = render(<h.ChatView {...h.props} />)
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
installScrollMetrics(scroller, 1_000, 300)
scroller.scrollTop = 700
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
if (this.dataset.chatAnchorKey === 'fixture:user:0') {
const top = 100 - scroller.scrollTop
return { top, bottom: top + 40 } as DOMRect
}
return { top: 0, bottom: 300 } as DOMRect
})
fireEvent.click(view.getByRole('button', { name: '加载并跳转到第 1 轮' }))
fireEvent.click(await view.findByRole('button', { name: '加载并跳转到第 1 轮' }))
expect(h.loadThrough).toHaveBeenCalledWith(0)
// The paged window commits: turn 1's rows and rail item enter the snapshot.
@@ -800,15 +842,14 @@ describe('ChatView', () => {
})
const first = view.getByRole('button', { name: '跳转到第 1 轮' })
expect(first.getAttribute('aria-current')).toBe('true')
expect(scroller.scrollTop).toBe(76)
// The mark stays busy until the jump settles: the loader's completion
// runs the final landing correction after the load-earlier button leaves.
expect(first.getAttribute('aria-busy')).toBe('true')
await act(async () => { releaseJump?.() })
// Only the busy lifecycle is asserted after settlement: jsdom's zero
// geometry makes the rAF active-turn resync read "at bottom" and hand the
// mark to the last turn, so aria-current here is timing-dependent under
// instrumentation; the landing position contract lives in the browser e2e.
expect(first.getAttribute('aria-busy')).toBeNull()
await waitFor(() => { expect(first.getAttribute('aria-busy')).toBeNull() })
expect(first.getAttribute('aria-current')).toBe('true')
expect(scroller.scrollTop).toBe(76)
})
it('hands a windowless tool result to the Tool seat with an empty tool name', () => {
@@ -2365,7 +2406,7 @@ describe('ChatView', () => {
nodes: [user(1, 'old'), assistant(2, 'b'), user(5, 'later'), assistant(6, 'a'), user(9, 'mine')],
})
})
expect(scroller.scrollTop).toBe(1600)
expect(scroller.scrollTop).toBe(1200)
})
it('back-to-bottom cancels an in-flight paging anchor', () => {
@@ -2379,7 +2420,7 @@ describe('ChatView', () => {
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_300)
expect(scroller.scrollTop).toBe(1_100)
expect(h.chatScroll.read()).toBeNull()
})
@@ -2398,7 +2439,7 @@ describe('ChatView', () => {
})
expect(scroller.scrollTop).toBe(100)
fireEvent.click(backButton)
expect(scroller.scrollTop).toBe(1000)
expect(scroller.scrollTop).toBe(700)
// At the bottom again: follow re-arms and the button unmounts.
expect(view.queryByLabelText('回到底部')).toBeNull()
})
@@ -2517,6 +2558,38 @@ describe('ChatView', () => {
expect(scroller.scrollTop).toBe(680)
})
it('follows a new submission immediately while an earlier scroll sample is pending', () => {
const nodes = [user(1, 'q'), assistant(2, 'a')]
const h = makeHarness({ nodes })
const view = render(<h.ChatView {...h.props} />)
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
const metrics = installScrollMetrics(scroller, 1_000, 300)
readerScroll(scroller, 700)
scroller.scrollTop = 650
fireEvent.scroll(scroller)
metrics.setHeight(1_135)
act(() => {
h.setSession({
pendingSubmissions: [{
requestId: 'req-follow' as never, placement: 'transcript',
time: 5_000, text: 'new prompt', attachments: [],
}],
})
})
expect(scroller.scrollTop).toBe(835)
metrics.setHeight(1_200)
act(() => {
h.setChat({ nodes: [...nodes, user(3, 'new prompt'), assistant(4, 'reply')] })
h.setSession({ pendingSubmissions: [], running: true })
})
fireEvent(scroller, new Event('scrollend'))
expect(scroller.scrollTop).toBe(900)
expect(view.queryByLabelText('回到底部')).toBeNull()
expect(h.chatScroll.read()).toBeNull()
})
it('clears an away sample when a back-to-bottom delivery restores pinned ownership', () => {
const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
const view = render(<h.ChatView {...h.props} />)
@@ -2601,7 +2674,7 @@ describe('ChatView', () => {
it('one ResizeObserver owns pinned dynamic-height follow and ignores growth while away', () => {
let notify: (() => void) | undefined
const observe = vi.fn()
const observe = vi.fn<(element: Element) => void>()
class ResizeObserverStub {
constructor(callback: ResizeObserverCallback) {
notify = () => { callback([], this as unknown as ResizeObserver) }
@@ -2621,15 +2694,17 @@ describe('ChatView', () => {
fireEvent(scroller, new Event('scrollend'))
Object.defineProperty(scroller, 'scrollHeight', { value: 1_200, writable: true })
act(() => { notify?.() })
expect(scroller.scrollTop).toBe(1_200)
expect(scroller.scrollTop).toBe(900)
readerScroll(scroller, 200)
Object.defineProperty(scroller, 'scrollHeight', { value: 1_400, writable: true })
act(() => { notify?.() })
expect(scroller.scrollTop).toBe(200)
expect(observe).toHaveBeenCalledTimes(1)
expect(observe.mock.calls.map(([element]) => element)).toEqual([
view.container.querySelector('[data-chat-flow]'), scroller,
])
})
it('pinned dynamic-height updates select the latest Turn without reading row geometry', () => {
it('pinned dynamic-height updates select the latest Turn without reading row geometry', async () => {
let notify: (() => void) | undefined
let nextFrame = 0
const frames = new Map<number, FrameRequestCallback>()
@@ -2639,13 +2714,19 @@ describe('ChatView', () => {
return nextFrame
})
vi.stubGlobal('cancelAnimationFrame', (id: number) => { frames.delete(id) })
class ResizeObserverStub {
const RailObserver = ResizeObserver
class ResizeObserverStub extends RailObserver {
private readonly onResize: () => void
constructor(callback: ResizeObserverCallback) {
notify = () => { callback([], this as unknown as ResizeObserver) }
super(callback)
this.onResize = () => { callback([], this) }
}
observe = vi.fn()
disconnect = vi.fn()
override observe(element: Element): void {
super.observe(element)
if (element.closest('nav') === null) notify = this.onResize
}
}
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
@@ -2658,6 +2739,7 @@ describe('ChatView', () => {
turnEnds: new Map([[1, 3], [2, 6]]),
})
const view = render(<h.ChatView {...h.props} />)
await view.findByRole('button', { name: '跳转到第 2 轮' })
const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
const metrics = installScrollMetrics(scroller, 1_000, 300)
scroller.scrollTop = 700
@@ -2705,11 +2787,11 @@ describe('ChatView', () => {
const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
const view = render(<h.ChatView {...h.props} />, { container: host })
// Open jump uses the host, not the local .scroll node.
expect(host.scrollTop).toBe(2000)
expect(host.scrollTop).toBe(1500)
readerScroll(host, 100)
expect(view.getByLabelText('回到底部')).toBeTruthy()
fireEvent.click(view.getByLabelText('回到底部'))
expect(host.scrollTop).toBe(2000)
expect(host.scrollTop).toBe(1500)
} finally {
host.remove()
}
@@ -2722,12 +2804,13 @@ describe('ChatView', () => {
Object.defineProperty(host, 'clientHeight', { value: 500, writable: true, configurable: true })
Object.defineProperty(host, 'scrollTop', { value: 0, writable: true, configurable: true })
document.body.appendChild(host)
let anchorTop = 80
let anchorOffset = 180
vi.spyOn(host, 'getBoundingClientRect').mockImplementation(
() => ({ top: 0, bottom: 500 } as DOMRect),
)
const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
if (this.dataset.chatAnchorKey === 'fixture:user:1') {
const anchorTop = anchorOffset - host.scrollTop
return { top: anchorTop, bottom: anchorTop + 40 } as DOMRect
}
return { top: 0, bottom: 40 } as DOMRect
@@ -2736,15 +2819,15 @@ describe('ChatView', () => {
const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] })
// Fresh open (nothing saved): the bottom jump stands.
const view = render(<h.ChatView {...h.props} />, { container: host })
expect(host.scrollTop).toBe(2000)
expect(host.scrollTop).toBe(1500)
// Reader scrolls up; the position is recorded continuously.
readerScroll(host, 100)
// View-tab switch away and back: the view unmounts, then remounts.
view.rerender(<div />)
anchorTop = 560
anchorOffset = 660
host.scrollTop = 0
view.rerender(<h.ChatView {...h.props} />)
expect(host.scrollTop).toBe(580) // approximate 100 + the row's 480px reflow shift
expect(host.scrollTop).toBe(580)
// The restored position is above the floor: follow stays disarmed.
expect(view.getByLabelText('回到底部')).toBeTruthy()
} finally {
@@ -2766,7 +2849,10 @@ describe('ChatView', () => {
})
document.body.appendChild(host)
const rect = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
if (this.dataset.chatAnchorKey === 'fixture:user:1') return { top: 300, bottom: 340 } as DOMRect
if (this.dataset.chatAnchorKey === 'fixture:user:1') {
const top = 1_700 - host.scrollTop
return { top, bottom: top + 40 } as DOMRect
}
return { top: 0, bottom: 500 } as DOMRect
})
try {
@@ -2801,7 +2887,7 @@ describe('ChatView', () => {
view.rerender(<div />)
host.scrollTop = 0
view.rerender(<h.ChatView {...h.props} />)
expect(host.scrollTop).toBe(2000)
expect(host.scrollTop).toBe(1500)
} finally {
host.remove()
}
@@ -0,0 +1,60 @@
import { vi } from 'vitest'
import { act } from '@testing-library/react'
/**
* Supply rail viewport sizes and element-local scrolling absent from jsdom.
* @param initialHeight - first delivered height, or null to delay initial layout.
* @returns a size-delivery control for the mounted rails.
*/
export function installTurnNavigatorObserver(initialHeight: number | null = 300) {
const rails = new Map<Element, Observer>()
let height = initialHeight
class Observer implements ResizeObserver {
constructor(private readonly callback: ResizeObserverCallback) {}
observe(element: Element): void {
if (!(element instanceof HTMLElement) || element.parentElement?.tagName !== 'NAV') return
rails.set(element, this)
element.scrollTo = (options: ScrollToOptions | number = {}, y?: number) => {
const next = typeof options === 'number' ? y ?? 0 : options.top ?? element.scrollTop
if (next === element.scrollTop) return
element.scrollTop = next
queueMicrotask(() => {
if (element.isConnected) act(() => { element.dispatchEvent(new Event('scroll')) })
})
}
queueMicrotask(() => {
const currentHeight = height
if (currentHeight !== null && rails.get(element) === this) act(() => { this.deliver(element, currentHeight) })
})
}
unobserve(element: Element): void { rails.delete(element) }
disconnect(): void {
for (const [element, observer] of rails) {
if (observer === this) rails.delete(element)
}
}
deliver(target: Element, blockSize: number): void {
const size = [{ inlineSize: 36, blockSize }]
this.callback([{
target,
borderBoxSize: size,
contentBoxSize: size,
devicePixelContentBoxSize: size,
contentRect: new DOMRectReadOnly(0, 0, 36, blockSize),
}], this)
}
}
vi.stubGlobal('ResizeObserver', Observer)
return {
resize(nextHeight: number): void {
height = nextHeight
for (const [element, observer] of rails) observer.deliver(element, height)
},
}
}
@@ -0,0 +1,155 @@
// @vitest-environment jsdom
import { createRef, StrictMode } from 'react'
import { act, cleanup, fireEvent, render, within } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import { TurnNavigator, type TurnNavigatorHandle } from '../src/client/chat/TurnNavigator.tsx'
import type { TurnRailItem } from '../src/client/chat/turn-rail-items.ts'
import { zh } from '../src/client/locale.ts'
import { installTurnNavigatorObserver } from './turn-navigator-fixture.ts'
afterEach(() => {
cleanup()
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
const items: readonly TurnRailItem[] = Array.from({ length: 1_000 }, (_, index) => ({
turn: index + 1,
prompt: `prompt ${String(index + 1)}`,
response: '',
anchor: { kind: 'loaded', key: `turn:${String(index + 1)}` },
}))
const t = makeTranslate(zh, commonZh)
describe('TurnNavigator', () => {
it('places the latest committed active turn on its first viewport size without scroll commands', () => {
const observer = installTurnNavigatorObserver(null)
const onNavigate = vi.fn()
const view = render(<TurnNavigator items={items} activeTurn={1} busyTurn={null} onNavigate={onNavigate} t={t} />)
const nav = view.getByRole('navigation')
const scroller = nav.firstElementChild as HTMLElement
const scrollTo = vi.spyOn(scroller, 'scrollTo')
const rect = vi.spyOn(scroller, 'getBoundingClientRect').mockImplementation(() => { throw new Error('synchronous rect read') })
Object.defineProperty(scroller, 'scrollHeight', { get: () => { throw new Error('scroll extent read') } })
Object.defineProperty(scroller, 'clientHeight', { get: () => { throw new Error('viewport height read') } })
view.rerender(<TurnNavigator items={items} activeTurn={1_000} busyTurn={null} onNavigate={onNavigate} t={t} />)
act(() => { observer.resize(300) })
expect(scroller.scrollTop).toBe(9_702)
expect(scrollTo).not.toHaveBeenCalled()
expect(rect).not.toHaveBeenCalled()
expect(within(nav).getAllByRole('button').length).toBeLessThan(40)
expect(view.getByRole('button', { name: '跳转到第 1000 轮' }).getAttribute('aria-current')).toBe('true')
expect(view.queryByRole('button', { name: '跳转到第 1 轮' })).toBeNull()
})
it('exposes separate activation and fixed-height scrolling controls without querying the scroll extent', async () => {
installTurnNavigatorObserver()
const ref = createRef<TurnNavigatorHandle>()
const onNavigate = vi.fn()
const view = render(<TurnNavigator ref={ref} items={items} activeTurn={1} busyTurn={null} onNavigate={onNavigate} t={t} />)
await view.findByRole('button', { name: '跳转到第 1 轮' })
const scroller = view.getByRole('navigation').firstElementChild as HTMLElement
Object.defineProperty(scroller, 'scrollHeight', { get: () => { throw new Error('scroll extent read') } })
Object.defineProperty(scroller, 'clientHeight', { get: () => { throw new Error('viewport height read') } })
await act(async () => { ref.current?.scrollToTurn(500) })
expect(scroller.scrollTop).toBe(4_846)
expect(view.getByRole('button', { name: '跳转到第 500 轮' })).toBeTruthy()
expect(onNavigate).not.toHaveBeenCalled()
act(() => { ref.current?.activateTurn(700) })
expect(onNavigate).toHaveBeenCalledExactlyOnceWith(items[699])
expect(scroller.scrollTop).toBe(4_846)
})
it('keeps keyboard focus mounted while scrolling to another virtual range', async () => {
installTurnNavigatorObserver()
const ref = createRef<TurnNavigatorHandle>()
const view = render(
<StrictMode>
<TurnNavigator ref={ref} items={items} activeTurn={1} busyTurn={null} onNavigate={vi.fn()} t={t} />
</StrictMode>,
)
const first = await view.findByRole('button', { name: '跳转到第 1 轮' })
act(() => { first.focus() })
await act(async () => { ref.current?.scrollToTurn(900) })
expect(document.activeElement).toBe(first)
expect(first.isConnected).toBe(true)
expect(view.getByRole('button', { name: '跳转到第 900 轮' })).toBeTruthy()
fireEvent.pointerMove(view.getByRole('button', { name: '跳转到第 900 轮' }))
expect(view.getByRole('tooltip').textContent).toBe('prompt 900')
act(() => { first.blur() })
expect(first.isConnected).toBe(false)
})
it('keeps active marks inside the safe band still and centers only after crossing either edge', async () => {
installTurnNavigatorObserver(288)
const props = { items, activeTurn: 50, busyTurn: null, onNavigate: vi.fn(), t }
const view = render(<TurnNavigator {...props} />)
await view.findByRole('button', { name: '跳转到第 50 轮' })
const scroller = view.getByRole('navigation').firstElementChild as HTMLElement
const scrollTo = vi.spyOn(scroller, 'scrollTo')
Object.defineProperty(scroller, 'scrollHeight', { get: () => { throw new Error('scroll extent read') } })
Object.defineProperty(scroller, 'clientHeight', { get: () => { throw new Error('viewport height read') } })
vi.spyOn(scroller, 'getBoundingClientRect').mockImplementation(() => { throw new Error('synchronous rect read') })
expect(scroller.scrollTop).toBe(352)
for (const activeTurn of [38, 62]) {
await act(async () => { view.rerender(<TurnNavigator {...props} activeTurn={activeTurn} />) })
expect(scroller.scrollTop).toBe(352)
}
expect(scrollTo).not.toHaveBeenCalled()
await act(async () => { view.rerender(<TurnNavigator {...props} activeTurn={63} />) })
expect(scroller.scrollTop).toBe(482)
expect(scrollTo).toHaveBeenCalledWith(expect.objectContaining({ top: 482 }))
scrollTo.mockClear()
await act(async () => { view.rerender(<TurnNavigator {...props} activeTurn={51} />) })
expect(scroller.scrollTop).toBe(482)
expect(scrollTo).not.toHaveBeenCalled()
await act(async () => { view.rerender(<TurnNavigator {...props} activeTurn={50} />) })
expect(scroller.scrollTop).toBe(352)
expect(scrollTo).toHaveBeenCalledWith(expect.objectContaining({ top: 352 }))
})
it('does not let scroll-induced pointer entry replace a hover or focused preview', async () => {
installTurnNavigatorObserver()
const view = render(<TurnNavigator items={items} activeTurn={1} busyTurn={null} onNavigate={vi.fn()} t={t} />)
const twentieth = await view.findByRole('button', { name: '跳转到第 20 轮' })
const nav = view.getByRole('navigation')
const scroller = nav.firstElementChild as HTMLElement
vi.spyOn(scroller, 'getBoundingClientRect').mockImplementation(() => { throw new Error('synchronous rect read') })
fireEvent.pointerEnter(nav)
fireEvent.pointerEnter(twentieth)
fireEvent.pointerMove(twentieth)
expect(view.getByRole('tooltip').textContent).toBe('prompt 20')
scroller.scrollTop = 100
fireEvent.scroll(scroller)
fireEvent.pointerEnter(view.getByRole('button', { name: '跳转到第 30 轮' }))
expect(view.getByRole('tooltip').textContent).toBe('prompt 20')
const focused = view.getByRole('button', { name: '跳转到第 21 轮' })
act(() => { focused.focus() })
const previewId = view.getByRole('tooltip').id
scroller.scrollTop = 600
fireEvent.scroll(scroller)
const underPointer = view.getByRole('button', { name: '跳转到第 70 轮' })
fireEvent.pointerEnter(underPointer)
expect(document.activeElement).toBe(focused)
expect(view.getByRole('tooltip').textContent).toBe('prompt 21')
expect(focused.getAttribute('aria-describedby')).toBe(previewId)
fireEvent.pointerMove(underPointer)
expect(view.getByRole('tooltip').textContent).toBe('prompt 70')
expect(underPointer.getAttribute('aria-describedby')).toBe(previewId)
})
})
+3
View File
@@ -2817,6 +2817,9 @@ importers:
'@deepseek-ai/dsh-util-workspace-path':
specifier: workspace:^
version: link:../../util/workspace-path
'@tanstack/react-virtual':
specifier: ^3.14.9
version: 3.14.9([email protected]([email protected]))([email protected])
'@types/react':
specifier: ~18.3.1
version: 18.3.31