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:
@@ -80,6 +80,17 @@ Main 负责窗口、生命周期、权限和消息转发。数据库查询、文
|
||||
- 异步改造要先确认“先写入、后读取”的顺序,没有测试覆盖就不合并。
|
||||
- 风险高、收益小,或者会改变现有功能的优化先不做,在计划文档里记录理由。
|
||||
|
||||
### P10 体验不倒退
|
||||
|
||||
性能优化只能让体验变好,不能用体验换速度。“行为不变”不仅指数据和结果,也包括用户能感知到的交互:
|
||||
|
||||
- **动画和过渡保持原样。** 平滑滚动(如“到底部”按钮)、展开收起、悬停、淡入淡出都不能因为优化而消失。任何瞬间写入 `scrollTop`/`scrollTo` 的代码都会打断正在进行的平滑滚动,必须在平滑滚动期间暂停。
|
||||
- **只渲染可见部分(P5)时,用户状态不能丢。** 行被卸载后,它的局部状态也会消失。以下行必须保持挂载,或者把状态存到行外:有焦点的行、用户展开过 `<details>` 或输入过内容的行、选中文本的两端、待回答的问题和待审批项、打开菜单或正在重命名的行。
|
||||
- **只渲染可见部分时,基本操作要和完整列表一致:** 跨行选择和复制、滚动位置不因上方插入或删除而跳动(关闭原生 `overflow-anchor` 时要自己做锚定)、快速拖动滚动条时没有空白帧、渲染结果缓存让重新挂载的行不出现“加载中”。
|
||||
- **合并和批处理不能增加交互延迟。** 第一条数据立即处理(leading edge),只合并之后同一帧内的数据。打字回显、终端回显不能多等一帧。
|
||||
- **低频同步不能让用户看到旧数据。** 复制、导出、保存这类读取全局状态的操作,要先把待同步的数据刷进去。
|
||||
- **体验要有测试。** 涉及动画、滚动、焦点的改动,需要真实 Electron 测试(逐帧采样)或单元测试,并确认测试在旧代码上会失败。
|
||||
|
||||
### P9 用工具守住规则
|
||||
|
||||
- lint 规则和性能阈值是合并前的硬性条件,不靠人工记得。
|
||||
@@ -98,6 +109,9 @@ Main 负责窗口、生命周期、权限和消息转发。数据库查询、文
|
||||
6. 有没有在启动阶段新增同步初始化?
|
||||
7. 是否附上了 `perf:app`、`perf:main` 或 `perf:knowledge` 的前后对比?
|
||||
8. 顺序、取消、持久化和恢复,有没有测试覆盖?
|
||||
9. 动画、平滑滚动、过渡还在吗?有没有新增会打断平滑滚动的瞬间滚动写入?
|
||||
10. 只渲染可见部分的列表,滚走再滚回来,展开状态、输入内容、选区、焦点还在吗?
|
||||
11. 合并或延迟处理后,第一次响应(打字回显、点击反馈)有没有变慢?复制、导出读到的是不是屏幕上的最新内容?
|
||||
|
||||
## 4. 当前差距
|
||||
|
||||
|
||||
@@ -868,7 +868,7 @@ Renderer 主线程降速 4 倍(Main 不降速,每次 reload 后重新应用
|
||||
|
||||
### 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 次均通过。
|
||||
|
||||
### 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 测试发现后改为优先使用后者。
|
||||
|
||||
### PERF-14 状态
|
||||
|
||||
@@ -3988,6 +3988,9 @@ export function registerIpcHandlers(
|
||||
let remoteEventBatchClosed = false
|
||||
const remoteEventBatcher = new RemoteEventBatcher<RemoteBatchEvent>({
|
||||
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) =>
|
||||
remoteConversationRecovery
|
||||
? assistantDatabase.appendRemoteConversationTaskEventsBatch({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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) => ({
|
||||
source: 'remote-semantic-transcript' as const,
|
||||
@@ -46,6 +46,49 @@ describe('RemoteEventBatcher', () => {
|
||||
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', () => {
|
||||
vi.useFakeTimers()
|
||||
const onError = vi.fn()
|
||||
@@ -55,7 +98,7 @@ describe('RemoteEventBatcher', () => {
|
||||
const onCommitted = vi.fn()
|
||||
const batcher = new RemoteEventBatcher<string>({ persist, onError })
|
||||
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(() => batcher.add(provenance(1), 'checkpoint')).toThrow('disk full')
|
||||
expect(() => batcher.flush()).toThrow('disk full')
|
||||
|
||||
@@ -18,6 +18,12 @@ export type RemoteEventBatcherOptions<TEvent> = {
|
||||
persist(entries: readonly RemoteEventBatchEntry<TEvent>[]): readonly boolean[]
|
||||
/** Called when a timer-driven flush fails (for example to abort the run). */
|
||||
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. */
|
||||
flushIntervalMs?: number
|
||||
maximumEvents?: number
|
||||
@@ -27,7 +33,11 @@ type PendingEntry<TEvent> = RemoteEventBatchEntry<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
|
||||
|
||||
/**
|
||||
@@ -47,6 +57,7 @@ export const REMOTE_EVENT_BATCH_MAXIMUM_EVENTS = 256
|
||||
export class RemoteEventBatcher<TEvent> {
|
||||
private readonly persist: RemoteEventBatcherOptions<TEvent>['persist']
|
||||
private readonly onError: RemoteEventBatcherOptions<TEvent>['onError']
|
||||
private readonly onTimerFlushed: RemoteEventBatcherOptions<TEvent>['onTimerFlushed']
|
||||
private readonly flushIntervalMs: number
|
||||
private readonly maximumEvents: number
|
||||
private pending: PendingEntry<TEvent>[] = []
|
||||
@@ -56,6 +67,7 @@ export class RemoteEventBatcher<TEvent> {
|
||||
constructor(options: RemoteEventBatcherOptions<TEvent>) {
|
||||
this.persist = options.persist
|
||||
this.onError = options.onError
|
||||
this.onTimerFlushed = options.onTimerFlushed
|
||||
this.flushIntervalMs =
|
||||
options.flushIntervalMs ?? REMOTE_EVENT_BATCH_FLUSH_INTERVAL_MS
|
||||
this.maximumEvents =
|
||||
@@ -132,6 +144,7 @@ export class RemoteEventBatcher<TEvent> {
|
||||
this.timer = undefined
|
||||
try {
|
||||
this.flush()
|
||||
this.onTimerFlushed?.()
|
||||
} catch (error) {
|
||||
this.onError?.(error)
|
||||
}
|
||||
|
||||
@@ -4842,6 +4842,48 @@ describe("App", () => {
|
||||
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 () => {
|
||||
render(<App />);
|
||||
const composer = await screen.findByLabelText("向 GoodBuddy 提问");
|
||||
|
||||
@@ -5399,11 +5399,17 @@ function App(): React.JSX.Element {
|
||||
[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;
|
||||
const snapshot = await window.goodbuddy.conversations.get(item.id);
|
||||
// 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 (
|
||||
|
||||
@@ -371,6 +371,88 @@ describe('ChatHistoryPane windowing', () => {
|
||||
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', () => {
|
||||
const messages = makeMessages(300)
|
||||
messages[299] = { ...messages[299]!, state: 'error', content: 'failed' }
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
@@ -40,6 +41,8 @@ import type { TimeFormatLocale } from "./time-format";
|
||||
*/
|
||||
export const messageRenderBatchSize = 80;
|
||||
const chatBottomProximity = 96;
|
||||
/** Rows kept mounted because the user changed their local state. */
|
||||
const maxInteractedRows = 8;
|
||||
|
||||
type ChatQuickAction = {
|
||||
title: string;
|
||||
@@ -251,6 +254,26 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
|
||||
// Rows kept mounted outside the range: the focused row, the first message
|
||||
// after the final "load earlier" step.
|
||||
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 =
|
||||
noteMessageNavigation?.conversationId === conversation.id
|
||||
@@ -333,8 +356,20 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
|
||||
}
|
||||
const rendered: number[] = [];
|
||||
for (let index = range.start; index < range.end; index += 1) rendered.push(index);
|
||||
for (const id of keptMessageIds) {
|
||||
const index = messages.findIndex((message) => message.id === id);
|
||||
// Rows that hold user state stay mounted wherever the reader scrolls
|
||||
// (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)) {
|
||||
rendered.push(index);
|
||||
}
|
||||
@@ -479,7 +514,7 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
|
||||
measuredLayoutRef.current = true;
|
||||
setMeasureVersion((version) => version + 1);
|
||||
}
|
||||
if (restorePendingRef.current) return;
|
||||
if (restorePendingRef.current || smoothScrollRef.current) return;
|
||||
if (pinnedToBottomRef.current) {
|
||||
scrollToBottomNow(scrollContainer);
|
||||
return;
|
||||
@@ -517,6 +552,106 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [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(
|
||||
(messageId: string, element: HTMLElement | null): void => {
|
||||
if (element) {
|
||||
@@ -663,6 +798,18 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
|
||||
const updateScrollPosition = useCallback((): void => {
|
||||
const scrollContainer = scrollRef.current;
|
||||
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 =
|
||||
scrollContainer.scrollHeight - scrollContainer.scrollTop - scrollContainer.clientHeight;
|
||||
if (distanceFromBottom <= chatBottomProximity) {
|
||||
@@ -710,6 +857,8 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
|
||||
}
|
||||
const activated = !wasActiveRef.current;
|
||||
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).
|
||||
if (activated) paddingTopRef.current = undefined;
|
||||
const measuredLayout = measuredLayoutRef.current;
|
||||
@@ -799,7 +948,7 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
|
||||
followFrameRef.current = requestAnimationFrame(() => {
|
||||
followFrameRef.current = undefined;
|
||||
const container = scrollRef.current;
|
||||
if (!container || !pinnedToBottomRef.current) {
|
||||
if (!container || !pinnedToBottomRef.current || smoothScrollRef.current) {
|
||||
return;
|
||||
}
|
||||
scrollToBottomNow(container);
|
||||
@@ -848,21 +997,78 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
|
||||
visibleMessageCount,
|
||||
]);
|
||||
|
||||
useEffect(() => () => endSmoothScrollRef.current?.(false), []);
|
||||
|
||||
const scrollToBottom = (): void => {
|
||||
const scrollContainer = scrollRef.current;
|
||||
if (!scrollContainer) {
|
||||
return;
|
||||
}
|
||||
pinnedToBottomRef.current = true;
|
||||
showScrollToBottomRef.current = false;
|
||||
setShowScrollToBottom(false);
|
||||
setView(bottomView);
|
||||
const reduceMotion =
|
||||
typeof window.matchMedia === "function" &&
|
||||
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({
|
||||
top: scrollContainer.scrollHeight,
|
||||
behavior: reduceMotion ? "auto" : "smooth",
|
||||
behavior: "smooth",
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -331,4 +331,48 @@ describe('ConversationSidebar windowing', () => {
|
||||
act(() => { vi.advanceTimersByTime(5_000) })
|
||||
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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import i18n from './i18n'
|
||||
@@ -81,6 +83,11 @@ describe('MagicNotesWorkspace note list windowing', () => {
|
||||
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 () => {
|
||||
await setup()
|
||||
layout.scroll(grid(), 1_500 * rowHeight)
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
vi
|
||||
} from 'vitest'
|
||||
import { changeUiLocale } from './i18n'
|
||||
import { clearMermaidResultCacheForTest } from './MermaidDiagram'
|
||||
import {
|
||||
InlineMarkdown,
|
||||
MarkdownRenderer,
|
||||
@@ -71,6 +72,7 @@ describe('MarkdownRenderer', () => {
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
clearMermaidResultCacheForTest()
|
||||
delete document.documentElement.dataset.theme
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
@@ -394,6 +396,19 @@ $\\href{javascript:alert(1)}{unsafe}$`}</MarkdownRenderer>
|
||||
).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 () => {
|
||||
const { container } = render(
|
||||
<MarkdownRenderer>{`\`\`\`mermaid
|
||||
|
||||
@@ -74,6 +74,44 @@ let mermaidModulePromise: Promise<Mermaid> | undefined
|
||||
let mermaidRenderQueue = Promise.resolve()
|
||||
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 =
|
||||
| {
|
||||
source: string
|
||||
@@ -552,13 +590,15 @@ export function MermaidDiagram({
|
||||
const sourceId = useId()
|
||||
const documentTheme = useDocumentTheme()
|
||||
const [renderState, setRenderState] =
|
||||
useState<MermaidRenderState>()
|
||||
useState<MermaidRenderState | undefined>(() => cachedMermaidResult(source, documentTheme))
|
||||
const [visibleSource, setVisibleSource] = useState<string>()
|
||||
const [viewerSource, setViewerSource] = useState<string>()
|
||||
const sourceVisible = visibleSource === source
|
||||
const viewerOpen = viewerSource === source
|
||||
|
||||
useEffect(() => {
|
||||
// A cached result is read during render below; nothing to do here.
|
||||
if (mermaidResultCache.has(mermaidCacheKey(source, documentTheme))) return
|
||||
let active = true
|
||||
const renderRevision = renderRevisionRef.current++
|
||||
const timeout = window.setTimeout(() => {
|
||||
@@ -570,12 +610,14 @@ export function MermaidDiagram({
|
||||
).then(
|
||||
(svg) => {
|
||||
if (active) {
|
||||
setRenderState({
|
||||
const state: MermaidRenderState = {
|
||||
source,
|
||||
status: 'ready',
|
||||
svg,
|
||||
theme: documentTheme
|
||||
})
|
||||
}
|
||||
rememberMermaidResult(state)
|
||||
setRenderState(state)
|
||||
}
|
||||
},
|
||||
() => {
|
||||
@@ -600,7 +642,7 @@ export function MermaidDiagram({
|
||||
renderState?.source === source &&
|
||||
renderState.theme === documentTheme
|
||||
? renderState
|
||||
: undefined
|
||||
: mermaidResultCache.get(mermaidCacheKey(source, documentTheme))
|
||||
const retainedSvg =
|
||||
renderState?.status === 'ready' &&
|
||||
renderState.source === source
|
||||
|
||||
@@ -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 { join } from 'node:path'
|
||||
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 frameScheduler = (callback: () => void): (() => void) => {
|
||||
frames.add(callback)
|
||||
@@ -517,18 +517,27 @@ describe('TerminalPanel', () => {
|
||||
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};` })
|
||||
}
|
||||
await waitFor(() => expect(frames.size).toBe(1))
|
||||
expect(emulator.write).not.toHaveBeenCalled()
|
||||
runFrame()
|
||||
// Wait until the event queue has consumed every event.
|
||||
await act(async () => {
|
||||
for (let tick = 0; tick < 10; tick += 1) await Promise.resolve()
|
||||
})
|
||||
expect(emulator.write).toHaveBeenCalledOnce()
|
||||
expect(emulator.write).toHaveBeenCalledWith('1;2;3;4;5;', expect.any(Function))
|
||||
expect(backend.adapter.ack).not.toHaveBeenCalled()
|
||||
callbacks.shift()?.()
|
||||
runFrame()
|
||||
expect(emulator.write).toHaveBeenCalledTimes(2)
|
||||
expect(emulator.write).toHaveBeenLastCalledWith('2;3;4;5;', expect.any(Function))
|
||||
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.
|
||||
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).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 () => {
|
||||
@@ -569,8 +617,10 @@ describe('TerminalPanel', () => {
|
||||
await screen.findByText('PowerShell 7')
|
||||
backend.emit({ sessionId, sequence: 1, type: 'output', data: 'a' })
|
||||
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(() =>
|
||||
expect(emulator.write).toHaveBeenCalledWith('ab', expect.any(Function))
|
||||
expect(emulator.write.mock.calls.map(([data]) => data)).toEqual(['a', 'b'])
|
||||
)
|
||||
await waitFor(() =>
|
||||
expect(backend.adapter.ack).toHaveBeenCalledWith({ sessionId, sequence: 2 })
|
||||
|
||||
@@ -279,8 +279,9 @@ export function TerminalPanel({
|
||||
acceptedSequenceRef.current = event.sequence
|
||||
|
||||
if (event.type === 'output') {
|
||||
// Merged with neighbours and written once per frame; the batcher ACKs
|
||||
// the batch's last sequence after xterm has processed it.
|
||||
// Written at once when idle, otherwise merged with the rest of the
|
||||
// frame; the batcher ACKs the batch's last sequence after xterm has
|
||||
// processed it.
|
||||
outputBatcherRef.current?.push(
|
||||
event.sessionId,
|
||||
event.sequence,
|
||||
@@ -700,8 +701,17 @@ export function TerminalPanel({
|
||||
title={t('sidebar.terminal.toolbar.clear')}
|
||||
aria-label={t('sidebar.terminal.toolbar.clear')}
|
||||
onClick={() => {
|
||||
emulatorRef.current?.clear()
|
||||
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" />
|
||||
|
||||
@@ -614,7 +614,7 @@ export function WorkspaceFilesPanel({
|
||||
</div>
|
||||
</WorkspaceGitTools>}
|
||||
</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 ? (
|
||||
<p className="assistant-sidebar__empty">
|
||||
{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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { changeUiLocale } from './i18n'
|
||||
@@ -109,6 +111,16 @@ describe('WorkspaceFilesPanel tree windowing', () => {
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'a-002.txt 的更多操作' })).toHaveFocus())
|
||||
}, 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 () => {
|
||||
await setup()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'a' }))
|
||||
|
||||
@@ -519,7 +519,7 @@ export function DocumentsView({
|
||||
: t('documents.table.noResults')}
|
||||
</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}>
|
||||
<thead>
|
||||
<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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import i18n from '../i18n'
|
||||
@@ -126,5 +128,13 @@ describe('DocumentsView windowing', () => {
|
||||
expect(renderedNames()).toHaveLength(10)
|
||||
expect(table().querySelector('.knowledge-documents__spacer')).toBeNull()
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -35,5 +35,8 @@
|
||||
stand in for unmounted rows. Row spacing moves into each row's padding so
|
||||
spacer heights and measured rows add up exactly. */
|
||||
.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-list__spacer { grid-column: 1 / -1; }
|
||||
|
||||
@@ -34,7 +34,7 @@ describe('TerminalOutputBatcher', () => {
|
||||
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 writes: Array<{ data: string; done: () => void }> = []
|
||||
const acks: number[] = []
|
||||
@@ -43,43 +43,62 @@ describe('TerminalOutputBatcher', () => {
|
||||
acknowledge: (_, sequence) => acks.push(sequence),
|
||||
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', 4, 'b')
|
||||
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(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')
|
||||
expect(writes).toHaveLength(2)
|
||||
frames.runFrame()
|
||||
writes[1]!.done()
|
||||
writes[1]!.done()
|
||||
expect(writes.map((write) => write.data)).toEqual(['abc', 'd'])
|
||||
expect(acks).toEqual([5, 6])
|
||||
writes[2]!.done()
|
||||
writes[2]!.done()
|
||||
expect(writes.map((write) => write.data)).toEqual(['k', 'abc', 'd'])
|
||||
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()
|
||||
})
|
||||
|
||||
it('flush writes pending output immediately and waits for the emulator', async () => {
|
||||
const frames = manualScheduler()
|
||||
let release: (() => void) | undefined
|
||||
const written: string[] = []
|
||||
const batcher = new TerminalOutputBatcher({
|
||||
write: (_, done) => {
|
||||
write: (data, done) => {
|
||||
written.push(data)
|
||||
release = done
|
||||
},
|
||||
acknowledge: vi.fn(),
|
||||
schedule: frames.schedule
|
||||
})
|
||||
batcher.push('s', 1, 'x')
|
||||
batcher.push('s', 2, 'y')
|
||||
expect(written).toEqual(['x'])
|
||||
let flushed = false
|
||||
const flush = batcher.flush().then(() => {
|
||||
flushed = true
|
||||
})
|
||||
expect(frames.pendingFrames()).toBe(0)
|
||||
expect(written).toEqual(['x', 'y'])
|
||||
await Promise.resolve()
|
||||
expect(flushed).toBe(false)
|
||||
release?.()
|
||||
@@ -97,10 +116,12 @@ describe('TerminalOutputBatcher', () => {
|
||||
acknowledge,
|
||||
schedule: frames.schedule
|
||||
})
|
||||
batcher.push('old', 8, 'shown')
|
||||
batcher.push('old', 9, 'stale')
|
||||
batcher.reset()
|
||||
frames.runFrame()
|
||||
expect(write).not.toHaveBeenCalled()
|
||||
expect(write).toHaveBeenCalledOnce()
|
||||
expect(write).toHaveBeenCalledWith('shown', expect.any(Function))
|
||||
expect(acknowledge).toHaveBeenCalledWith('old', 9)
|
||||
batcher.dispose()
|
||||
})
|
||||
@@ -139,9 +160,14 @@ describe('TerminalOutputBatcher', () => {
|
||||
})
|
||||
batcher.push('s', 1, 'hidden ')
|
||||
batcher.push('s', 2, 'output')
|
||||
batcher.push('s', 3, '!')
|
||||
expect(writes).toEqual(['hidden '])
|
||||
vi.advanceTimersByTime(TERMINAL_FRAME_FALLBACK_MS)
|
||||
expect(writes).toEqual(['hidden output'])
|
||||
expect(acks).toEqual([2])
|
||||
expect(writes).toEqual(['hidden ', 'output!'])
|
||||
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()
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
/**
|
||||
* Renderer-side terminal output batching (P3): output events are merged and
|
||||
* written to the emulator at most once per animation frame, in sequence order.
|
||||
* Renderer-side terminal output batching (P3): output arriving while idle is
|
||||
* 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
|
||||
* write callback, which is what Main's ACK-based backpressure expects.
|
||||
*/
|
||||
@@ -71,7 +73,14 @@ export class TerminalOutputBatcher {
|
||||
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 {
|
||||
if (this.disposed) {
|
||||
return
|
||||
@@ -83,9 +92,23 @@ export class TerminalOutputBatcher {
|
||||
this.pendingSessionId = sessionId
|
||||
this.pendingSequence = sequence
|
||||
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
|
||||
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 = undefined
|
||||
return this.writePending()
|
||||
}
|
||||
|
||||
private writePending(): Promise<void> {
|
||||
if (this.pending.length > 0) {
|
||||
const data = this.pending.length === 1 ? this.pending[0]! : this.pending.join('')
|
||||
const sessionId = this.pendingSessionId!
|
||||
|
||||
@@ -184,6 +184,13 @@ export function useListWindow({
|
||||
const rowRefCallbacks = useRef(new Map<string, (element: HTMLElement | null) => void>())
|
||||
const observerRef = useRef<ResizeObserver | 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 scrollParentRef = useRef(scrollParent)
|
||||
useLayoutEffect(() => {
|
||||
@@ -284,6 +291,7 @@ export function useListWindow({
|
||||
const layoutNow = layoutRef.current
|
||||
if (!current || !layoutNow || !activeRef.current || isHidden(current.list)) return
|
||||
const { top, viewport } = position(current)
|
||||
scrollTopRef.current = top
|
||||
if (heights.setViewport(viewport)) setMeasureVersion((version) => version + 1)
|
||||
// Hysteresis: re-window only when the viewport (plus a margin) leaves the
|
||||
// mounted rows, so a steady scroll remounts rows every screen or so.
|
||||
@@ -348,28 +356,45 @@ export function useListWindow({
|
||||
const current = geometry()
|
||||
if (!current) return
|
||||
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) {
|
||||
scopeRef.current = scope
|
||||
scrollAnchorRef.current = undefined
|
||||
// New search results start at the top; short lists keep the browser's
|
||||
// own clamping, as before windowing.
|
||||
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
|
||||
if (windowed && anchor) {
|
||||
const element = rowElements.current.get(anchor.id)
|
||||
let delta: number | undefined
|
||||
if (element) {
|
||||
const delta = element.getBoundingClientRect().top - viewportTop(container) - anchor.top
|
||||
if (Math.abs(delta) >= 0.5) {
|
||||
if (container) container.scrollTop += delta
|
||||
else window.scrollBy(0, delta)
|
||||
}
|
||||
delta = element.getBoundingClientRect().top - viewportTop(container) - anchor.top
|
||||
} else if (idsAnchor && layoutRef.current) {
|
||||
// The row is not mounted (yet): its laid-out top is its cached offset.
|
||||
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
|
||||
if (!windowed || !layoutNow || !active || isHidden(current.list)) return
|
||||
const { top } = position(current)
|
||||
scrollTopRef.current = top
|
||||
if (!coversViewport(layoutNow, top) && Math.abs(top - anchorTop) >= 1) setAnchorTop(top)
|
||||
}, [active, anchorTop, geometry, ids, keepKey, measureVersion, position, resetScrollOnScopeChange, scope, windowed])
|
||||
|
||||
@@ -421,6 +446,45 @@ export function useListWindow({
|
||||
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. */
|
||||
function isHidden(list: HTMLElement): boolean {
|
||||
return list.closest('[hidden]') !== null
|
||||
|
||||
@@ -607,7 +607,26 @@ try {
|
||||
}
|
||||
await sleep(300)
|
||||
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')
|
||||
|
||||
@@ -163,8 +163,22 @@ app.whenReady().then(async () => {
|
||||
const clicks = await evaluate(`Number(document.querySelector('[data-clicks]').dataset.clicks)`)
|
||||
await click('.topbar button')
|
||||
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 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`)
|
||||
assert.ok(bottom <= geometry.composer.top, 'last message is above composer')
|
||||
assert.equal(await evaluate(`document.querySelector('[aria-label="Composer"]').value`), 'Draft')
|
||||
|
||||
Reference in New Issue
Block a user