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 用工具守住规则
- 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 状态
+3
View File
@@ -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({
+45 -2
View File
@@ -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')
+14 -1
View File
@@ -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)
}
+42
View File
@@ -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 提问");
+8 -2
View File
@@ -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 (
+82
View File
@@ -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' }
+213 -7
View File
@@ -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
+46 -4
View File
@@ -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
+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 { 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 })
+13 -3
View File
@@ -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" />
+1 -1
View File
@@ -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')
})
})
+3
View File
@@ -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()
})
+32 -5
View File
@@ -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!
+70 -6
View File
@@ -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
+20 -1
View File
@@ -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')
+14
View File
@@ -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')