From 805acc3fce08369fdc38104f5d806e995ed77230 Mon Sep 17 00:00:00 2001 From: mesalogo Date: Sat, 3 Oct 2026 16:19:56 +0800 Subject: [PATCH] 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). --- docs/architecture/performance-principles.md | 14 ++ ...performance-experience-improvement-plan.md | 21 +- src/main/ipc.ts | 3 + src/main/remote-event-batcher.test.ts | 47 +++- src/main/remote-event-batcher.ts | 15 +- src/renderer/src/App.test.tsx | 42 ++++ src/renderer/src/App.tsx | 10 +- src/renderer/src/ChatHistoryPane.test.tsx | 82 +++++++ src/renderer/src/ChatHistoryPane.tsx | 220 +++++++++++++++++- src/renderer/src/ConversationSidebar.test.tsx | 44 ++++ .../src/MagicNotesWorkspace.window.test.tsx | 7 + src/renderer/src/MarkdownRenderer.test.tsx | 15 ++ src/renderer/src/MermaidDiagram.tsx | 50 +++- src/renderer/src/TerminalPanel.test.tsx | 74 +++++- src/renderer/src/TerminalPanel.tsx | 16 +- src/renderer/src/WorkspaceFilesPanel.tsx | 2 +- .../src/WorkspaceFilesPanel.window.test.tsx | 12 + .../src/knowledge-workspace/DocumentsView.tsx | 2 +- .../DocumentsView.window.test.tsx | 10 + src/renderer/src/magic-notes-panel.css | 3 + .../src/terminal-output-batcher.test.ts | 60 +++-- src/renderer/src/terminal-output-batcher.ts | 37 ++- src/renderer/src/use-list-window.ts | 76 +++++- tests/support/app-perf-driver.mjs | 21 +- tests/support/frosted-glass-driver.mjs | 14 ++ 25 files changed, 834 insertions(+), 63 deletions(-) diff --git a/docs/architecture/performance-principles.md b/docs/architecture/performance-principles.md index 95871fc0..8af0add8 100644 --- a/docs/architecture/performance-principles.md +++ b/docs/architecture/performance-principles.md @@ -80,6 +80,17 @@ Main 负责窗口、生命周期、权限和消息转发。数据库查询、文 - 异步改造要先确认“先写入、后读取”的顺序,没有测试覆盖就不合并。 - 风险高、收益小,或者会改变现有功能的优化先不做,在计划文档里记录理由。 +### P10 体验不倒退 + +性能优化只能让体验变好,不能用体验换速度。“行为不变”不仅指数据和结果,也包括用户能感知到的交互: + +- **动画和过渡保持原样。** 平滑滚动(如“到底部”按钮)、展开收起、悬停、淡入淡出都不能因为优化而消失。任何瞬间写入 `scrollTop`/`scrollTo` 的代码都会打断正在进行的平滑滚动,必须在平滑滚动期间暂停。 +- **只渲染可见部分(P5)时,用户状态不能丢。** 行被卸载后,它的局部状态也会消失。以下行必须保持挂载,或者把状态存到行外:有焦点的行、用户展开过 `
` 或输入过内容的行、选中文本的两端、待回答的问题和待审批项、打开菜单或正在重命名的行。 +- **只渲染可见部分时,基本操作要和完整列表一致:** 跨行选择和复制、滚动位置不因上方插入或删除而跳动(关闭原生 `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. 当前差距 diff --git a/docs/roadmap/product-performance-experience-improvement-plan.md b/docs/roadmap/product-performance-experience-improvement-plan.md index 30cc8410..58658a32 100644 --- a/docs/roadmap/product-performance-experience-improvement-plan.md +++ b/docs/roadmap/product-performance-experience-improvement-plan.md @@ -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 状态 diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 39e21c7f..f33530cc 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -3988,6 +3988,9 @@ export function registerIpcHandlers( let remoteEventBatchClosed = false const remoteEventBatcher = new RemoteEventBatcher({ 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({ diff --git a/src/main/remote-event-batcher.test.ts b/src/main/remote-event-batcher.test.ts index c705c205..989f8f9c 100644 --- a/src/main/remote-event-batcher.test.ts +++ b/src/main/remote-event-batcher.test.ts @@ -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({ 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({ + 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({ 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') diff --git a/src/main/remote-event-batcher.ts b/src/main/remote-event-batcher.ts index b1a936d0..65fc1c93 100644 --- a/src/main/remote-event-batcher.ts +++ b/src/main/remote-event-batcher.ts @@ -18,6 +18,12 @@ export type RemoteEventBatcherOptions = { persist(entries: readonly RemoteEventBatchEntry[]): 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 = RemoteEventBatchEntry & { onCommitted?: RemoteEventCommitted } -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 { private readonly persist: RemoteEventBatcherOptions['persist'] private readonly onError: RemoteEventBatcherOptions['onError'] + private readonly onTimerFlushed: RemoteEventBatcherOptions['onTimerFlushed'] private readonly flushIntervalMs: number private readonly maximumEvents: number private pending: PendingEntry[] = [] @@ -56,6 +67,7 @@ export class RemoteEventBatcher { constructor(options: RemoteEventBatcherOptions) { 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 { this.timer = undefined try { this.flush() + this.onTimerFlushed?.() } catch (error) { this.onError?.(error) } diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index e4eedaa9..9adce5ae 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -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(); + 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(".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(); const composer = await screen.findByLabelText("向 GoodBuddy 提问"); diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 4d942c1d..f7a4a4e6 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -5399,11 +5399,17 @@ function App(): React.JSX.Element { [t], ); - const readConversationForExport = async (item: Conversation): Promise => { + const readConversationForExport = async (selected: Conversation): Promise => { + // 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 ( diff --git a/src/renderer/src/ChatHistoryPane.test.tsx b/src/renderer/src/ChatHistoryPane.test.tsx index 0c2fa7cc..8c753c91 100644 --- a/src/renderer/src/ChatHistoryPane.test.tsx +++ b/src/renderer/src/ChatHistoryPane.test.tsx @@ -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('[data-message-id="m1993"] details')! + act(() => { + details.open = true + details.dispatchEvent(new Event('toggle')) + }) + const selected = container.querySelector('[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' } diff --git a/src/renderer/src/ChatHistoryPane.tsx b/src/renderer/src/ChatHistoryPane.tsx index 71418d11..29578862 100644 --- a/src/renderer/src/ChatHistoryPane.tsx +++ b/src/renderer/src/ChatHistoryPane.tsx @@ -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([]); + // Rows the user changed (toggled a
, typed into a question card): + // their local state would be lost if they unmounted. Bounded, oldest out. + const [interactedMessageIds, setInteractedMessageIds] = useState([]); + // Rows holding the start and end of a non-empty text selection. + const [selectionMessageIds, setSelectionMessageIds] = useState([]); + // 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(); + 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(".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(".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(".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(".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", }); }; diff --git a/src/renderer/src/ConversationSidebar.test.tsx b/src/renderer/src/ConversationSidebar.test.tsx index 564880b4..7fcc3332 100644 --- a/src/renderer/src/ConversationSidebar.test.tsx +++ b/src/renderer/src/ConversationSidebar.test.tsx @@ -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(`[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') + }) }) diff --git a/src/renderer/src/MagicNotesWorkspace.window.test.tsx b/src/renderer/src/MagicNotesWorkspace.window.test.tsx index 55bd31be..d1a751c5 100644 --- a/src/renderer/src/MagicNotesWorkspace.window.test.tsx +++ b/src/renderer/src/MagicNotesWorkspace.window.test.tsx @@ -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) diff --git a/src/renderer/src/MarkdownRenderer.test.tsx b/src/renderer/src/MarkdownRenderer.test.tsx index ab74d2a8..5638a170 100644 --- a/src/renderer/src/MarkdownRenderer.test.tsx +++ b/src/renderer/src/MarkdownRenderer.test.tsx @@ -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}$`} ).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({source}) + await screen.findByRole('region', { name: 'Mermaid 图表,可横向滚动' }) + first.unmount() + expect(mermaidMock.render).toHaveBeenCalledTimes(1) + const again = render({source}) + // 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( {`\`\`\`mermaid diff --git a/src/renderer/src/MermaidDiagram.tsx b/src/renderer/src/MermaidDiagram.tsx index 662e876f..0355379b 100644 --- a/src/renderer/src/MermaidDiagram.tsx +++ b/src/renderer/src/MermaidDiagram.tsx @@ -74,6 +74,44 @@ let mermaidModulePromise: Promise | 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() + +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() + useState(() => cachedMermaidResult(source, documentTheme)) const [visibleSource, setVisibleSource] = useState() const [viewerSource, setViewerSource] = useState() 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 diff --git a/src/renderer/src/TerminalPanel.test.tsx b/src/renderer/src/TerminalPanel.test.tsx index 8aff0f06..ad6394bc 100644 --- a/src/renderer/src/TerminalPanel.test.tsx +++ b/src/renderer/src/TerminalPanel.test.tsx @@ -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( + + ) + 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 }) diff --git a/src/renderer/src/TerminalPanel.tsx b/src/renderer/src/TerminalPanel.tsx index 3b5f4cc9..e3e2d4b5 100644 --- a/src/renderer/src/TerminalPanel.tsx +++ b/src/renderer/src/TerminalPanel.tsx @@ -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() + } + }) }} >