Merge pull request #4494 from deepseek-harness/perf/chat-navigation-history
perf(chat): 虚拟化轮次导航并聚合历史跳转
This commit is contained in:
commit
21e31ac05e
28 files changed
+1886
-877
No files matched your search
+6
@@ -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) 覆盖真实滚动与未加载轮次落点。功能证据与上述理论估算相互独立。
|
||||
@@ -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()
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,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
|
||||
@@ -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>
|
||||
|
||||
-----
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
-----
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
Generated
+3
@@ -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
|
||||
|
||||
Reference in new issue
Block a user