fix: restore the jump-to-bottom glide and keep user state, selections and scroll anchors in windowed lists

Pause scroll writes while the jump-to-bottom smooth scroll runs; keep rows with pending questions, approvals, recent interactions and selection ends mounted; cache Mermaid results; copy text spanning unmounted rows; anchor the sidebar on id changes; disable native overflow-anchor for windowed lists; write terminal output on the leading edge and before clear; forward remote events every 16 ms; flush streaming text before copy/export; add performance principle P10 (no UX regressions).
This commit is contained in:
mesalogo
2026-10-03 16:19:56 +08:00
parent a620c22676
commit 805acc3fce
25 changed files with 834 additions and 63 deletions
@@ -80,6 +80,17 @@ Main 负责窗口、生命周期、权限和消息转发。数据库查询、文
- 异步改造要先确认“先写入、后读取”的顺序,没有测试覆盖就不合并。 - 异步改造要先确认“先写入、后读取”的顺序,没有测试覆盖就不合并。
- 风险高、收益小,或者会改变现有功能的优化先不做,在计划文档里记录理由。 - 风险高、收益小,或者会改变现有功能的优化先不做,在计划文档里记录理由。
### P10 体验不倒退
性能优化只能让体验变好,不能用体验换速度。“行为不变”不仅指数据和结果,也包括用户能感知到的交互:
- **动画和过渡保持原样。** 平滑滚动(如“到底部”按钮)、展开收起、悬停、淡入淡出都不能因为优化而消失。任何瞬间写入 `scrollTop`/`scrollTo` 的代码都会打断正在进行的平滑滚动,必须在平滑滚动期间暂停。
- **只渲染可见部分(P5)时,用户状态不能丢。** 行被卸载后,它的局部状态也会消失。以下行必须保持挂载,或者把状态存到行外:有焦点的行、用户展开过 `<details>` 或输入过内容的行、选中文本的两端、待回答的问题和待审批项、打开菜单或正在重命名的行。
- **只渲染可见部分时,基本操作要和完整列表一致:** 跨行选择和复制、滚动位置不因上方插入或删除而跳动(关闭原生 `overflow-anchor` 时要自己做锚定)、快速拖动滚动条时没有空白帧、渲染结果缓存让重新挂载的行不出现“加载中”。
- **合并和批处理不能增加交互延迟。** 第一条数据立即处理(leading edge),只合并之后同一帧内的数据。打字回显、终端回显不能多等一帧。
- **低频同步不能让用户看到旧数据。** 复制、导出、保存这类读取全局状态的操作,要先把待同步的数据刷进去。
- **体验要有测试。** 涉及动画、滚动、焦点的改动,需要真实 Electron 测试(逐帧采样)或单元测试,并确认测试在旧代码上会失败。
### P9 用工具守住规则 ### P9 用工具守住规则
- lint 规则和性能阈值是合并前的硬性条件,不靠人工记得。 - lint 规则和性能阈值是合并前的硬性条件,不靠人工记得。
@@ -98,6 +109,9 @@ Main 负责窗口、生命周期、权限和消息转发。数据库查询、文
6. 有没有在启动阶段新增同步初始化? 6. 有没有在启动阶段新增同步初始化?
7. 是否附上了 `perf:app`、`perf:main` 或 `perf:knowledge` 的前后对比? 7. 是否附上了 `perf:app`、`perf:main` 或 `perf:knowledge` 的前后对比?
8. 顺序、取消、持久化和恢复,有没有测试覆盖? 8. 顺序、取消、持久化和恢复,有没有测试覆盖?
9. 动画、平滑滚动、过渡还在吗?有没有新增会打断平滑滚动的瞬间滚动写入?
10. 只渲染可见部分的列表,滚走再滚回来,展开状态、输入内容、选区、焦点还在吗?
11. 合并或延迟处理后,第一次响应(打字回显、点击反馈)有没有变慢?复制、导出读到的是不是屏幕上的最新内容?
## 4. 当前差距 ## 4. 当前差距
@@ -868,7 +868,7 @@ Renderer 主线程降速 4 倍(Main 不降速,每次 reload 后重新应用
### 7.2 按性能原则补齐差距(2026-10-03,第三轮并行) ### 7.2 按性能原则补齐差距(2026-10-03,第三轮并行)
统一标准见 `docs/architecture/performance-principles.md`(P1–P9、性能预算和评审清单)。本轮按其中“当前差距”并行推进: 统一标准见 `docs/architecture/performance-principles.md`(P1–P10、性能预算和评审清单)。本轮按其中“当前差距”并行推进:
| 原则 | 改动 | 测量结果 | | 原则 | 改动 | 测量结果 |
| --- | --- | --- | | --- | --- | --- |
@@ -893,6 +893,25 @@ Renderer 主线程降速 4 倍(Main 不降速,每次 reload 后重新应用
本轮回归:`perf:app` 60 项阈值全部通过(不降速,流式和流式打字长任务均为 0);typecheck、全量 lint 通过;全量测试中 2 个失败分别为 OpenCode 免费额度 403 的集成用例和 `magic-notes-*` 真实 Electron 测试在整套并行负载下的偶发失败,后者单独运行和 6 个 Electron 布局测试并行连跑 3 次均通过。 本轮回归:`perf:app` 60 项阈值全部通过(不降速,流式和流式打字长任务均为 0);typecheck、全量 lint 通过;全量测试中 2 个失败分别为 OpenCode 免费额度 403 的集成用例和 `magic-notes-*` 真实 Electron 测试在整套并行负载下的偶发失败,后者单独运行和 6 个 Electron 布局测试并行连跑 3 次均通过。
### 7.3 体验倒退复查(2026-10-03)
逐一复查了所有性能提交对交互体验的影响,并新增原则 P10“体验不倒退”。发现并修复:
| 问题 | 来源 | 修复 | 验证 |
| --- | --- | --- | --- |
| “到底部”按钮的平滑滚动动画消失,直接跳到底部 | `fdf7d83` 消息窗口化:贴底跟随、ResizeObserver、锚定修正都会瞬间写入 `scrollTop`,打断平滑滚动;点击时还立即切换窗口导致可见行被卸载 | 滑动期间暂停所有滚动写入,只随视口重新窗口化;到达底部、`scrollend` 或 1.5 秒兜底时再贴底;用户滚轮、按键或点击会接管滚动 | 真实 Electron 逐帧采样,要求有中间帧(旧代码中间帧为 0),连跑 3 次通过;新增单元测试,旧代码失败 |
| 消息滚出可见区后丢失状态:待回答问题里已选的选项和输入的内容、展开的工具详情和推理、Mermaid 重新绘制并显示“加载中” | `fdf7d83` | 有待回答问题或待审批的行、用户交互过的最近 8 行、选区两端所在的行保持挂载;Mermaid 按“主题 + 源码”缓存渲染结果(最多 64 个) | 单元测试,旧代码失败 |
| 跨多条消息选择文本时,选区起点被卸载;复制时只拿到已挂载的文字 | `fdf7d83` | 选区两端所在的行保持挂载;复制时,未挂载的消息用原文补全 | 单元测试 |
| 侧栏在上方插入、删除、置顶或 5 秒重排时,可见的会话行跳动 | `663aa85`,窗口化关闭了原生滚动锚定 | 会话 id 变化时,在绘制前按第一条可见行恢复位置;列表在顶部时不锚定 | 单元测试,旧代码失败 |
| 笔记、文件树、知识库文档表格的锚定修正可能和浏览器原生锚定冲突 | `b2eb86a` | 窗口化状态下关闭 `overflow-anchor` | 单元测试 |
| 终端打字回显多等一帧;点击“清屏”前到达的输出在清屏之后才显示 | `f4458af` | 空闲时立即写入,只合并同一帧内后续到达的输出;清屏前先写完待写入的输出 | 单元测试 |
| 远程 Runtime 流式输出最多 50 ms 一批,显示一顿一顿 | `d859cbf` | 合并间隔改为 16 ms;写库成功后立即转发,仍然是先写库再转发 | 单元测试 |
| 流式输出时从侧栏复制或导出会话,可能缺最后不超过 250 ms 的文字 | PERF-13 流式缓冲 | 复制、导出前先把待同步的流式文字刷进状态 | 单元测试,旧代码失败 |
检查后没有问题:CSS 动画和过渡没有被删改;其余 `scrollIntoView` 的行为和原来一致;标题跑马灯、侧栏 5 秒重排的悬停冻结、重命名和菜单行保持挂载;输入框自动增高;Markdown 和 KaTeX 渲染结果。快速大跨度跳转是否出现空白帧也做了实测:在 `perf:app` 滚动场景中加了检查,CPU 降速 4 倍时 10 次跳转中空白帧为 0,所以没有改动。
已知限制(不是倒退,原来也没有):读屏软件的浏览模式访问不到未挂载的消息;应用本身没有页内查找功能。
修复记录:文件树窗口化最初以 `.assistant-sidebar__body` 为滚动容器,但真实布局中滚动的是停靠的 `.workspace-files__files-view`,真实 Electron 测试发现后改为优先使用后者。 修复记录:文件树窗口化最初以 `.assistant-sidebar__body` 为滚动容器,但真实布局中滚动的是停靠的 `.workspace-files__files-view`,真实 Electron 测试发现后改为优先使用后者。
### PERF-14 状态 ### PERF-14 状态
+3
View File
@@ -3988,6 +3988,9 @@ export function registerIpcHandlers(
let remoteEventBatchClosed = false let remoteEventBatchClosed = false
const remoteEventBatcher = new RemoteEventBatcher<RemoteBatchEvent>({ const remoteEventBatcher = new RemoteEventBatcher<RemoteBatchEvent>({
onError: (error) => controller.abort(error), onError: (error) => controller.abort(error),
// The batch already coalesced a frame of deltas: send them now rather
// than holding them for another public-buffer interval.
onTimerFlushed: () => publicEventBuffer.flush(),
persist: (entries) => persist: (entries) =>
remoteConversationRecovery remoteConversationRecovery
? assistantDatabase.appendRemoteConversationTaskEventsBatch({ ? assistantDatabase.appendRemoteConversationTaskEventsBatch({
+45 -2
View File
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from 'vitest' import { afterEach, describe, expect, it, vi } from 'vitest'
import { RemoteEventBatcher } from './remote-event-batcher' import { REMOTE_EVENT_BATCH_FLUSH_INTERVAL_MS, RemoteEventBatcher } from './remote-event-batcher'
const provenance = (eventIndex: number) => ({ const provenance = (eventIndex: number) => ({
source: 'remote-semantic-transcript' as const, source: 'remote-semantic-transcript' as const,
@@ -46,6 +46,49 @@ describe('RemoteEventBatcher', () => {
batcher.dispose() batcher.dispose()
}) })
it('commits (and so forwards) streamed events within one frame by default', () => {
vi.useFakeTimers()
expect(REMOTE_EVENT_BATCH_FLUSH_INTERVAL_MS).toBe(16)
const persist = vi.fn((entries: readonly unknown[]) => entries.map(() => true))
const committed: string[] = []
const batcher = new RemoteEventBatcher<string>({ persist })
batcher.add(provenance(0), 'a', (event) => committed.push(event))
batcher.add(provenance(1), 'b', (event) => committed.push(event))
vi.advanceTimersByTime(15)
// Nothing is forwarded before it is persisted.
expect(committed).toEqual([])
vi.advanceTimersByTime(1)
expect(persist).toHaveBeenCalledOnce()
expect(committed).toEqual(['a', 'b'])
batcher.dispose()
})
it('reports a timer flush after its events committed, and not after a failed one', () => {
vi.useFakeTimers()
const order: string[] = []
let fail = false
const batcher = new RemoteEventBatcher<string>({
persist: (entries) => {
if (fail) throw new Error('disk full')
return entries.map(() => true)
},
onTimerFlushed: () => order.push('flushed'),
onError: () => order.push('error')
})
batcher.add(provenance(0), 'a', (event) => order.push(event))
vi.advanceTimersByTime(REMOTE_EVENT_BATCH_FLUSH_INTERVAL_MS)
expect(order).toEqual(['a', 'flushed'])
// Explicit flushes leave forwarding to the caller.
batcher.add(provenance(1), 'b', (event) => order.push(event))
batcher.flush()
expect(order).toEqual(['a', 'flushed', 'b'])
fail = true
batcher.add(provenance(2), 'c', (event) => order.push(event))
vi.advanceTimersByTime(REMOTE_EVENT_BATCH_FLUSH_INTERVAL_MS)
expect(order).toEqual(['a', 'flushed', 'b', 'error'])
batcher.dispose()
})
it('rethrows a failed commit and stays failed so later checkpoints are never committed', () => { it('rethrows a failed commit and stays failed so later checkpoints are never committed', () => {
vi.useFakeTimers() vi.useFakeTimers()
const onError = vi.fn() const onError = vi.fn()
@@ -55,7 +98,7 @@ describe('RemoteEventBatcher', () => {
const onCommitted = vi.fn() const onCommitted = vi.fn()
const batcher = new RemoteEventBatcher<string>({ persist, onError }) const batcher = new RemoteEventBatcher<string>({ persist, onError })
batcher.add(provenance(0), 'a', onCommitted) batcher.add(provenance(0), 'a', onCommitted)
vi.advanceTimersByTime(50) vi.advanceTimersByTime(REMOTE_EVENT_BATCH_FLUSH_INTERVAL_MS)
expect(onError).toHaveBeenCalledWith(new Error('disk full')) expect(onError).toHaveBeenCalledWith(new Error('disk full'))
expect(() => batcher.add(provenance(1), 'checkpoint')).toThrow('disk full') expect(() => batcher.add(provenance(1), 'checkpoint')).toThrow('disk full')
expect(() => batcher.flush()).toThrow('disk full') expect(() => batcher.flush()).toThrow('disk full')
+14 -1
View File
@@ -18,6 +18,12 @@ export type RemoteEventBatcherOptions<TEvent> = {
persist(entries: readonly RemoteEventBatchEntry<TEvent>[]): readonly boolean[] persist(entries: readonly RemoteEventBatchEntry<TEvent>[]): readonly boolean[]
/** Called when a timer-driven flush fails (for example to abort the run). */ /** Called when a timer-driven flush fails (for example to abort the run). */
onError?(error: unknown): void onError?(error: unknown): void
/**
* Called after a timer-driven flush committed (and its `onCommitted`
* callbacks ran), e.g. to forward the committed events without waiting for
* a second coalescing timer downstream.
*/
onTimerFlushed?(): void
/** Safety flush for buffered events that are not followed by a boundary. */ /** Safety flush for buffered events that are not followed by a boundary. */
flushIntervalMs?: number flushIntervalMs?: number
maximumEvents?: number maximumEvents?: number
@@ -27,7 +33,11 @@ type PendingEntry<TEvent> = RemoteEventBatchEntry<TEvent> & {
onCommitted?: RemoteEventCommitted<TEvent> onCommitted?: RemoteEventCommitted<TEvent>
} }
export const REMOTE_EVENT_BATCH_FLUSH_INTERVAL_MS = 50 /**
* One frame: committed events are forwarded to the renderer only after their
* batch commits, so this bounds how long streamed remote text is held back.
*/
export const REMOTE_EVENT_BATCH_FLUSH_INTERVAL_MS = 16
export const REMOTE_EVENT_BATCH_MAXIMUM_EVENTS = 256 export const REMOTE_EVENT_BATCH_MAXIMUM_EVENTS = 256
/** /**
@@ -47,6 +57,7 @@ export const REMOTE_EVENT_BATCH_MAXIMUM_EVENTS = 256
export class RemoteEventBatcher<TEvent> { export class RemoteEventBatcher<TEvent> {
private readonly persist: RemoteEventBatcherOptions<TEvent>['persist'] private readonly persist: RemoteEventBatcherOptions<TEvent>['persist']
private readonly onError: RemoteEventBatcherOptions<TEvent>['onError'] private readonly onError: RemoteEventBatcherOptions<TEvent>['onError']
private readonly onTimerFlushed: RemoteEventBatcherOptions<TEvent>['onTimerFlushed']
private readonly flushIntervalMs: number private readonly flushIntervalMs: number
private readonly maximumEvents: number private readonly maximumEvents: number
private pending: PendingEntry<TEvent>[] = [] private pending: PendingEntry<TEvent>[] = []
@@ -56,6 +67,7 @@ export class RemoteEventBatcher<TEvent> {
constructor(options: RemoteEventBatcherOptions<TEvent>) { constructor(options: RemoteEventBatcherOptions<TEvent>) {
this.persist = options.persist this.persist = options.persist
this.onError = options.onError this.onError = options.onError
this.onTimerFlushed = options.onTimerFlushed
this.flushIntervalMs = this.flushIntervalMs =
options.flushIntervalMs ?? REMOTE_EVENT_BATCH_FLUSH_INTERVAL_MS options.flushIntervalMs ?? REMOTE_EVENT_BATCH_FLUSH_INTERVAL_MS
this.maximumEvents = this.maximumEvents =
@@ -132,6 +144,7 @@ export class RemoteEventBatcher<TEvent> {
this.timer = undefined this.timer = undefined
try { try {
this.flush() this.flush()
this.onTimerFlushed?.()
} catch (error) { } catch (error) {
this.onError?.(error) this.onError?.(error)
} }
+42
View File
@@ -4842,6 +4842,48 @@ describe("App", () => {
expect(sidebarRenderProbes.row.mock.calls.length).toBeLessThanOrEqual(4); expect(sidebarRenderProbes.row.mock.calls.length).toBeLessThanOrEqual(4);
}); });
it("copies and exports a streaming conversation with the text shown on screen", async () => {
const { container } = render(<App />);
const composer = await screen.findByLabelText("向 GoodBuddy 提问");
fireEvent.change(composer, { target: { value: "Copy while streaming" } });
fireEvent.click(screen.getByLabelText("发送"));
await waitFor(() => expect(run).toHaveBeenCalledOnce());
const requestId = run.mock.calls[0]![0].requestId;
// Let the first delta's flush land, then stream more within one flush window.
act(() => agentListener?.({ requestId, type: "text", delta: "Live" }));
await screen.findByText("Live");
await act(() => new Promise((resolve) => setTimeout(resolve, 400)));
for (const delta of [" alpha", " omega"]) {
act(() => agentListener?.({ requestId, type: "text", delta }));
}
expect(screen.getByText("Live alpha omega")).toBeInTheDocument();
const list = container.querySelector<HTMLElement>(".conversation-list")!;
fireEvent.click(within(list).getByLabelText(/^更多会话操作 /u));
fireEvent.click(screen.getByRole("menuitem", { name: "复制完整会话" }));
await waitFor(() => expect(api.clipboard.writeText).toHaveBeenCalledOnce());
expect(api.clipboard.writeText).toHaveBeenCalledWith(expect.stringContaining("Live alpha omega"));
const blobs: Blob[] = [];
const createObjectURL = vi.fn((blob: Blob) => { blobs.push(blob); return "blob:export"; });
const originalUrl = { create: URL.createObjectURL, revoke: URL.revokeObjectURL };
URL.createObjectURL = createObjectURL;
URL.revokeObjectURL = vi.fn();
const click = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => undefined);
try {
act(() => agentListener?.({ requestId, type: "text", delta: " tail" }));
fireEvent.click(within(list).getByLabelText(/^更多会话操作 /u));
fireEvent.click(screen.getByRole("menuitem", { name: "导出 Markdown" }));
await waitFor(() => expect(createObjectURL).toHaveBeenCalledOnce());
expect(await blobs[0]!.text()).toContain("Live alpha omega tail");
} finally {
click.mockRestore();
URL.createObjectURL = originalUrl.create;
URL.revokeObjectURL = originalUrl.revoke;
}
fireEvent.click(screen.getByLabelText("停止生成"));
await waitFor(() => expect(api.agent.cancel).toHaveBeenCalledWith(requestId));
});
it("does not re-render the App root when the active conversation's streaming text is flushed", async () => { it("does not re-render the App root when the active conversation's streaming text is flushed", async () => {
render(<App />); render(<App />);
const composer = await screen.findByLabelText("向 GoodBuddy 提问"); const composer = await screen.findByLabelText("向 GoodBuddy 提问");
+8 -2
View File
@@ -5399,11 +5399,17 @@ function App(): React.JSX.Element {
[t], [t],
); );
const readConversationForExport = async (item: Conversation): Promise<Conversation> => { const readConversationForExport = async (selected: Conversation): Promise<Conversation> => {
// Streaming deltas reach the conversation store only on the live-message
// flush cadence; absorb them now so the copy holds everything on screen.
if (liveMessages.hasEntries()) setConversations((current) => current);
const item = conversationStore.getConversation(selected.id) ?? selected;
if (!item.messageSummary) return item; if (!item.messageSummary) return item;
const snapshot = await window.goodbuddy.conversations.get(item.id); const snapshot = await window.goodbuddy.conversations.get(item.id);
// Export reads do not acquire a view-cache entry or retain tool metadata. // Export reads do not acquire a view-cache entry or retain tool metadata.
return mergePersistedConversations([item], [snapshot], new Map())[0]!; const merged = mergePersistedConversations([item], [snapshot], new Map())[0]!;
// Deltas that streamed in while the snapshot was read.
return liveMessages.resolveConversations([merged])[0]!;
}; };
const copyConversation = async ( const copyConversation = async (
+82
View File
@@ -371,6 +371,88 @@ describe('ChatHistoryPane windowing', () => {
expect(chat.scrollTop + viewport).toBe(chat.scrollHeight) expect(chat.scrollTop + viewport).toBe(chat.scrollHeight)
}) })
// UX parity with the fully mounted list: rows holding user state survive
// scrolling away and back.
it('keeps rows with an open details element, a pending question or a selection mounted', async () => {
const messages = makeMessages(2_000)
messages[1_991] = {
...messages[1_991]!,
approval: { id: 'approval-1', title: 'Run', description: 'Run a tool' }
}
messages[1_993] = {
...messages[1_993]!,
reasoning: 'thinking',
blocks: [{ id: 'r1', type: 'reasoning', content: 'thinking' }, { id: 't1', type: 'text', content: 'answer' }]
} as Message
const { chat, container } = setup(messages)
measure()
const details = container.querySelector<HTMLDetailsElement>('[data-message-id="m1993"] details')!
act(() => {
details.open = true
details.dispatchEvent(new Event('toggle'))
})
const selected = container.querySelector<HTMLElement>('[data-message-id="m1995"] .message')!
act(() => {
const range = document.createRange()
range.selectNodeContents(selected)
const selection = document.getSelection()!
selection.removeAllRanges()
selection.addRange(range)
document.dispatchEvent(new Event('selectionchange'))
})
act(() => { chat.scrollTop = 0; fireEvent.scroll(chat) })
await frame()
measure()
const ids = renderedIds(container)
expect(ids).toContain('m0')
expect(ids).toEqual(expect.arrayContaining(['m1991', 'm1993', 'm1995']))
// The very same element, so its open state is intact.
expect(container.querySelector('[data-message-id="m1993"] details')).toBe(details)
expect(details.open).toBe(true)
expect(ids).not.toContain('m1500')
document.getSelection()?.removeAllRanges()
})
it('glides to the bottom without instant scroll writes, then pins', async () => {
const messages = makeMessages(500)
const { chat, container } = setup(messages)
measure()
act(() => { chat.scrollTop = 1_000; fireEvent.scroll(chat) })
await frame()
measure()
const scrollTo = vi.mocked(chat.scrollTo)
// A smooth scroll the browser animates: record the call, move nothing yet.
scrollTo.mockImplementation(() => undefined)
scrollTo.mockClear()
fireEvent.click(container.querySelector('.chat-scroll-to-bottom')!)
expect(scrollTo).toHaveBeenCalledTimes(1)
expect(scrollTo.mock.calls[0]![0]).toMatchObject({ behavior: 'smooth' })
// Mid-glide frames, resizes and re-windowing must not write scrollTop.
const before = chat.scrollTop
for (const step of [10_000, 30_000]) {
act(() => {
Object.getOwnPropertyDescriptor(chat, 'scrollTop')!.set!.call(chat, step)
fireEvent.scroll(chat)
})
await frame()
measure()
expect(scrollTo.mock.calls.every(([options]) => (options as ScrollToOptions).behavior === 'smooth')).toBe(true)
}
expect(chat.scrollTop).not.toBe(before)
expect(container.querySelector('.chat-scroll-to-bottom')).toBeNull()
// Arriving at the bottom ends the glide and pins.
act(() => {
chat.scrollTop = chat.scrollHeight
fireEvent.scroll(chat)
})
await frame()
measure()
expect(renderedIds(container).at(-1)).toBe('m499')
expect(chat.scrollTop + viewport).toBe(chat.scrollHeight)
expect(container.querySelector('.chat-scroll-to-bottom')).toBeNull()
})
it('keeps messageIndex-based props for windowed rows', () => { it('keeps messageIndex-based props for windowed rows', () => {
const messages = makeMessages(300) const messages = makeMessages(300)
messages[299] = { ...messages[299]!, state: 'error', content: 'failed' } messages[299] = { ...messages[299]!, state: 'error', content: 'failed' }
+213 -7
View File
@@ -4,6 +4,7 @@ import {
useCallback, useCallback,
useEffect, useEffect,
useLayoutEffect, useLayoutEffect,
useMemo,
useRef, useRef,
useState, useState,
type ReactNode, type ReactNode,
@@ -40,6 +41,8 @@ import type { TimeFormatLocale } from "./time-format";
*/ */
export const messageRenderBatchSize = 80; export const messageRenderBatchSize = 80;
const chatBottomProximity = 96; const chatBottomProximity = 96;
/** Rows kept mounted because the user changed their local state. */
const maxInteractedRows = 8;
type ChatQuickAction = { type ChatQuickAction = {
title: string; title: string;
@@ -251,6 +254,26 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
// Rows kept mounted outside the range: the focused row, the first message // Rows kept mounted outside the range: the focused row, the first message
// after the final "load earlier" step. // after the final "load earlier" step.
const [keptMessageIds, setKeptMessageIds] = useState<readonly string[]>([]); const [keptMessageIds, setKeptMessageIds] = useState<readonly string[]>([]);
// Rows the user changed (toggled a <details>, typed into a question card):
// their local state would be lost if they unmounted. Bounded, oldest out.
const [interactedMessageIds, setInteractedMessageIds] = useState<readonly string[]>([]);
// Rows holding the start and end of a non-empty text selection.
const [selectionMessageIds, setSelectionMessageIds] = useState<readonly string[]>([]);
// Set while the jump-to-bottom button's smooth scroll runs.
const smoothScrollRef = useRef(false);
// Ends the glide; `true` pins to the bottom, `false` just stops tracking.
const endSmoothScrollRef = useRef<((pin: boolean) => void) | undefined>(undefined);
const messageIndexById = useMemo(
() => new Map(messages.map((message, index) => [message.id, index])),
[messages],
);
const pendingMessageIndexes = useMemo(() => {
const indexes: number[] = [];
messages.forEach((message, index) => {
if (message.approval || message.pendingQuestions?.length) indexes.push(index);
});
return indexes;
}, [messages]);
const navigationTarget = const navigationTarget =
noteMessageNavigation?.conversationId === conversation.id noteMessageNavigation?.conversationId === conversation.id
@@ -333,8 +356,20 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
} }
const rendered: number[] = []; const rendered: number[] = [];
for (let index = range.start; index < range.end; index += 1) rendered.push(index); for (let index = range.start; index < range.end; index += 1) rendered.push(index);
for (const id of keptMessageIds) { // Rows that hold user state stay mounted wherever the reader scrolls
const index = messages.findIndex((message) => message.id === id); // (UX parity with the fully mounted list): focus/navigation, rows the user
// interacted with (open details, typed answers), the selected text, and
// pending questions and approvals.
const keptIndexes = new Set<number>();
const keepId = (id: string): void => {
const index = messageIndexById.get(id);
if (index !== undefined) keptIndexes.add(index);
};
keptMessageIds.forEach(keepId);
interactedMessageIds.forEach(keepId);
selectionMessageIds.forEach(keepId);
pendingMessageIndexes.forEach((index) => keptIndexes.add(index));
for (const index of keptIndexes) {
if (index >= windowStart && (index < range.start || index >= range.end)) { if (index >= windowStart && (index < range.start || index >= range.end)) {
rendered.push(index); rendered.push(index);
} }
@@ -479,7 +514,7 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
measuredLayoutRef.current = true; measuredLayoutRef.current = true;
setMeasureVersion((version) => version + 1); setMeasureVersion((version) => version + 1);
} }
if (restorePendingRef.current) return; if (restorePendingRef.current || smoothScrollRef.current) return;
if (pinnedToBottomRef.current) { if (pinnedToBottomRef.current) {
scrollToBottomNow(scrollContainer); scrollToBottomNow(scrollContainer);
return; return;
@@ -517,6 +552,106 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
return () => cancelAnimationFrame(frame); return () => cancelAnimationFrame(frame);
}, [active, captureAnchor, navigationTarget, view, visibleMessageCount]); }, [active, captureAnchor, navigationTarget, view, visibleMessageCount]);
// Rows the user interacts with keep their local state (open details, typed
// question answers, "copied" feedback, Mermaid toggles) as they did when
// every loaded row stayed mounted.
useEffect(() => {
const list = listRef.current;
if (!active || !list) return;
const rowIdOf = (target: EventTarget | null): string | undefined =>
target instanceof Element
? target.closest<HTMLElement>(".message-window-row")?.dataset.messageId
: undefined;
const remember = (event: Event): void => {
if (event.type === "click" && !(event.target instanceof Element && event.target.closest("button, summary, a, input, label"))) return;
const id = rowIdOf(event.target);
if (!id) return;
setInteractedMessageIds((ids) =>
ids.at(-1) === id ? ids : [...ids.filter((item) => item !== id), id].slice(-maxInteractedRows),
);
};
// `toggle` does not bubble; capture sees it on the way down.
list.addEventListener("toggle", remember, true);
list.addEventListener("input", remember);
list.addEventListener("click", remember);
return () => {
list.removeEventListener("toggle", remember, true);
list.removeEventListener("input", remember);
list.removeEventListener("click", remember);
};
}, [active]);
// A text selection keeps the rows at both of its ends mounted, so dragging
// a selection across many messages does not lose its start.
useEffect(() => {
if (!active) return;
const update = (): void => {
const list = listRef.current;
const selection = document.getSelection();
let next: string[] = [];
if (list && selection && !selection.isCollapsed && selection.rangeCount > 0) {
for (const node of [selection.anchorNode, selection.focusNode]) {
const element = node instanceof Element ? node : node?.parentElement;
const id = element && list.contains(element)
? element.closest<HTMLElement>(".message-window-row")?.dataset.messageId
: undefined;
if (id && !next.includes(id)) next.push(id);
}
}
if (next.length === 0) next = [];
setSelectionMessageIds((ids) => (sameIds(ids, next) ? ids : next));
};
document.addEventListener("selectionchange", update);
return () => document.removeEventListener("selectionchange", update);
}, [active]);
// Copying a selection that spans unmounted rows: the browser would only
// copy mounted text, so fill the gaps from the message content.
useEffect(() => {
const list = listRef.current;
if (!active || !list) return;
const onCopy = (event: ClipboardEvent): void => {
const selection = document.getSelection();
if (!selection || selection.isCollapsed || selection.rangeCount === 0 || !event.clipboardData) return;
const range = selection.getRangeAt(0);
if (!range.intersectsNode(list)) return;
const spacers = [...list.querySelectorAll<HTMLElement>(".message-window-spacer")]
.filter((spacer) => spacer.offsetHeight > 0 && range.intersectsNode(spacer));
if (spacers.length === 0) return;
const model = modelRef.current;
if (!model) return;
const indexOfBoundary = (node: Node, offset: number, fallback: number): number => {
const element = node instanceof Element ? (node.childNodes[offset] as Element | undefined) ?? node : node.parentElement;
const id = element instanceof Element ? element.closest<HTMLElement>(".message-window-row")?.dataset.messageId : undefined;
const index = id ? model.messages.findIndex((message) => message.id === id) : -1;
return index >= 0 ? index : fallback;
};
const first = indexOfBoundary(range.startContainer, range.startOffset, model.windowStart);
const last = indexOfBoundary(range.endContainer, range.endOffset, model.messages.length - 1);
const parts: string[] = [];
for (let index = Math.max(first, model.windowStart); index <= last; index += 1) {
const message = model.messages[index];
if (!message) continue;
const row = rowElementsRef.current.get(message.id);
if (row) {
const part = document.createRange();
part.selectNodeContents(row);
if (index === first && row.contains(range.startContainer)) part.setStart(range.startContainer, range.startOffset);
if (index === last && row.contains(range.endContainer)) part.setEnd(range.endContainer, range.endOffset);
const text = part.toString().trim();
if (text) parts.push(text);
} else if (message.content.trim()) {
parts.push(message.content.trim());
}
}
if (parts.length === 0) return;
event.clipboardData.setData("text/plain", parts.join("\n\n"));
event.preventDefault();
};
document.addEventListener("copy", onCopy);
return () => document.removeEventListener("copy", onCopy);
}, [active]);
const handleArticleRef = useCallback( const handleArticleRef = useCallback(
(messageId: string, element: HTMLElement | null): void => { (messageId: string, element: HTMLElement | null): void => {
if (element) { if (element) {
@@ -663,6 +798,18 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
const updateScrollPosition = useCallback((): void => { const updateScrollPosition = useCallback((): void => {
const scrollContainer = scrollRef.current; const scrollContainer = scrollRef.current;
if (!scrollContainer) return; if (!scrollContainer) return;
if (smoothScrollRef.current) {
// Jump-to-bottom glide: re-window around the moving viewport only.
// Arriving at the bottom ends it (`scrollend` is not always delivered).
if (scrollContainer.scrollHeight - scrollContainer.scrollTop - scrollContainer.clientHeight < 1) {
endSmoothScrollRef.current?.(true);
return;
}
saveScrollPosition(scrollContainer);
anchorRef.current = undefined;
scrollFrameRef.current ??= requestAnimationFrame(processScroll);
return;
}
const distanceFromBottom = const distanceFromBottom =
scrollContainer.scrollHeight - scrollContainer.scrollTop - scrollContainer.clientHeight; scrollContainer.scrollHeight - scrollContainer.scrollTop - scrollContainer.clientHeight;
if (distanceFromBottom <= chatBottomProximity) { if (distanceFromBottom <= chatBottomProximity) {
@@ -710,6 +857,8 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
} }
const activated = !wasActiveRef.current; const activated = !wasActiveRef.current;
wasActiveRef.current = true; wasActiveRef.current = true;
// Any scrollTop write would cancel the jump-to-bottom glide.
if (smoothScrollRef.current) return;
// Styles may have changed while hidden (frosted glass toggled). // Styles may have changed while hidden (frosted glass toggled).
if (activated) paddingTopRef.current = undefined; if (activated) paddingTopRef.current = undefined;
const measuredLayout = measuredLayoutRef.current; const measuredLayout = measuredLayoutRef.current;
@@ -799,7 +948,7 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
followFrameRef.current = requestAnimationFrame(() => { followFrameRef.current = requestAnimationFrame(() => {
followFrameRef.current = undefined; followFrameRef.current = undefined;
const container = scrollRef.current; const container = scrollRef.current;
if (!container || !pinnedToBottomRef.current) { if (!container || !pinnedToBottomRef.current || smoothScrollRef.current) {
return; return;
} }
scrollToBottomNow(container); scrollToBottomNow(container);
@@ -848,21 +997,78 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
visibleMessageCount, visibleMessageCount,
]); ]);
useEffect(() => () => endSmoothScrollRef.current?.(false), []);
const scrollToBottom = (): void => { const scrollToBottom = (): void => {
const scrollContainer = scrollRef.current; const scrollContainer = scrollRef.current;
if (!scrollContainer) { if (!scrollContainer) {
return; return;
} }
pinnedToBottomRef.current = true;
showScrollToBottomRef.current = false; showScrollToBottomRef.current = false;
setShowScrollToBottom(false); setShowScrollToBottom(false);
setView(bottomView);
const reduceMotion = const reduceMotion =
typeof window.matchMedia === "function" && typeof window.matchMedia === "function" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches; window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduceMotion) {
pinnedToBottomRef.current = true;
setView(bottomView);
scrollContainer.scrollTo({ top: scrollContainer.scrollHeight, behavior: "auto" });
return;
}
// The glide (UX): rows keep re-windowing around the moving viewport, but
// nothing writes scrollTop until it ends, because any write would cancel
// the browser's smooth scroll. At the end it pins to the real bottom.
endSmoothScrollRef.current?.(false);
smoothScrollRef.current = true;
pinnedToBottomRef.current = false;
const finish = (pin: boolean): void => {
if (!smoothScrollRef.current) return;
smoothScrollRef.current = false;
cleanup();
const container = scrollRef.current;
if (!container) return;
if (pin) {
pinnedToBottomRef.current = true;
anchorRef.current = undefined;
setView(bottomView);
scrollToBottomNow(container);
}
// Re-evaluate the pin and the button from where the glide stopped.
container.dispatchEvent(new Event("scroll"));
};
// A `scrollend` of an earlier scroll can arrive just after the glide
// starts; only one at (or stalled near) the bottom ends it.
const onEnd = (): void => {
const remaining = scrollContainer.scrollHeight - scrollContainer.scrollTop - scrollContainer.clientHeight;
if (remaining <= chatBottomProximity) finish(true);
};
// The reader takes over: keep wherever they stop.
const onInterrupt = (): void => finish(false);
const cleanup = (): void => {
window.clearTimeout(timer);
scrollContainer.removeEventListener("scrollend", onEnd);
scrollContainer.removeEventListener("wheel", onInterrupt);
scrollContainer.removeEventListener("pointerdown", onInterrupt);
scrollContainer.removeEventListener("keydown", onInterrupt);
endSmoothScrollRef.current = undefined;
};
endSmoothScrollRef.current = (pin) => {
if (pin) {
finish(true);
return;
}
smoothScrollRef.current = false;
cleanup();
};
scrollContainer.addEventListener("scrollend", onEnd);
scrollContainer.addEventListener("wheel", onInterrupt, { passive: true });
scrollContainer.addEventListener("pointerdown", onInterrupt);
scrollContainer.addEventListener("keydown", onInterrupt);
// No `scrollend` (already at the target, or unsupported): end anyway.
const timer = window.setTimeout(() => finish(true), 1_500);
scrollContainer.scrollTo({ scrollContainer.scrollTo({
top: scrollContainer.scrollHeight, top: scrollContainer.scrollHeight,
behavior: reduceMotion ? "auto" : "smooth", behavior: "smooth",
}); });
}; };
@@ -331,4 +331,48 @@ describe('ConversationSidebar windowing', () => {
act(() => { vi.advanceTimersByTime(5_000) }) act(() => { vi.advanceTimersByTime(5_000) })
expect(renderedIds(list)[0]).toBe('c800') expect(renderedIds(list)[0]).toBe('c800')
}) })
/** The first visible row and its offset from the viewport top. */
function readerRow(list: HTMLElement): { id: string; top: number } {
const id = visibleIds(list)[0]!
const row = list.querySelector<HTMLElement>(`[data-conversation-window-row="${id}"]`)!
return { id, top: row.getBoundingClientRect().top }
}
it('keeps the visible rows in place when rows are inserted, removed or reordered above them', () => {
vi.useFakeTimers()
const { list, store } = setup(1_000)
scrollList(list, 12_010)
const before = readerRow(list)
expect(before.id).toBe('c300')
// A new conversation at the top.
act(() => store.set((current) => [
{ id: 'new', title: 'New', updatedAt: 2_000_000, messages: [] } as unknown as Conversation, ...current,
]))
expect(readerRow(list)).toEqual(before)
// A delete above the reader.
act(() => store.set((current) => current.filter((item) => item.id !== idOf(5))))
expect(readerRow(list)).toEqual(before)
// Pinning a row from below moves it above the reader.
act(() => store.set((current) => current.map((item) => item.id === idOf(900) ? { ...item, pinned: true } : item)))
expect(readerRow(list)).toEqual(before)
// The 5 s reorder moves a row from below to the top.
act(() => store.set((current) => current.map((item) =>
item.id === idOf(700) ? { ...item, messageSummary: { count: 1, latestMessageAt: 3_000_000 } } : item)))
act(() => { vi.advanceTimersByTime(5_000) })
expect(readerRow(list)).toEqual(before)
})
it('keeps the top of the list at the top when a row is inserted there', () => {
const { list, store } = setup(1_000)
act(() => store.set((current) => [
{ id: 'new', title: 'New', updatedAt: 2_000_000, messages: [] } as unknown as Conversation, ...current,
]))
expect(list.scrollTop).toBe(0)
expect(visibleIds(list)[0]).toBe('new')
})
}) })
@@ -1,3 +1,5 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import i18n from './i18n' import i18n from './i18n'
@@ -81,6 +83,11 @@ describe('MagicNotesWorkspace note list windowing', () => {
expect(first).toHaveAttribute('aria-setsize', String(count)) expect(first).toHaveAttribute('aria-setsize', String(count))
}) })
it('turns native scroll anchoring off for the windowed grid only', () => {
const css = readFileSync(join(process.cwd(), 'src/renderer/src/magic-notes-panel.css'), 'utf8')
expect(css).toMatch(/\.magic-notes-card-grid--windowed\s*\{\s*overflow-anchor:\s*none;\s*\}/)
})
it('mounts the notes at the scroll position', async () => { it('mounts the notes at the scroll position', async () => {
await setup() await setup()
layout.scroll(grid(), 1_500 * rowHeight) layout.scroll(grid(), 1_500 * rowHeight)
@@ -15,6 +15,7 @@ import {
vi vi
} from 'vitest' } from 'vitest'
import { changeUiLocale } from './i18n' import { changeUiLocale } from './i18n'
import { clearMermaidResultCacheForTest } from './MermaidDiagram'
import { import {
InlineMarkdown, InlineMarkdown,
MarkdownRenderer, MarkdownRenderer,
@@ -71,6 +72,7 @@ describe('MarkdownRenderer', () => {
afterEach(() => { afterEach(() => {
cleanup() cleanup()
clearMermaidResultCacheForTest()
delete document.documentElement.dataset.theme delete document.documentElement.dataset.theme
vi.clearAllMocks() vi.clearAllMocks()
}) })
@@ -394,6 +396,19 @@ $\\href{javascript:alert(1)}{unsafe}$`}</MarkdownRenderer>
).not.toBeInTheDocument() ).not.toBeInTheDocument()
}) })
it('reuses a rendered Mermaid diagram when the row remounts (windowed chat)', async () => {
const source = '```mermaid\nflowchart LR\n Cached --> Again\n```'
const first = render(<MarkdownRenderer>{source}</MarkdownRenderer>)
await screen.findByRole('region', { name: 'Mermaid 图表,可横向滚动' })
first.unmount()
expect(mermaidMock.render).toHaveBeenCalledTimes(1)
const again = render(<MarkdownRenderer>{source}</MarkdownRenderer>)
// Shown immediately: no loading state, no second render.
expect(again.container.querySelector('#safe-node')).toBeInTheDocument()
expect(screen.queryByText('正在绘制 Mermaid 图表…')).toBeNull()
expect(mermaidMock.render).toHaveBeenCalledTimes(1)
})
it('renders Mermaid fences with strict bounded configuration', async () => { it('renders Mermaid fences with strict bounded configuration', async () => {
const { container } = render( const { container } = render(
<MarkdownRenderer>{`\`\`\`mermaid <MarkdownRenderer>{`\`\`\`mermaid
+46 -4
View File
@@ -74,6 +74,44 @@ let mermaidModulePromise: Promise<Mermaid> | undefined
let mermaidRenderQueue = Promise.resolve() let mermaidRenderQueue = Promise.resolve()
let mermaidDiagramSequence = 0 let mermaidDiagramSequence = 0
/**
* Rendered results by theme and source. Windowed chat rows remount when they
* scroll back into view; reusing the result keeps them from flashing the
* loading state and changing height (UX parity with fully mounted lists).
*/
const mermaidResultCacheLimit = 64
const mermaidResultCache = new Map<string, MermaidRenderState>()
function mermaidCacheKey(source: string, theme: 'light' | 'dark'): string {
return `${theme}\n${source}`
}
function cachedMermaidResult(source: string, theme: 'light' | 'dark'): MermaidRenderState | undefined {
const key = mermaidCacheKey(source, theme)
const cached = mermaidResultCache.get(key)
if (cached) {
mermaidResultCache.delete(key)
mermaidResultCache.set(key, cached)
}
return cached
}
function rememberMermaidResult(state: MermaidRenderState): void {
const key = mermaidCacheKey(state.source, state.theme)
mermaidResultCache.delete(key)
mermaidResultCache.set(key, state)
while (mermaidResultCache.size > mermaidResultCacheLimit) {
const oldest = mermaidResultCache.keys().next().value
if (oldest === undefined) break
mermaidResultCache.delete(oldest)
}
}
/** Test hook. */
export function clearMermaidResultCacheForTest(): void {
mermaidResultCache.clear()
}
type MermaidRenderState = type MermaidRenderState =
| { | {
source: string source: string
@@ -552,13 +590,15 @@ export function MermaidDiagram({
const sourceId = useId() const sourceId = useId()
const documentTheme = useDocumentTheme() const documentTheme = useDocumentTheme()
const [renderState, setRenderState] = const [renderState, setRenderState] =
useState<MermaidRenderState>() useState<MermaidRenderState | undefined>(() => cachedMermaidResult(source, documentTheme))
const [visibleSource, setVisibleSource] = useState<string>() const [visibleSource, setVisibleSource] = useState<string>()
const [viewerSource, setViewerSource] = useState<string>() const [viewerSource, setViewerSource] = useState<string>()
const sourceVisible = visibleSource === source const sourceVisible = visibleSource === source
const viewerOpen = viewerSource === source const viewerOpen = viewerSource === source
useEffect(() => { useEffect(() => {
// A cached result is read during render below; nothing to do here.
if (mermaidResultCache.has(mermaidCacheKey(source, documentTheme))) return
let active = true let active = true
const renderRevision = renderRevisionRef.current++ const renderRevision = renderRevisionRef.current++
const timeout = window.setTimeout(() => { const timeout = window.setTimeout(() => {
@@ -570,12 +610,14 @@ export function MermaidDiagram({
).then( ).then(
(svg) => { (svg) => {
if (active) { if (active) {
setRenderState({ const state: MermaidRenderState = {
source, source,
status: 'ready', status: 'ready',
svg, svg,
theme: documentTheme theme: documentTheme
}) }
rememberMermaidResult(state)
setRenderState(state)
} }
}, },
() => { () => {
@@ -600,7 +642,7 @@ export function MermaidDiagram({
renderState?.source === source && renderState?.source === source &&
renderState.theme === documentTheme renderState.theme === documentTheme
? renderState ? renderState
: undefined : mermaidResultCache.get(mermaidCacheKey(source, documentTheme))
const retainedSvg = const retainedSvg =
renderState?.status === 'ready' && renderState?.status === 'ready' &&
renderState.source === source renderState.source === source
+62 -12
View File
@@ -1,4 +1,4 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { readFileSync } from 'node:fs' import { readFileSync } from 'node:fs'
import { join } from 'node:path' import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -485,7 +485,7 @@ describe('TerminalPanel', () => {
) )
}) })
it('batches output into one write per frame and ACKs after the write callback', async () => { it('writes idle output at once, merges the rest of the frame, and ACKs after the write callback', async () => {
const frames = new Set<() => void>() const frames = new Set<() => void>()
const frameScheduler = (callback: () => void): (() => void) => { const frameScheduler = (callback: () => void): (() => void) => {
frames.add(callback) frames.add(callback)
@@ -517,18 +517,27 @@ describe('TerminalPanel', () => {
expect(backend.adapter.create).toHaveBeenCalledOnce() expect(backend.adapter.create).toHaveBeenCalledOnce()
) )
for (let sequence = 1; sequence <= 5; sequence += 1) { // The first output (a keystroke echo) is written without waiting for a frame.
backend.emit({ sessionId, sequence: 1, type: 'output', data: '1;' })
await waitFor(() => expect(emulator.write).toHaveBeenCalledWith('1;', expect.any(Function)))
expect(frames.size).toBe(1)
callbacks.shift()?.()
expect(backend.adapter.ack).toHaveBeenCalledWith({ sessionId, sequence: 1 })
for (let sequence = 2; sequence <= 5; sequence += 1) {
backend.emit({ sessionId, sequence, type: 'output', data: `${sequence};` }) backend.emit({ sessionId, sequence, type: 'output', data: `${sequence};` })
} }
await waitFor(() => expect(frames.size).toBe(1)) // Wait until the event queue has consumed every event.
expect(emulator.write).not.toHaveBeenCalled() await act(async () => {
runFrame() for (let tick = 0; tick < 10; tick += 1) await Promise.resolve()
})
expect(emulator.write).toHaveBeenCalledOnce() expect(emulator.write).toHaveBeenCalledOnce()
expect(emulator.write).toHaveBeenCalledWith('1;2;3;4;5;', expect.any(Function)) runFrame()
expect(backend.adapter.ack).not.toHaveBeenCalled() expect(emulator.write).toHaveBeenCalledTimes(2)
callbacks.shift()?.() expect(emulator.write).toHaveBeenLastCalledWith('2;3;4;5;', expect.any(Function))
expect(backend.adapter.ack).toHaveBeenCalledOnce() expect(backend.adapter.ack).toHaveBeenCalledOnce()
expect(backend.adapter.ack).toHaveBeenCalledWith({ sessionId, sequence: 5 }) callbacks.shift()?.()
expect(backend.adapter.ack).toHaveBeenCalledTimes(2)
expect(backend.adapter.ack).toHaveBeenLastCalledWith({ sessionId, sequence: 5 })
// A control event flushes pending output first and is applied after it. // A control event flushes pending output first and is applied after it.
backend.emit({ sessionId, sequence: 6, type: 'output', data: 'bye' }) backend.emit({ sessionId, sequence: 6, type: 'output', data: 'bye' })
@@ -547,7 +556,46 @@ describe('TerminalPanel', () => {
expect(backend.adapter.ack).toHaveBeenLastCalledWith({ sessionId, sequence: 7 }) expect(backend.adapter.ack).toHaveBeenLastCalledWith({ sessionId, sequence: 7 })
) )
expect(backend.adapter.ack).toHaveBeenCalledWith({ sessionId, sequence: 6 }) expect(backend.adapter.ack).toHaveBeenCalledWith({ sessionId, sequence: 6 })
expect(emulator.write).toHaveBeenCalledTimes(2) expect(emulator.write).toHaveBeenCalledTimes(3)
})
it('writes output that arrived before Clear and then clears', async () => {
const frames = new Set<() => void>()
const frameScheduler = (callback: () => void): (() => void) => {
frames.add(callback)
return () => frames.delete(callback)
}
const backend = adapterHarness()
const emulator = emulatorHarness()
const order: string[] = []
const callbacks: Array<() => void> = []
emulator.write.mockImplementation((data: string, callback?: () => void) => {
order.push(`write:${data}`)
if (callback) callbacks.push(callback)
})
vi.mocked(emulator.terminal.clear).mockImplementation(() => { order.push('clear') })
render(
<TerminalPanel
adapter={backend.adapter}
frameScheduler={frameScheduler}
sessionId={sessionId}
target={{ type: 'local' }}
terminalFactory={emulator.factory}
/>
)
await screen.findByText('PowerShell 7')
backend.emit({ sessionId, sequence: 1, type: 'output', data: 'a' })
backend.emit({ sessionId, sequence: 2, type: 'output', data: 'b' })
await waitFor(() => expect(order).toEqual(['write:a']))
await act(async () => {
for (let tick = 0; tick < 10; tick += 1) await Promise.resolve()
})
fireEvent.click(screen.getByRole('button', { name: '清屏' }))
// The pending 'b' is written before the clear, and the clear waits for xterm.
expect(order).toEqual(['write:a', 'write:b'])
callbacks.splice(0).forEach((callback) => callback())
await waitFor(() => expect(order).toEqual(['write:a', 'write:b', 'clear']))
expect(backend.adapter.ack).toHaveBeenLastCalledWith({ sessionId, sequence: 2 })
}) })
it('keeps writing output for a hidden terminal tab', async () => { it('keeps writing output for a hidden terminal tab', async () => {
@@ -569,8 +617,10 @@ describe('TerminalPanel', () => {
await screen.findByText('PowerShell 7') await screen.findByText('PowerShell 7')
backend.emit({ sessionId, sequence: 1, type: 'output', data: 'a' }) backend.emit({ sessionId, sequence: 1, type: 'output', data: 'a' })
backend.emit({ sessionId, sequence: 2, type: 'output', data: 'b' }) backend.emit({ sessionId, sequence: 2, type: 'output', data: 'b' })
// 'a' is written at once; 'b' arrives in the same frame window and is
// written by the timer fallback (hidden windows run no frames).
await waitFor(() => await waitFor(() =>
expect(emulator.write).toHaveBeenCalledWith('ab', expect.any(Function)) expect(emulator.write.mock.calls.map(([data]) => data)).toEqual(['a', 'b'])
) )
await waitFor(() => await waitFor(() =>
expect(backend.adapter.ack).toHaveBeenCalledWith({ sessionId, sequence: 2 }) expect(backend.adapter.ack).toHaveBeenCalledWith({ sessionId, sequence: 2 })
+13 -3
View File
@@ -279,8 +279,9 @@ export function TerminalPanel({
acceptedSequenceRef.current = event.sequence acceptedSequenceRef.current = event.sequence
if (event.type === 'output') { if (event.type === 'output') {
// Merged with neighbours and written once per frame; the batcher ACKs // Written at once when idle, otherwise merged with the rest of the
// the batch's last sequence after xterm has processed it. // frame; the batcher ACKs the batch's last sequence after xterm has
// processed it.
outputBatcherRef.current?.push( outputBatcherRef.current?.push(
event.sessionId, event.sessionId,
event.sequence, event.sequence,
@@ -700,8 +701,17 @@ export function TerminalPanel({
title={t('sidebar.terminal.toolbar.clear')} title={t('sidebar.terminal.toolbar.clear')}
aria-label={t('sidebar.terminal.toolbar.clear')} aria-label={t('sidebar.terminal.toolbar.clear')}
onClick={() => { onClick={() => {
emulatorRef.current?.clear()
emulatorRef.current?.focus() emulatorRef.current?.focus()
// Output that arrived before the click is shown, then cleared:
// write what the batcher holds and wait until xterm processed it
// (xterm's clear() does not queue behind pending writes).
const emulator = emulatorRef.current
const flushed = outputBatcherRef.current?.flush() ?? Promise.resolve()
void flushed.then(() => {
if (emulatorRef.current === emulator) {
emulator?.clear()
}
})
}} }}
> >
<Eraser aria-hidden="true" /> <Eraser aria-hidden="true" />
+1 -1
View File
@@ -614,7 +614,7 @@ export function WorkspaceFilesPanel({
</div> </div>
</WorkspaceGitTools>} </WorkspaceGitTools>}
</div> </div>
<div className="workspace-files__files-view" hidden={activeView !== 'files'}> <div className={treeWindow.windowed ? 'workspace-files__files-view workspace-files__files-view--windowed' : 'workspace-files__files-view'} hidden={activeView !== 'files'}>
{loadingPaths.has(browsedPath) && !root ? ( {loadingPaths.has(browsedPath) && !root ? (
<p className="assistant-sidebar__empty"> <p className="assistant-sidebar__empty">
{t('files.readingWorkspace')} {t('files.readingWorkspace')}
@@ -1,3 +1,5 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { changeUiLocale } from './i18n' import { changeUiLocale } from './i18n'
@@ -109,6 +111,16 @@ describe('WorkspaceFilesPanel tree windowing', () => {
await waitFor(() => expect(screen.getByRole('button', { name: 'a-002.txt 的更多操作' })).toHaveFocus()) await waitFor(() => expect(screen.getByRole('button', { name: 'a-002.txt 的更多操作' })).toHaveFocus())
}, 20_000) }, 20_000)
it('turns native scroll anchoring off on the files view only while the tree is windowed', async () => {
const css = readFileSync(join(process.cwd(), 'src/renderer/src/styles.css'), 'utf8')
expect(css).toMatch(/\.workspace-files__files-view--windowed\s*\{\s*overflow-anchor:\s*none;/)
await setup()
expect(body()).toHaveClass('workspace-files__files-view--windowed')
// Collapsing everything drops below the threshold: native anchoring is back.
for (const directory of directories) fireEvent.click(screen.getByRole('button', { name: directory }))
expect(body()).not.toHaveClass('workspace-files__files-view--windowed')
}, 20_000)
it('collapses and expands directories in the windowed tree', async () => { it('collapses and expands directories in the windowed tree', async () => {
await setup() await setup()
fireEvent.click(screen.getByRole('button', { name: 'a' })) fireEvent.click(screen.getByRole('button', { name: 'a' }))
@@ -519,7 +519,7 @@ export function DocumentsView({
: t('documents.table.noResults')} : t('documents.table.noResults')}
</div> </div>
) : ( ) : (
<div className="knowledge-documents__table-scroll"> <div className={documentWindow.windowed ? 'knowledge-documents__table-scroll knowledge-documents__table-scroll--windowed' : 'knowledge-documents__table-scroll'}>
<table aria-label={t('documents.table.title')} aria-rowcount={documentWindow.windowed ? rows.length + 1 : undefined}> <table aria-label={t('documents.table.title')} aria-rowcount={documentWindow.windowed ? rows.length + 1 : undefined}>
<thead> <thead>
<tr aria-rowindex={documentWindow.windowed ? 1 : undefined}> <tr aria-rowindex={documentWindow.windowed ? 1 : undefined}>
@@ -1,3 +1,5 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import i18n from '../i18n' import i18n from '../i18n'
@@ -126,5 +128,13 @@ describe('DocumentsView windowing', () => {
expect(renderedNames()).toHaveLength(10) expect(renderedNames()).toHaveLength(10)
expect(table().querySelector('.knowledge-documents__spacer')).toBeNull() expect(table().querySelector('.knowledge-documents__spacer')).toBeNull()
expect(table()).not.toHaveAttribute('aria-rowcount') expect(table()).not.toHaveAttribute('aria-rowcount')
expect(document.querySelector('.knowledge-documents__table-scroll')).not.toHaveClass('knowledge-documents__table-scroll--windowed')
})
it('turns native scroll anchoring off on the page only while the table is windowed', () => {
const css = readFileSync(join(process.cwd(), 'src/renderer/src/styles.css'), 'utf8')
expect(css).toMatch(/\.workspace-panel-scroll:has\(\.knowledge-documents__table-scroll--windowed\),\s*\.knowledge-documents__table-scroll--windowed\s*\{\s*overflow-anchor:\s*none;/)
setup()
expect(document.querySelector('.knowledge-documents__table-scroll')).toHaveClass('knowledge-documents__table-scroll--windowed')
}) })
}) })
+3
View File
@@ -35,5 +35,8 @@
stand in for unmounted rows. Row spacing moves into each row's padding so stand in for unmounted rows. Row spacing moves into each row's padding so
spacer heights and measured rows add up exactly. */ spacer heights and measured rows add up exactly. */
.magic-notes-list.magic-notes-card-grid.magic-notes-card-grid--windowed { row-gap: 0; } .magic-notes-list.magic-notes-card-grid.magic-notes-card-grid--windowed { row-gap: 0; }
/* The grid keeps the reader's row in place itself; native scroll anchoring
could pick a spacer and fight the correction. */
.magic-notes-card-grid--windowed { overflow-anchor: none; }
.magic-notes-card-grid--windowed > .magic-notes-window-row { display: grid; grid-column: 1 / -1; grid-template-columns: subgrid; align-items: stretch; } .magic-notes-card-grid--windowed > .magic-notes-window-row { display: grid; grid-column: 1 / -1; grid-template-columns: subgrid; align-items: stretch; }
.magic-notes-card-grid--windowed > .magic-notes-list__spacer { grid-column: 1 / -1; } .magic-notes-card-grid--windowed > .magic-notes-list__spacer { grid-column: 1 / -1; }
@@ -34,7 +34,7 @@ describe('TerminalOutputBatcher', () => {
vi.unstubAllGlobals() vi.unstubAllGlobals()
}) })
it('writes once per frame in order and ACKs the last sequence after the write callback', async () => { it('writes idle output at once, merges the rest of the frame, and ACKs the last sequence after the write callback', async () => {
const frames = manualScheduler() const frames = manualScheduler()
const writes: Array<{ data: string; done: () => void }> = [] const writes: Array<{ data: string; done: () => void }> = []
const acks: number[] = [] const acks: number[] = []
@@ -43,43 +43,62 @@ describe('TerminalOutputBatcher', () => {
acknowledge: (_, sequence) => acks.push(sequence), acknowledge: (_, sequence) => acks.push(sequence),
schedule: frames.schedule schedule: frames.schedule
}) })
// Leading edge: a keystroke echo is written without waiting for a frame.
batcher.push('s', 2, 'k')
expect(writes.map((write) => write.data)).toEqual(['k'])
expect(frames.pendingFrames()).toBe(1)
writes[0]!.done()
expect(acks).toEqual([2])
// Output in the same frame is merged and written once when the frame runs.
batcher.push('s', 3, 'a') batcher.push('s', 3, 'a')
batcher.push('s', 4, 'b') batcher.push('s', 4, 'b')
batcher.push('s', 5, 'c') batcher.push('s', 5, 'c')
expect(writes).toHaveLength(1)
frames.runFrame()
expect(writes.map((write) => write.data)).toEqual(['k', 'abc'])
expect(acks).toEqual([2])
writes[1]!.done()
expect(acks).toEqual([2, 5])
// A busy frame keeps the window open: the next output waits a frame.
expect(frames.pendingFrames()).toBe(1) expect(frames.pendingFrames()).toBe(1)
expect(writes).toHaveLength(0)
frames.runFrame()
expect(writes.map((write) => write.data)).toEqual(['abc'])
expect(acks).toEqual([])
writes[0]!.done()
expect(acks).toEqual([5])
batcher.push('s', 6, 'd') batcher.push('s', 6, 'd')
expect(writes).toHaveLength(2)
frames.runFrame() frames.runFrame()
writes[1]!.done() writes[2]!.done()
writes[1]!.done() writes[2]!.done()
expect(writes.map((write) => write.data)).toEqual(['abc', 'd']) expect(writes.map((write) => write.data)).toEqual(['k', 'abc', 'd'])
expect(acks).toEqual([5, 6]) expect(acks).toEqual([2, 5, 6])
// A frame without output returns to idle: the next output is immediate.
frames.runFrame()
expect(frames.pendingFrames()).toBe(0)
batcher.push('s', 7, 'e')
expect(writes.map((write) => write.data)).toEqual(['k', 'abc', 'd', 'e'])
batcher.dispose() batcher.dispose()
}) })
it('flush writes pending output immediately and waits for the emulator', async () => { it('flush writes pending output immediately and waits for the emulator', async () => {
const frames = manualScheduler() const frames = manualScheduler()
let release: (() => void) | undefined let release: (() => void) | undefined
const written: string[] = []
const batcher = new TerminalOutputBatcher({ const batcher = new TerminalOutputBatcher({
write: (_, done) => { write: (data, done) => {
written.push(data)
release = done release = done
}, },
acknowledge: vi.fn(), acknowledge: vi.fn(),
schedule: frames.schedule schedule: frames.schedule
}) })
batcher.push('s', 1, 'x') batcher.push('s', 1, 'x')
batcher.push('s', 2, 'y')
expect(written).toEqual(['x'])
let flushed = false let flushed = false
const flush = batcher.flush().then(() => { const flush = batcher.flush().then(() => {
flushed = true flushed = true
}) })
expect(frames.pendingFrames()).toBe(0) expect(frames.pendingFrames()).toBe(0)
expect(written).toEqual(['x', 'y'])
await Promise.resolve() await Promise.resolve()
expect(flushed).toBe(false) expect(flushed).toBe(false)
release?.() release?.()
@@ -97,10 +116,12 @@ describe('TerminalOutputBatcher', () => {
acknowledge, acknowledge,
schedule: frames.schedule schedule: frames.schedule
}) })
batcher.push('old', 8, 'shown')
batcher.push('old', 9, 'stale') batcher.push('old', 9, 'stale')
batcher.reset() batcher.reset()
frames.runFrame() frames.runFrame()
expect(write).not.toHaveBeenCalled() expect(write).toHaveBeenCalledOnce()
expect(write).toHaveBeenCalledWith('shown', expect.any(Function))
expect(acknowledge).toHaveBeenCalledWith('old', 9) expect(acknowledge).toHaveBeenCalledWith('old', 9)
batcher.dispose() batcher.dispose()
}) })
@@ -139,9 +160,14 @@ describe('TerminalOutputBatcher', () => {
}) })
batcher.push('s', 1, 'hidden ') batcher.push('s', 1, 'hidden ')
batcher.push('s', 2, 'output') batcher.push('s', 2, 'output')
batcher.push('s', 3, '!')
expect(writes).toEqual(['hidden '])
vi.advanceTimersByTime(TERMINAL_FRAME_FALLBACK_MS) vi.advanceTimersByTime(TERMINAL_FRAME_FALLBACK_MS)
expect(writes).toEqual(['hidden output']) expect(writes).toEqual(['hidden ', 'output!'])
expect(acks).toEqual([2]) expect(acks).toEqual([1, 3])
// The window closes after an empty fallback tick; no timers linger.
vi.advanceTimersByTime(TERMINAL_FRAME_FALLBACK_MS)
expect(vi.getTimerCount()).toBe(0)
batcher.dispose() batcher.dispose()
}) })
+32 -5
View File
@@ -1,6 +1,8 @@
/** /**
* Renderer-side terminal output batching (P3): output events are merged and * Renderer-side terminal output batching (P3): output arriving while idle is
* written to the emulator at most once per animation frame, in sequence order. * written to the emulator at once (no added echo latency); output arriving
* within the same frame after that is merged and written on the next
* animation frame, in sequence order.
* Each batch is acknowledged with its highest sequence after the emulator's * Each batch is acknowledged with its highest sequence after the emulator's
* write callback, which is what Main's ACK-based backpressure expects. * write callback, which is what Main's ACK-based backpressure expects.
*/ */
@@ -71,7 +73,14 @@ export class TerminalOutputBatcher {
return this.pending.length > 0 return this.pending.length > 0
} }
/** Queues output; events must be pushed in increasing sequence order. */ /**
* Queues output; events must be pushed in increasing sequence order.
*
* Leading edge: output arriving while idle (no write in the current frame
* window) is written at once, so keystroke echo does not wait for a frame.
* Output arriving after that within the same frame is merged and written
* when the frame runs; a steady stream thus writes at most once per frame.
*/
push(sessionId: string, sequence: number, data: string): void { push(sessionId: string, sequence: number, data: string): void {
if (this.disposed) { if (this.disposed) {
return return
@@ -83,9 +92,23 @@ export class TerminalOutputBatcher {
this.pendingSessionId = sessionId this.pendingSessionId = sessionId
this.pendingSequence = sequence this.pendingSequence = sequence
this.pending.push(data) this.pending.push(data)
this.cancelFrame ??= this.schedule(() => { if (this.cancelFrame) {
return
}
void this.writePending()
this.openFrameWindow()
}
/** Until the frame runs, further output is merged instead of written. */
private openFrameWindow(): void {
this.cancelFrame = this.schedule(() => {
this.cancelFrame = undefined this.cancelFrame = undefined
void this.flush() if (this.disposed || this.pending.length === 0) {
// Nothing arrived during the frame: idle again.
return
}
void this.writePending()
this.openFrameWindow()
}) })
} }
@@ -99,6 +122,10 @@ export class TerminalOutputBatcher {
} }
this.cancelFrame?.() this.cancelFrame?.()
this.cancelFrame = undefined this.cancelFrame = undefined
return this.writePending()
}
private writePending(): Promise<void> {
if (this.pending.length > 0) { if (this.pending.length > 0) {
const data = this.pending.length === 1 ? this.pending[0]! : this.pending.join('') const data = this.pending.length === 1 ? this.pending[0]! : this.pending.join('')
const sessionId = this.pendingSessionId! const sessionId = this.pendingSessionId!
+70 -6
View File
@@ -184,6 +184,13 @@ export function useListWindow({
const rowRefCallbacks = useRef(new Map<string, (element: HTMLElement | null) => void>()) const rowRefCallbacks = useRef(new Map<string, (element: HTMLElement | null) => void>())
const observerRef = useRef<ResizeObserver | undefined>(undefined) const observerRef = useRef<ResizeObserver | undefined>(undefined)
const scrollAnchorRef = useRef<{ id: string; top: number } | undefined>(undefined) const scrollAnchorRef = useRef<{ id: string; top: number } | undefined>(undefined)
// List-relative scroll position seen at the last scroll event or commit, and
// the ids and offsets of the last committed windowed layout. Together they
// locate the reader's row when ids change, without reading layout per scroll.
const scrollTopRef = useRef(0)
const committedRef = useRef<
{ ids: readonly string[]; offsets: readonly number[]; viewport: number } | undefined
>(undefined)
const scopeRef = useRef(scope) const scopeRef = useRef(scope)
const scrollParentRef = useRef(scrollParent) const scrollParentRef = useRef(scrollParent)
useLayoutEffect(() => { useLayoutEffect(() => {
@@ -284,6 +291,7 @@ export function useListWindow({
const layoutNow = layoutRef.current const layoutNow = layoutRef.current
if (!current || !layoutNow || !activeRef.current || isHidden(current.list)) return if (!current || !layoutNow || !activeRef.current || isHidden(current.list)) return
const { top, viewport } = position(current) const { top, viewport } = position(current)
scrollTopRef.current = top
if (heights.setViewport(viewport)) setMeasureVersion((version) => version + 1) if (heights.setViewport(viewport)) setMeasureVersion((version) => version + 1)
// Hysteresis: re-window only when the viewport (plus a margin) leaves the // Hysteresis: re-window only when the viewport (plus a margin) leaves the
// mounted rows, so a steady scroll remounts rows every screen or so. // mounted rows, so a steady scroll remounts rows every screen or so.
@@ -348,28 +356,45 @@ export function useListWindow({
const current = geometry() const current = geometry()
if (!current) return if (!current) return
const { container } = current const { container } = current
const committed = committedRef.current
committedRef.current = windowed && layoutRef.current
? { ids, offsets: layoutRef.current.offsets, viewport: layoutRef.current.viewport }
: undefined
let idsAnchor: { id: string; top: number } | undefined
if (scopeRef.current !== scope) { if (scopeRef.current !== scope) {
scopeRef.current = scope scopeRef.current = scope
scrollAnchorRef.current = undefined scrollAnchorRef.current = undefined
// New search results start at the top; short lists keep the browser's // New search results start at the top; short lists keep the browser's
// own clamping, as before windowing. // own clamping, as before windowing.
if (resetScrollOnScopeChange && windowed && container && container.scrollTop !== 0) container.scrollTop = 0 if (resetScrollOnScopeChange && windowed && container && container.scrollTop !== 0) container.scrollTop = 0
} else if (windowed && committed && committed.ids !== ids) {
// Rows were inserted, removed or reordered: keep the reader's row where
// it was, like native scroll anchoring did before windowing (which the
// windowed list turns off). The top of the list stays the top.
idsAnchor = idsChangeAnchor(committed.ids, committed.offsets, ids, scrollTopRef.current, committed.viewport)
} }
const anchor = scrollAnchorRef.current const anchor = idsAnchor ?? scrollAnchorRef.current
scrollAnchorRef.current = undefined scrollAnchorRef.current = undefined
if (windowed && anchor) { if (windowed && anchor) {
const element = rowElements.current.get(anchor.id) const element = rowElements.current.get(anchor.id)
let delta: number | undefined
if (element) { if (element) {
const delta = element.getBoundingClientRect().top - viewportTop(container) - anchor.top delta = element.getBoundingClientRect().top - viewportTop(container) - anchor.top
if (Math.abs(delta) >= 0.5) { } else if (idsAnchor && layoutRef.current) {
if (container) container.scrollTop += delta // The row is not mounted (yet): its laid-out top is its cached offset.
else window.scrollBy(0, delta) const index = ids.indexOf(anchor.id)
} const offset = index >= 0 ? layoutRef.current.offsets[index] : undefined
if (offset !== undefined) delta = offset - scrollTopRef.current - anchor.top
}
if (delta !== undefined && Math.abs(delta) >= 0.5) {
if (container) container.scrollTop += delta
else window.scrollBy(0, delta)
} }
} }
const layoutNow = layoutRef.current const layoutNow = layoutRef.current
if (!windowed || !layoutNow || !active || isHidden(current.list)) return if (!windowed || !layoutNow || !active || isHidden(current.list)) return
const { top } = position(current) const { top } = position(current)
scrollTopRef.current = top
if (!coversViewport(layoutNow, top) && Math.abs(top - anchorTop) >= 1) setAnchorTop(top) if (!coversViewport(layoutNow, top) && Math.abs(top - anchorTop) >= 1) setAnchorTop(top)
}, [active, anchorTop, geometry, ids, keepKey, measureVersion, position, resetScrollOnScopeChange, scope, windowed]) }, [active, anchorTop, geometry, ids, keepKey, measureVersion, position, resetScrollOnScopeChange, scope, windowed])
@@ -421,6 +446,45 @@ export function useListWindow({
return { windowed, segments, rowRef, onScroll, onFocus, onBlur } return { windowed, segments, rowRef, onScroll, onFocus, onBlur }
} }
/**
* The row to hold in place when the ids change: the first row of the previous
* layout reaching into the viewport that is still in the list (as native
* scroll anchoring skips removed nodes), with its offset from the viewport top.
* Uses cached offsets only. None at the top of the list or when nothing changed.
*/
export function idsChangeAnchor(
previousIds: readonly string[],
previousOffsets: readonly number[],
ids: readonly string[],
scrollTop: number,
viewportHeight: number,
): { id: string; top: number } | undefined {
if (scrollTop <= 0 || sameIds(previousIds, ids)) return undefined
const count = Math.min(previousIds.length, previousOffsets.length - 1)
// First row whose bottom is below the viewport top.
let low = 0
let high = count
while (low < high) {
const middle = (low + high) >> 1
if (previousOffsets[middle + 1]! > scrollTop) high = middle
else low = middle + 1
}
const present = new Set(ids)
// Only rows that were in view: an anchor far below would move the view.
for (let index = low; index < count && previousOffsets[index]! < scrollTop + viewportHeight; index += 1) {
const id = previousIds[index]!
if (present.has(id)) return { id, top: previousOffsets[index]! - scrollTop }
}
return undefined
}
function sameIds(left: readonly string[], right: readonly string[]): boolean {
if (left === right) return true
if (left.length !== right.length) return false
for (let index = 0; index < left.length; index += 1) if (left[index] !== right[index]) return false
return true
}
/** A list inside a `hidden` subtree has no geometry; leave its window alone. */ /** A list inside a `hidden` subtree has no geometry; leave its window alone. */
function isHidden(list: HTMLElement): boolean { function isHidden(list: HTMLElement): boolean {
return list.closest('[hidden]') !== null return list.closest('[hidden]') !== null
+20 -1
View File
@@ -607,7 +607,26 @@ try {
} }
await sleep(300) await sleep(300)
const after = await js(scroller) const after = await js(scroller)
return { fields: { scrollTopBefore: before, scrollTopAfter: after, scrolledPx: before === null || after === null ? null : before - after } } // UX: large jumps (scrollbar drag, Home/End, flick) must not paint a frame
// whose viewport is not covered by mounted rows (a blank screen).
const blankFrames = await js(`new Promise((resolve) => {
const c = document.querySelector('.chat-history-pane[data-active=true] .chat');
const covered = () => {
const v = c.getBoundingClientRect(), mid = v.top + v.height / 2;
return [...c.querySelectorAll('.message-window-row')].some((row) => { const r = row.getBoundingClientRect(); return r.top <= mid && r.bottom >= mid; });
};
const targets = [0.5, 0.1, 0.9, 0.3, 0.7, 0.05, 0.95, 0.2, 0.8, 0.4].map((f) => Math.round((c.scrollHeight - c.clientHeight) * f));
let blank = 0, checked = 0;
const step = () => {
const target = targets.shift();
if (target === undefined) { resolve({ blank, checked }); return; }
c.scrollTop = target;
// The frame the jump is painted in: rAF runs before paint, after rendering work queued by the scroll.
requestAnimationFrame(() => requestAnimationFrame(() => { checked += 1; if (!covered()) blank += 1; setTimeout(step, 50); }));
};
step();
})`)
return { fields: { scrollTopBefore: before, scrollTopAfter: after, scrolledPx: before === null || after === null ? null : before - after, jumpBlankFrames: blankFrames.blank, jumpChecks: blankFrames.checked } }
})) }))
await screenshot('long-conversation') await screenshot('long-conversation')
+14
View File
@@ -163,8 +163,22 @@ app.whenReady().then(async () => {
const clicks = await evaluate(`Number(document.querySelector('[data-clicks]').dataset.clicks)`) const clicks = await evaluate(`Number(document.querySelector('[data-clicks]').dataset.clicks)`)
await click('.topbar button') await click('.topbar button')
await waitFor(`Number(document.querySelector('[data-clicks]').dataset.clicks) === ${clicks + 1}`) await waitFor(`Number(document.querySelector('[data-clicks]').dataset.clicks) === ${clicks + 1}`)
// The jump-to-bottom button glides (UX): sample scrollTop each frame after
// the click; an instant jump would show no position between start and end.
await evaluate(`(() => { const e = document.querySelector('.chat-history-pane[data-active="true"] .chat');
const samples = window.__jumpSamples = [e.scrollTop];
const sample = () => { samples.push(e.scrollTop); if (samples.length < 90) requestAnimationFrame(sample); };
requestAnimationFrame(sample); })()`)
await click('.chat-scroll-to-bottom') await click('.chat-scroll-to-bottom')
await waitFor(`(() => { const e = document.querySelector('.chat-history-pane[data-active="true"] .chat'); return e.scrollHeight - e.clientHeight - e.scrollTop < 1; })()`) await waitFor(`(() => { const e = document.querySelector('.chat-history-pane[data-active="true"] .chat'); return e.scrollHeight - e.clientHeight - e.scrollTop < 1; })()`)
await waitFor('window.__jumpSamples.length >= 90')
const glide = await evaluate(`(() => { const e = document.querySelector('.chat-history-pane[data-active="true"] .chat');
const end = e.scrollHeight - e.clientHeight, s = window.__jumpSamples, start = s[0];
const between = new Set(s.filter(v => v > start + 1 && v < end - 1).map(Math.round));
const reappeared = !!document.querySelector('.chat-scroll-to-bottom');
return { start, end, between: between.size, reappeared, samples: s.slice(0, 12).map(Math.round) }; })()`)
assert.ok(glide.between >= 3, `${theme}/${enabled}: jump-to-bottom animates ${JSON.stringify(glide)}`)
assert.equal(glide.reappeared, false, `${theme}/${enabled}: button stays hidden after the glide`)
const bottom = await evaluate(`document.querySelector('.chat-history-pane[data-active="true"] article:last-child').getBoundingClientRect().bottom`) const bottom = await evaluate(`document.querySelector('.chat-history-pane[data-active="true"] article:last-child').getBoundingClientRect().bottom`)
assert.ok(bottom <= geometry.composer.top, 'last message is above composer') assert.ok(bottom <= geometry.composer.top, 'last message is above composer')
assert.equal(await evaluate(`document.querySelector('[aria-label="Composer"]').value`), 'Draft') assert.equal(await evaluate(`document.querySelector('[aria-label="Composer"]').value`), 'Draft')