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:
@@ -3988,6 +3988,9 @@ export function registerIpcHandlers(
|
||||
let remoteEventBatchClosed = false
|
||||
const remoteEventBatcher = new RemoteEventBatcher<RemoteBatchEvent>({
|
||||
onError: (error) => controller.abort(error),
|
||||
// The batch already coalesced a frame of deltas: send them now rather
|
||||
// than holding them for another public-buffer interval.
|
||||
onTimerFlushed: () => publicEventBuffer.flush(),
|
||||
persist: (entries) =>
|
||||
remoteConversationRecovery
|
||||
? assistantDatabase.appendRemoteConversationTaskEventsBatch({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { RemoteEventBatcher } from './remote-event-batcher'
|
||||
import { REMOTE_EVENT_BATCH_FLUSH_INTERVAL_MS, RemoteEventBatcher } from './remote-event-batcher'
|
||||
|
||||
const provenance = (eventIndex: number) => ({
|
||||
source: 'remote-semantic-transcript' as const,
|
||||
@@ -46,6 +46,49 @@ describe('RemoteEventBatcher', () => {
|
||||
batcher.dispose()
|
||||
})
|
||||
|
||||
it('commits (and so forwards) streamed events within one frame by default', () => {
|
||||
vi.useFakeTimers()
|
||||
expect(REMOTE_EVENT_BATCH_FLUSH_INTERVAL_MS).toBe(16)
|
||||
const persist = vi.fn((entries: readonly unknown[]) => entries.map(() => true))
|
||||
const committed: string[] = []
|
||||
const batcher = new RemoteEventBatcher<string>({ persist })
|
||||
batcher.add(provenance(0), 'a', (event) => committed.push(event))
|
||||
batcher.add(provenance(1), 'b', (event) => committed.push(event))
|
||||
vi.advanceTimersByTime(15)
|
||||
// Nothing is forwarded before it is persisted.
|
||||
expect(committed).toEqual([])
|
||||
vi.advanceTimersByTime(1)
|
||||
expect(persist).toHaveBeenCalledOnce()
|
||||
expect(committed).toEqual(['a', 'b'])
|
||||
batcher.dispose()
|
||||
})
|
||||
|
||||
it('reports a timer flush after its events committed, and not after a failed one', () => {
|
||||
vi.useFakeTimers()
|
||||
const order: string[] = []
|
||||
let fail = false
|
||||
const batcher = new RemoteEventBatcher<string>({
|
||||
persist: (entries) => {
|
||||
if (fail) throw new Error('disk full')
|
||||
return entries.map(() => true)
|
||||
},
|
||||
onTimerFlushed: () => order.push('flushed'),
|
||||
onError: () => order.push('error')
|
||||
})
|
||||
batcher.add(provenance(0), 'a', (event) => order.push(event))
|
||||
vi.advanceTimersByTime(REMOTE_EVENT_BATCH_FLUSH_INTERVAL_MS)
|
||||
expect(order).toEqual(['a', 'flushed'])
|
||||
// Explicit flushes leave forwarding to the caller.
|
||||
batcher.add(provenance(1), 'b', (event) => order.push(event))
|
||||
batcher.flush()
|
||||
expect(order).toEqual(['a', 'flushed', 'b'])
|
||||
fail = true
|
||||
batcher.add(provenance(2), 'c', (event) => order.push(event))
|
||||
vi.advanceTimersByTime(REMOTE_EVENT_BATCH_FLUSH_INTERVAL_MS)
|
||||
expect(order).toEqual(['a', 'flushed', 'b', 'error'])
|
||||
batcher.dispose()
|
||||
})
|
||||
|
||||
it('rethrows a failed commit and stays failed so later checkpoints are never committed', () => {
|
||||
vi.useFakeTimers()
|
||||
const onError = vi.fn()
|
||||
@@ -55,7 +98,7 @@ describe('RemoteEventBatcher', () => {
|
||||
const onCommitted = vi.fn()
|
||||
const batcher = new RemoteEventBatcher<string>({ persist, onError })
|
||||
batcher.add(provenance(0), 'a', onCommitted)
|
||||
vi.advanceTimersByTime(50)
|
||||
vi.advanceTimersByTime(REMOTE_EVENT_BATCH_FLUSH_INTERVAL_MS)
|
||||
expect(onError).toHaveBeenCalledWith(new Error('disk full'))
|
||||
expect(() => batcher.add(provenance(1), 'checkpoint')).toThrow('disk full')
|
||||
expect(() => batcher.flush()).toThrow('disk full')
|
||||
|
||||
@@ -18,6 +18,12 @@ export type RemoteEventBatcherOptions<TEvent> = {
|
||||
persist(entries: readonly RemoteEventBatchEntry<TEvent>[]): readonly boolean[]
|
||||
/** Called when a timer-driven flush fails (for example to abort the run). */
|
||||
onError?(error: unknown): void
|
||||
/**
|
||||
* Called after a timer-driven flush committed (and its `onCommitted`
|
||||
* callbacks ran), e.g. to forward the committed events without waiting for
|
||||
* a second coalescing timer downstream.
|
||||
*/
|
||||
onTimerFlushed?(): void
|
||||
/** Safety flush for buffered events that are not followed by a boundary. */
|
||||
flushIntervalMs?: number
|
||||
maximumEvents?: number
|
||||
@@ -27,7 +33,11 @@ type PendingEntry<TEvent> = RemoteEventBatchEntry<TEvent> & {
|
||||
onCommitted?: RemoteEventCommitted<TEvent>
|
||||
}
|
||||
|
||||
export const REMOTE_EVENT_BATCH_FLUSH_INTERVAL_MS = 50
|
||||
/**
|
||||
* One frame: committed events are forwarded to the renderer only after their
|
||||
* batch commits, so this bounds how long streamed remote text is held back.
|
||||
*/
|
||||
export const REMOTE_EVENT_BATCH_FLUSH_INTERVAL_MS = 16
|
||||
export const REMOTE_EVENT_BATCH_MAXIMUM_EVENTS = 256
|
||||
|
||||
/**
|
||||
@@ -47,6 +57,7 @@ export const REMOTE_EVENT_BATCH_MAXIMUM_EVENTS = 256
|
||||
export class RemoteEventBatcher<TEvent> {
|
||||
private readonly persist: RemoteEventBatcherOptions<TEvent>['persist']
|
||||
private readonly onError: RemoteEventBatcherOptions<TEvent>['onError']
|
||||
private readonly onTimerFlushed: RemoteEventBatcherOptions<TEvent>['onTimerFlushed']
|
||||
private readonly flushIntervalMs: number
|
||||
private readonly maximumEvents: number
|
||||
private pending: PendingEntry<TEvent>[] = []
|
||||
@@ -56,6 +67,7 @@ export class RemoteEventBatcher<TEvent> {
|
||||
constructor(options: RemoteEventBatcherOptions<TEvent>) {
|
||||
this.persist = options.persist
|
||||
this.onError = options.onError
|
||||
this.onTimerFlushed = options.onTimerFlushed
|
||||
this.flushIntervalMs =
|
||||
options.flushIntervalMs ?? REMOTE_EVENT_BATCH_FLUSH_INTERVAL_MS
|
||||
this.maximumEvents =
|
||||
@@ -132,6 +144,7 @@ export class RemoteEventBatcher<TEvent> {
|
||||
this.timer = undefined
|
||||
try {
|
||||
this.flush()
|
||||
this.onTimerFlushed?.()
|
||||
} catch (error) {
|
||||
this.onError?.(error)
|
||||
}
|
||||
|
||||
@@ -4842,6 +4842,48 @@ describe("App", () => {
|
||||
expect(sidebarRenderProbes.row.mock.calls.length).toBeLessThanOrEqual(4);
|
||||
});
|
||||
|
||||
it("copies and exports a streaming conversation with the text shown on screen", async () => {
|
||||
const { container } = render(<App />);
|
||||
const composer = await screen.findByLabelText("向 GoodBuddy 提问");
|
||||
fireEvent.change(composer, { target: { value: "Copy while streaming" } });
|
||||
fireEvent.click(screen.getByLabelText("发送"));
|
||||
await waitFor(() => expect(run).toHaveBeenCalledOnce());
|
||||
const requestId = run.mock.calls[0]![0].requestId;
|
||||
// Let the first delta's flush land, then stream more within one flush window.
|
||||
act(() => agentListener?.({ requestId, type: "text", delta: "Live" }));
|
||||
await screen.findByText("Live");
|
||||
await act(() => new Promise((resolve) => setTimeout(resolve, 400)));
|
||||
for (const delta of [" alpha", " omega"]) {
|
||||
act(() => agentListener?.({ requestId, type: "text", delta }));
|
||||
}
|
||||
expect(screen.getByText("Live alpha omega")).toBeInTheDocument();
|
||||
const list = container.querySelector<HTMLElement>(".conversation-list")!;
|
||||
fireEvent.click(within(list).getByLabelText(/^更多会话操作 /u));
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "复制完整会话" }));
|
||||
await waitFor(() => expect(api.clipboard.writeText).toHaveBeenCalledOnce());
|
||||
expect(api.clipboard.writeText).toHaveBeenCalledWith(expect.stringContaining("Live alpha omega"));
|
||||
|
||||
const blobs: Blob[] = [];
|
||||
const createObjectURL = vi.fn((blob: Blob) => { blobs.push(blob); return "blob:export"; });
|
||||
const originalUrl = { create: URL.createObjectURL, revoke: URL.revokeObjectURL };
|
||||
URL.createObjectURL = createObjectURL;
|
||||
URL.revokeObjectURL = vi.fn();
|
||||
const click = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => undefined);
|
||||
try {
|
||||
act(() => agentListener?.({ requestId, type: "text", delta: " tail" }));
|
||||
fireEvent.click(within(list).getByLabelText(/^更多会话操作 /u));
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "导出 Markdown" }));
|
||||
await waitFor(() => expect(createObjectURL).toHaveBeenCalledOnce());
|
||||
expect(await blobs[0]!.text()).toContain("Live alpha omega tail");
|
||||
} finally {
|
||||
click.mockRestore();
|
||||
URL.createObjectURL = originalUrl.create;
|
||||
URL.revokeObjectURL = originalUrl.revoke;
|
||||
}
|
||||
fireEvent.click(screen.getByLabelText("停止生成"));
|
||||
await waitFor(() => expect(api.agent.cancel).toHaveBeenCalledWith(requestId));
|
||||
});
|
||||
|
||||
it("does not re-render the App root when the active conversation's streaming text is flushed", async () => {
|
||||
render(<App />);
|
||||
const composer = await screen.findByLabelText("向 GoodBuddy 提问");
|
||||
|
||||
@@ -5399,11 +5399,17 @@ function App(): React.JSX.Element {
|
||||
[t],
|
||||
);
|
||||
|
||||
const readConversationForExport = async (item: Conversation): Promise<Conversation> => {
|
||||
const readConversationForExport = async (selected: Conversation): Promise<Conversation> => {
|
||||
// Streaming deltas reach the conversation store only on the live-message
|
||||
// flush cadence; absorb them now so the copy holds everything on screen.
|
||||
if (liveMessages.hasEntries()) setConversations((current) => current);
|
||||
const item = conversationStore.getConversation(selected.id) ?? selected;
|
||||
if (!item.messageSummary) return item;
|
||||
const snapshot = await window.goodbuddy.conversations.get(item.id);
|
||||
// Export reads do not acquire a view-cache entry or retain tool metadata.
|
||||
return mergePersistedConversations([item], [snapshot], new Map())[0]!;
|
||||
const merged = mergePersistedConversations([item], [snapshot], new Map())[0]!;
|
||||
// Deltas that streamed in while the snapshot was read.
|
||||
return liveMessages.resolveConversations([merged])[0]!;
|
||||
};
|
||||
|
||||
const copyConversation = async (
|
||||
|
||||
@@ -371,6 +371,88 @@ describe('ChatHistoryPane windowing', () => {
|
||||
expect(chat.scrollTop + viewport).toBe(chat.scrollHeight)
|
||||
})
|
||||
|
||||
// UX parity with the fully mounted list: rows holding user state survive
|
||||
// scrolling away and back.
|
||||
it('keeps rows with an open details element, a pending question or a selection mounted', async () => {
|
||||
const messages = makeMessages(2_000)
|
||||
messages[1_991] = {
|
||||
...messages[1_991]!,
|
||||
approval: { id: 'approval-1', title: 'Run', description: 'Run a tool' }
|
||||
}
|
||||
messages[1_993] = {
|
||||
...messages[1_993]!,
|
||||
reasoning: 'thinking',
|
||||
blocks: [{ id: 'r1', type: 'reasoning', content: 'thinking' }, { id: 't1', type: 'text', content: 'answer' }]
|
||||
} as Message
|
||||
const { chat, container } = setup(messages)
|
||||
measure()
|
||||
const details = container.querySelector<HTMLDetailsElement>('[data-message-id="m1993"] details')!
|
||||
act(() => {
|
||||
details.open = true
|
||||
details.dispatchEvent(new Event('toggle'))
|
||||
})
|
||||
const selected = container.querySelector<HTMLElement>('[data-message-id="m1995"] .message')!
|
||||
act(() => {
|
||||
const range = document.createRange()
|
||||
range.selectNodeContents(selected)
|
||||
const selection = document.getSelection()!
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
document.dispatchEvent(new Event('selectionchange'))
|
||||
})
|
||||
|
||||
act(() => { chat.scrollTop = 0; fireEvent.scroll(chat) })
|
||||
await frame()
|
||||
measure()
|
||||
const ids = renderedIds(container)
|
||||
expect(ids).toContain('m0')
|
||||
expect(ids).toEqual(expect.arrayContaining(['m1991', 'm1993', 'm1995']))
|
||||
// The very same element, so its open state is intact.
|
||||
expect(container.querySelector('[data-message-id="m1993"] details')).toBe(details)
|
||||
expect(details.open).toBe(true)
|
||||
expect(ids).not.toContain('m1500')
|
||||
document.getSelection()?.removeAllRanges()
|
||||
})
|
||||
|
||||
it('glides to the bottom without instant scroll writes, then pins', async () => {
|
||||
const messages = makeMessages(500)
|
||||
const { chat, container } = setup(messages)
|
||||
measure()
|
||||
act(() => { chat.scrollTop = 1_000; fireEvent.scroll(chat) })
|
||||
await frame()
|
||||
measure()
|
||||
const scrollTo = vi.mocked(chat.scrollTo)
|
||||
// A smooth scroll the browser animates: record the call, move nothing yet.
|
||||
scrollTo.mockImplementation(() => undefined)
|
||||
scrollTo.mockClear()
|
||||
fireEvent.click(container.querySelector('.chat-scroll-to-bottom')!)
|
||||
expect(scrollTo).toHaveBeenCalledTimes(1)
|
||||
expect(scrollTo.mock.calls[0]![0]).toMatchObject({ behavior: 'smooth' })
|
||||
// Mid-glide frames, resizes and re-windowing must not write scrollTop.
|
||||
const before = chat.scrollTop
|
||||
for (const step of [10_000, 30_000]) {
|
||||
act(() => {
|
||||
Object.getOwnPropertyDescriptor(chat, 'scrollTop')!.set!.call(chat, step)
|
||||
fireEvent.scroll(chat)
|
||||
})
|
||||
await frame()
|
||||
measure()
|
||||
expect(scrollTo.mock.calls.every(([options]) => (options as ScrollToOptions).behavior === 'smooth')).toBe(true)
|
||||
}
|
||||
expect(chat.scrollTop).not.toBe(before)
|
||||
expect(container.querySelector('.chat-scroll-to-bottom')).toBeNull()
|
||||
// Arriving at the bottom ends the glide and pins.
|
||||
act(() => {
|
||||
chat.scrollTop = chat.scrollHeight
|
||||
fireEvent.scroll(chat)
|
||||
})
|
||||
await frame()
|
||||
measure()
|
||||
expect(renderedIds(container).at(-1)).toBe('m499')
|
||||
expect(chat.scrollTop + viewport).toBe(chat.scrollHeight)
|
||||
expect(container.querySelector('.chat-scroll-to-bottom')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps messageIndex-based props for windowed rows', () => {
|
||||
const messages = makeMessages(300)
|
||||
messages[299] = { ...messages[299]!, state: 'error', content: 'failed' }
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
@@ -40,6 +41,8 @@ import type { TimeFormatLocale } from "./time-format";
|
||||
*/
|
||||
export const messageRenderBatchSize = 80;
|
||||
const chatBottomProximity = 96;
|
||||
/** Rows kept mounted because the user changed their local state. */
|
||||
const maxInteractedRows = 8;
|
||||
|
||||
type ChatQuickAction = {
|
||||
title: string;
|
||||
@@ -251,6 +254,26 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
|
||||
// Rows kept mounted outside the range: the focused row, the first message
|
||||
// after the final "load earlier" step.
|
||||
const [keptMessageIds, setKeptMessageIds] = useState<readonly string[]>([]);
|
||||
// Rows the user changed (toggled a <details>, typed into a question card):
|
||||
// their local state would be lost if they unmounted. Bounded, oldest out.
|
||||
const [interactedMessageIds, setInteractedMessageIds] = useState<readonly string[]>([]);
|
||||
// Rows holding the start and end of a non-empty text selection.
|
||||
const [selectionMessageIds, setSelectionMessageIds] = useState<readonly string[]>([]);
|
||||
// Set while the jump-to-bottom button's smooth scroll runs.
|
||||
const smoothScrollRef = useRef(false);
|
||||
// Ends the glide; `true` pins to the bottom, `false` just stops tracking.
|
||||
const endSmoothScrollRef = useRef<((pin: boolean) => void) | undefined>(undefined);
|
||||
const messageIndexById = useMemo(
|
||||
() => new Map(messages.map((message, index) => [message.id, index])),
|
||||
[messages],
|
||||
);
|
||||
const pendingMessageIndexes = useMemo(() => {
|
||||
const indexes: number[] = [];
|
||||
messages.forEach((message, index) => {
|
||||
if (message.approval || message.pendingQuestions?.length) indexes.push(index);
|
||||
});
|
||||
return indexes;
|
||||
}, [messages]);
|
||||
|
||||
const navigationTarget =
|
||||
noteMessageNavigation?.conversationId === conversation.id
|
||||
@@ -333,8 +356,20 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
|
||||
}
|
||||
const rendered: number[] = [];
|
||||
for (let index = range.start; index < range.end; index += 1) rendered.push(index);
|
||||
for (const id of keptMessageIds) {
|
||||
const index = messages.findIndex((message) => message.id === id);
|
||||
// Rows that hold user state stay mounted wherever the reader scrolls
|
||||
// (UX parity with the fully mounted list): focus/navigation, rows the user
|
||||
// interacted with (open details, typed answers), the selected text, and
|
||||
// pending questions and approvals.
|
||||
const keptIndexes = new Set<number>();
|
||||
const keepId = (id: string): void => {
|
||||
const index = messageIndexById.get(id);
|
||||
if (index !== undefined) keptIndexes.add(index);
|
||||
};
|
||||
keptMessageIds.forEach(keepId);
|
||||
interactedMessageIds.forEach(keepId);
|
||||
selectionMessageIds.forEach(keepId);
|
||||
pendingMessageIndexes.forEach((index) => keptIndexes.add(index));
|
||||
for (const index of keptIndexes) {
|
||||
if (index >= windowStart && (index < range.start || index >= range.end)) {
|
||||
rendered.push(index);
|
||||
}
|
||||
@@ -479,7 +514,7 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
|
||||
measuredLayoutRef.current = true;
|
||||
setMeasureVersion((version) => version + 1);
|
||||
}
|
||||
if (restorePendingRef.current) return;
|
||||
if (restorePendingRef.current || smoothScrollRef.current) return;
|
||||
if (pinnedToBottomRef.current) {
|
||||
scrollToBottomNow(scrollContainer);
|
||||
return;
|
||||
@@ -517,6 +552,106 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [active, captureAnchor, navigationTarget, view, visibleMessageCount]);
|
||||
|
||||
// Rows the user interacts with keep their local state (open details, typed
|
||||
// question answers, "copied" feedback, Mermaid toggles) as they did when
|
||||
// every loaded row stayed mounted.
|
||||
useEffect(() => {
|
||||
const list = listRef.current;
|
||||
if (!active || !list) return;
|
||||
const rowIdOf = (target: EventTarget | null): string | undefined =>
|
||||
target instanceof Element
|
||||
? target.closest<HTMLElement>(".message-window-row")?.dataset.messageId
|
||||
: undefined;
|
||||
const remember = (event: Event): void => {
|
||||
if (event.type === "click" && !(event.target instanceof Element && event.target.closest("button, summary, a, input, label"))) return;
|
||||
const id = rowIdOf(event.target);
|
||||
if (!id) return;
|
||||
setInteractedMessageIds((ids) =>
|
||||
ids.at(-1) === id ? ids : [...ids.filter((item) => item !== id), id].slice(-maxInteractedRows),
|
||||
);
|
||||
};
|
||||
// `toggle` does not bubble; capture sees it on the way down.
|
||||
list.addEventListener("toggle", remember, true);
|
||||
list.addEventListener("input", remember);
|
||||
list.addEventListener("click", remember);
|
||||
return () => {
|
||||
list.removeEventListener("toggle", remember, true);
|
||||
list.removeEventListener("input", remember);
|
||||
list.removeEventListener("click", remember);
|
||||
};
|
||||
}, [active]);
|
||||
|
||||
// A text selection keeps the rows at both of its ends mounted, so dragging
|
||||
// a selection across many messages does not lose its start.
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
const update = (): void => {
|
||||
const list = listRef.current;
|
||||
const selection = document.getSelection();
|
||||
let next: string[] = [];
|
||||
if (list && selection && !selection.isCollapsed && selection.rangeCount > 0) {
|
||||
for (const node of [selection.anchorNode, selection.focusNode]) {
|
||||
const element = node instanceof Element ? node : node?.parentElement;
|
||||
const id = element && list.contains(element)
|
||||
? element.closest<HTMLElement>(".message-window-row")?.dataset.messageId
|
||||
: undefined;
|
||||
if (id && !next.includes(id)) next.push(id);
|
||||
}
|
||||
}
|
||||
if (next.length === 0) next = [];
|
||||
setSelectionMessageIds((ids) => (sameIds(ids, next) ? ids : next));
|
||||
};
|
||||
document.addEventListener("selectionchange", update);
|
||||
return () => document.removeEventListener("selectionchange", update);
|
||||
}, [active]);
|
||||
|
||||
// Copying a selection that spans unmounted rows: the browser would only
|
||||
// copy mounted text, so fill the gaps from the message content.
|
||||
useEffect(() => {
|
||||
const list = listRef.current;
|
||||
if (!active || !list) return;
|
||||
const onCopy = (event: ClipboardEvent): void => {
|
||||
const selection = document.getSelection();
|
||||
if (!selection || selection.isCollapsed || selection.rangeCount === 0 || !event.clipboardData) return;
|
||||
const range = selection.getRangeAt(0);
|
||||
if (!range.intersectsNode(list)) return;
|
||||
const spacers = [...list.querySelectorAll<HTMLElement>(".message-window-spacer")]
|
||||
.filter((spacer) => spacer.offsetHeight > 0 && range.intersectsNode(spacer));
|
||||
if (spacers.length === 0) return;
|
||||
const model = modelRef.current;
|
||||
if (!model) return;
|
||||
const indexOfBoundary = (node: Node, offset: number, fallback: number): number => {
|
||||
const element = node instanceof Element ? (node.childNodes[offset] as Element | undefined) ?? node : node.parentElement;
|
||||
const id = element instanceof Element ? element.closest<HTMLElement>(".message-window-row")?.dataset.messageId : undefined;
|
||||
const index = id ? model.messages.findIndex((message) => message.id === id) : -1;
|
||||
return index >= 0 ? index : fallback;
|
||||
};
|
||||
const first = indexOfBoundary(range.startContainer, range.startOffset, model.windowStart);
|
||||
const last = indexOfBoundary(range.endContainer, range.endOffset, model.messages.length - 1);
|
||||
const parts: string[] = [];
|
||||
for (let index = Math.max(first, model.windowStart); index <= last; index += 1) {
|
||||
const message = model.messages[index];
|
||||
if (!message) continue;
|
||||
const row = rowElementsRef.current.get(message.id);
|
||||
if (row) {
|
||||
const part = document.createRange();
|
||||
part.selectNodeContents(row);
|
||||
if (index === first && row.contains(range.startContainer)) part.setStart(range.startContainer, range.startOffset);
|
||||
if (index === last && row.contains(range.endContainer)) part.setEnd(range.endContainer, range.endOffset);
|
||||
const text = part.toString().trim();
|
||||
if (text) parts.push(text);
|
||||
} else if (message.content.trim()) {
|
||||
parts.push(message.content.trim());
|
||||
}
|
||||
}
|
||||
if (parts.length === 0) return;
|
||||
event.clipboardData.setData("text/plain", parts.join("\n\n"));
|
||||
event.preventDefault();
|
||||
};
|
||||
document.addEventListener("copy", onCopy);
|
||||
return () => document.removeEventListener("copy", onCopy);
|
||||
}, [active]);
|
||||
|
||||
const handleArticleRef = useCallback(
|
||||
(messageId: string, element: HTMLElement | null): void => {
|
||||
if (element) {
|
||||
@@ -663,6 +798,18 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
|
||||
const updateScrollPosition = useCallback((): void => {
|
||||
const scrollContainer = scrollRef.current;
|
||||
if (!scrollContainer) return;
|
||||
if (smoothScrollRef.current) {
|
||||
// Jump-to-bottom glide: re-window around the moving viewport only.
|
||||
// Arriving at the bottom ends it (`scrollend` is not always delivered).
|
||||
if (scrollContainer.scrollHeight - scrollContainer.scrollTop - scrollContainer.clientHeight < 1) {
|
||||
endSmoothScrollRef.current?.(true);
|
||||
return;
|
||||
}
|
||||
saveScrollPosition(scrollContainer);
|
||||
anchorRef.current = undefined;
|
||||
scrollFrameRef.current ??= requestAnimationFrame(processScroll);
|
||||
return;
|
||||
}
|
||||
const distanceFromBottom =
|
||||
scrollContainer.scrollHeight - scrollContainer.scrollTop - scrollContainer.clientHeight;
|
||||
if (distanceFromBottom <= chatBottomProximity) {
|
||||
@@ -710,6 +857,8 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
|
||||
}
|
||||
const activated = !wasActiveRef.current;
|
||||
wasActiveRef.current = true;
|
||||
// Any scrollTop write would cancel the jump-to-bottom glide.
|
||||
if (smoothScrollRef.current) return;
|
||||
// Styles may have changed while hidden (frosted glass toggled).
|
||||
if (activated) paddingTopRef.current = undefined;
|
||||
const measuredLayout = measuredLayoutRef.current;
|
||||
@@ -799,7 +948,7 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
|
||||
followFrameRef.current = requestAnimationFrame(() => {
|
||||
followFrameRef.current = undefined;
|
||||
const container = scrollRef.current;
|
||||
if (!container || !pinnedToBottomRef.current) {
|
||||
if (!container || !pinnedToBottomRef.current || smoothScrollRef.current) {
|
||||
return;
|
||||
}
|
||||
scrollToBottomNow(container);
|
||||
@@ -848,21 +997,78 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
|
||||
visibleMessageCount,
|
||||
]);
|
||||
|
||||
useEffect(() => () => endSmoothScrollRef.current?.(false), []);
|
||||
|
||||
const scrollToBottom = (): void => {
|
||||
const scrollContainer = scrollRef.current;
|
||||
if (!scrollContainer) {
|
||||
return;
|
||||
}
|
||||
pinnedToBottomRef.current = true;
|
||||
showScrollToBottomRef.current = false;
|
||||
setShowScrollToBottom(false);
|
||||
setView(bottomView);
|
||||
const reduceMotion =
|
||||
typeof window.matchMedia === "function" &&
|
||||
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
if (reduceMotion) {
|
||||
pinnedToBottomRef.current = true;
|
||||
setView(bottomView);
|
||||
scrollContainer.scrollTo({ top: scrollContainer.scrollHeight, behavior: "auto" });
|
||||
return;
|
||||
}
|
||||
// The glide (UX): rows keep re-windowing around the moving viewport, but
|
||||
// nothing writes scrollTop until it ends, because any write would cancel
|
||||
// the browser's smooth scroll. At the end it pins to the real bottom.
|
||||
endSmoothScrollRef.current?.(false);
|
||||
smoothScrollRef.current = true;
|
||||
pinnedToBottomRef.current = false;
|
||||
const finish = (pin: boolean): void => {
|
||||
if (!smoothScrollRef.current) return;
|
||||
smoothScrollRef.current = false;
|
||||
cleanup();
|
||||
const container = scrollRef.current;
|
||||
if (!container) return;
|
||||
if (pin) {
|
||||
pinnedToBottomRef.current = true;
|
||||
anchorRef.current = undefined;
|
||||
setView(bottomView);
|
||||
scrollToBottomNow(container);
|
||||
}
|
||||
// Re-evaluate the pin and the button from where the glide stopped.
|
||||
container.dispatchEvent(new Event("scroll"));
|
||||
};
|
||||
// A `scrollend` of an earlier scroll can arrive just after the glide
|
||||
// starts; only one at (or stalled near) the bottom ends it.
|
||||
const onEnd = (): void => {
|
||||
const remaining = scrollContainer.scrollHeight - scrollContainer.scrollTop - scrollContainer.clientHeight;
|
||||
if (remaining <= chatBottomProximity) finish(true);
|
||||
};
|
||||
// The reader takes over: keep wherever they stop.
|
||||
const onInterrupt = (): void => finish(false);
|
||||
const cleanup = (): void => {
|
||||
window.clearTimeout(timer);
|
||||
scrollContainer.removeEventListener("scrollend", onEnd);
|
||||
scrollContainer.removeEventListener("wheel", onInterrupt);
|
||||
scrollContainer.removeEventListener("pointerdown", onInterrupt);
|
||||
scrollContainer.removeEventListener("keydown", onInterrupt);
|
||||
endSmoothScrollRef.current = undefined;
|
||||
};
|
||||
endSmoothScrollRef.current = (pin) => {
|
||||
if (pin) {
|
||||
finish(true);
|
||||
return;
|
||||
}
|
||||
smoothScrollRef.current = false;
|
||||
cleanup();
|
||||
};
|
||||
scrollContainer.addEventListener("scrollend", onEnd);
|
||||
scrollContainer.addEventListener("wheel", onInterrupt, { passive: true });
|
||||
scrollContainer.addEventListener("pointerdown", onInterrupt);
|
||||
scrollContainer.addEventListener("keydown", onInterrupt);
|
||||
// No `scrollend` (already at the target, or unsupported): end anyway.
|
||||
const timer = window.setTimeout(() => finish(true), 1_500);
|
||||
scrollContainer.scrollTo({
|
||||
top: scrollContainer.scrollHeight,
|
||||
behavior: reduceMotion ? "auto" : "smooth",
|
||||
behavior: "smooth",
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -331,4 +331,48 @@ describe('ConversationSidebar windowing', () => {
|
||||
act(() => { vi.advanceTimersByTime(5_000) })
|
||||
expect(renderedIds(list)[0]).toBe('c800')
|
||||
})
|
||||
|
||||
/** The first visible row and its offset from the viewport top. */
|
||||
function readerRow(list: HTMLElement): { id: string; top: number } {
|
||||
const id = visibleIds(list)[0]!
|
||||
const row = list.querySelector<HTMLElement>(`[data-conversation-window-row="${id}"]`)!
|
||||
return { id, top: row.getBoundingClientRect().top }
|
||||
}
|
||||
|
||||
it('keeps the visible rows in place when rows are inserted, removed or reordered above them', () => {
|
||||
vi.useFakeTimers()
|
||||
const { list, store } = setup(1_000)
|
||||
scrollList(list, 12_010)
|
||||
const before = readerRow(list)
|
||||
expect(before.id).toBe('c300')
|
||||
|
||||
// A new conversation at the top.
|
||||
act(() => store.set((current) => [
|
||||
{ id: 'new', title: 'New', updatedAt: 2_000_000, messages: [] } as unknown as Conversation, ...current,
|
||||
]))
|
||||
expect(readerRow(list)).toEqual(before)
|
||||
|
||||
// A delete above the reader.
|
||||
act(() => store.set((current) => current.filter((item) => item.id !== idOf(5))))
|
||||
expect(readerRow(list)).toEqual(before)
|
||||
|
||||
// Pinning a row from below moves it above the reader.
|
||||
act(() => store.set((current) => current.map((item) => item.id === idOf(900) ? { ...item, pinned: true } : item)))
|
||||
expect(readerRow(list)).toEqual(before)
|
||||
|
||||
// The 5 s reorder moves a row from below to the top.
|
||||
act(() => store.set((current) => current.map((item) =>
|
||||
item.id === idOf(700) ? { ...item, messageSummary: { count: 1, latestMessageAt: 3_000_000 } } : item)))
|
||||
act(() => { vi.advanceTimersByTime(5_000) })
|
||||
expect(readerRow(list)).toEqual(before)
|
||||
})
|
||||
|
||||
it('keeps the top of the list at the top when a row is inserted there', () => {
|
||||
const { list, store } = setup(1_000)
|
||||
act(() => store.set((current) => [
|
||||
{ id: 'new', title: 'New', updatedAt: 2_000_000, messages: [] } as unknown as Conversation, ...current,
|
||||
]))
|
||||
expect(list.scrollTop).toBe(0)
|
||||
expect(visibleIds(list)[0]).toBe('new')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import i18n from './i18n'
|
||||
@@ -81,6 +83,11 @@ describe('MagicNotesWorkspace note list windowing', () => {
|
||||
expect(first).toHaveAttribute('aria-setsize', String(count))
|
||||
})
|
||||
|
||||
it('turns native scroll anchoring off for the windowed grid only', () => {
|
||||
const css = readFileSync(join(process.cwd(), 'src/renderer/src/magic-notes-panel.css'), 'utf8')
|
||||
expect(css).toMatch(/\.magic-notes-card-grid--windowed\s*\{\s*overflow-anchor:\s*none;\s*\}/)
|
||||
})
|
||||
|
||||
it('mounts the notes at the scroll position', async () => {
|
||||
await setup()
|
||||
layout.scroll(grid(), 1_500 * rowHeight)
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
vi
|
||||
} from 'vitest'
|
||||
import { changeUiLocale } from './i18n'
|
||||
import { clearMermaidResultCacheForTest } from './MermaidDiagram'
|
||||
import {
|
||||
InlineMarkdown,
|
||||
MarkdownRenderer,
|
||||
@@ -71,6 +72,7 @@ describe('MarkdownRenderer', () => {
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
clearMermaidResultCacheForTest()
|
||||
delete document.documentElement.dataset.theme
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
@@ -394,6 +396,19 @@ $\\href{javascript:alert(1)}{unsafe}$`}</MarkdownRenderer>
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('reuses a rendered Mermaid diagram when the row remounts (windowed chat)', async () => {
|
||||
const source = '```mermaid\nflowchart LR\n Cached --> Again\n```'
|
||||
const first = render(<MarkdownRenderer>{source}</MarkdownRenderer>)
|
||||
await screen.findByRole('region', { name: 'Mermaid 图表,可横向滚动' })
|
||||
first.unmount()
|
||||
expect(mermaidMock.render).toHaveBeenCalledTimes(1)
|
||||
const again = render(<MarkdownRenderer>{source}</MarkdownRenderer>)
|
||||
// Shown immediately: no loading state, no second render.
|
||||
expect(again.container.querySelector('#safe-node')).toBeInTheDocument()
|
||||
expect(screen.queryByText('正在绘制 Mermaid 图表…')).toBeNull()
|
||||
expect(mermaidMock.render).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('renders Mermaid fences with strict bounded configuration', async () => {
|
||||
const { container } = render(
|
||||
<MarkdownRenderer>{`\`\`\`mermaid
|
||||
|
||||
@@ -74,6 +74,44 @@ let mermaidModulePromise: Promise<Mermaid> | undefined
|
||||
let mermaidRenderQueue = Promise.resolve()
|
||||
let mermaidDiagramSequence = 0
|
||||
|
||||
/**
|
||||
* Rendered results by theme and source. Windowed chat rows remount when they
|
||||
* scroll back into view; reusing the result keeps them from flashing the
|
||||
* loading state and changing height (UX parity with fully mounted lists).
|
||||
*/
|
||||
const mermaidResultCacheLimit = 64
|
||||
const mermaidResultCache = new Map<string, MermaidRenderState>()
|
||||
|
||||
function mermaidCacheKey(source: string, theme: 'light' | 'dark'): string {
|
||||
return `${theme}\n${source}`
|
||||
}
|
||||
|
||||
function cachedMermaidResult(source: string, theme: 'light' | 'dark'): MermaidRenderState | undefined {
|
||||
const key = mermaidCacheKey(source, theme)
|
||||
const cached = mermaidResultCache.get(key)
|
||||
if (cached) {
|
||||
mermaidResultCache.delete(key)
|
||||
mermaidResultCache.set(key, cached)
|
||||
}
|
||||
return cached
|
||||
}
|
||||
|
||||
function rememberMermaidResult(state: MermaidRenderState): void {
|
||||
const key = mermaidCacheKey(state.source, state.theme)
|
||||
mermaidResultCache.delete(key)
|
||||
mermaidResultCache.set(key, state)
|
||||
while (mermaidResultCache.size > mermaidResultCacheLimit) {
|
||||
const oldest = mermaidResultCache.keys().next().value
|
||||
if (oldest === undefined) break
|
||||
mermaidResultCache.delete(oldest)
|
||||
}
|
||||
}
|
||||
|
||||
/** Test hook. */
|
||||
export function clearMermaidResultCacheForTest(): void {
|
||||
mermaidResultCache.clear()
|
||||
}
|
||||
|
||||
type MermaidRenderState =
|
||||
| {
|
||||
source: string
|
||||
@@ -552,13 +590,15 @@ export function MermaidDiagram({
|
||||
const sourceId = useId()
|
||||
const documentTheme = useDocumentTheme()
|
||||
const [renderState, setRenderState] =
|
||||
useState<MermaidRenderState>()
|
||||
useState<MermaidRenderState | undefined>(() => cachedMermaidResult(source, documentTheme))
|
||||
const [visibleSource, setVisibleSource] = useState<string>()
|
||||
const [viewerSource, setViewerSource] = useState<string>()
|
||||
const sourceVisible = visibleSource === source
|
||||
const viewerOpen = viewerSource === source
|
||||
|
||||
useEffect(() => {
|
||||
// A cached result is read during render below; nothing to do here.
|
||||
if (mermaidResultCache.has(mermaidCacheKey(source, documentTheme))) return
|
||||
let active = true
|
||||
const renderRevision = renderRevisionRef.current++
|
||||
const timeout = window.setTimeout(() => {
|
||||
@@ -570,12 +610,14 @@ export function MermaidDiagram({
|
||||
).then(
|
||||
(svg) => {
|
||||
if (active) {
|
||||
setRenderState({
|
||||
const state: MermaidRenderState = {
|
||||
source,
|
||||
status: 'ready',
|
||||
svg,
|
||||
theme: documentTheme
|
||||
})
|
||||
}
|
||||
rememberMermaidResult(state)
|
||||
setRenderState(state)
|
||||
}
|
||||
},
|
||||
() => {
|
||||
@@ -600,7 +642,7 @@ export function MermaidDiagram({
|
||||
renderState?.source === source &&
|
||||
renderState.theme === documentTheme
|
||||
? renderState
|
||||
: undefined
|
||||
: mermaidResultCache.get(mermaidCacheKey(source, documentTheme))
|
||||
const retainedSvg =
|
||||
renderState?.status === 'ready' &&
|
||||
renderState.source === source
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -485,7 +485,7 @@ describe('TerminalPanel', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('batches output into one write per frame and ACKs after the write callback', async () => {
|
||||
it('writes idle output at once, merges the rest of the frame, and ACKs after the write callback', async () => {
|
||||
const frames = new Set<() => void>()
|
||||
const frameScheduler = (callback: () => void): (() => void) => {
|
||||
frames.add(callback)
|
||||
@@ -517,18 +517,27 @@ describe('TerminalPanel', () => {
|
||||
expect(backend.adapter.create).toHaveBeenCalledOnce()
|
||||
)
|
||||
|
||||
for (let sequence = 1; sequence <= 5; sequence += 1) {
|
||||
// The first output (a keystroke echo) is written without waiting for a frame.
|
||||
backend.emit({ sessionId, sequence: 1, type: 'output', data: '1;' })
|
||||
await waitFor(() => expect(emulator.write).toHaveBeenCalledWith('1;', expect.any(Function)))
|
||||
expect(frames.size).toBe(1)
|
||||
callbacks.shift()?.()
|
||||
expect(backend.adapter.ack).toHaveBeenCalledWith({ sessionId, sequence: 1 })
|
||||
for (let sequence = 2; sequence <= 5; sequence += 1) {
|
||||
backend.emit({ sessionId, sequence, type: 'output', data: `${sequence};` })
|
||||
}
|
||||
await waitFor(() => expect(frames.size).toBe(1))
|
||||
expect(emulator.write).not.toHaveBeenCalled()
|
||||
runFrame()
|
||||
// Wait until the event queue has consumed every event.
|
||||
await act(async () => {
|
||||
for (let tick = 0; tick < 10; tick += 1) await Promise.resolve()
|
||||
})
|
||||
expect(emulator.write).toHaveBeenCalledOnce()
|
||||
expect(emulator.write).toHaveBeenCalledWith('1;2;3;4;5;', expect.any(Function))
|
||||
expect(backend.adapter.ack).not.toHaveBeenCalled()
|
||||
callbacks.shift()?.()
|
||||
runFrame()
|
||||
expect(emulator.write).toHaveBeenCalledTimes(2)
|
||||
expect(emulator.write).toHaveBeenLastCalledWith('2;3;4;5;', expect.any(Function))
|
||||
expect(backend.adapter.ack).toHaveBeenCalledOnce()
|
||||
expect(backend.adapter.ack).toHaveBeenCalledWith({ sessionId, sequence: 5 })
|
||||
callbacks.shift()?.()
|
||||
expect(backend.adapter.ack).toHaveBeenCalledTimes(2)
|
||||
expect(backend.adapter.ack).toHaveBeenLastCalledWith({ sessionId, sequence: 5 })
|
||||
|
||||
// A control event flushes pending output first and is applied after it.
|
||||
backend.emit({ sessionId, sequence: 6, type: 'output', data: 'bye' })
|
||||
@@ -547,7 +556,46 @@ describe('TerminalPanel', () => {
|
||||
expect(backend.adapter.ack).toHaveBeenLastCalledWith({ sessionId, sequence: 7 })
|
||||
)
|
||||
expect(backend.adapter.ack).toHaveBeenCalledWith({ sessionId, sequence: 6 })
|
||||
expect(emulator.write).toHaveBeenCalledTimes(2)
|
||||
expect(emulator.write).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('writes output that arrived before Clear and then clears', async () => {
|
||||
const frames = new Set<() => void>()
|
||||
const frameScheduler = (callback: () => void): (() => void) => {
|
||||
frames.add(callback)
|
||||
return () => frames.delete(callback)
|
||||
}
|
||||
const backend = adapterHarness()
|
||||
const emulator = emulatorHarness()
|
||||
const order: string[] = []
|
||||
const callbacks: Array<() => void> = []
|
||||
emulator.write.mockImplementation((data: string, callback?: () => void) => {
|
||||
order.push(`write:${data}`)
|
||||
if (callback) callbacks.push(callback)
|
||||
})
|
||||
vi.mocked(emulator.terminal.clear).mockImplementation(() => { order.push('clear') })
|
||||
render(
|
||||
<TerminalPanel
|
||||
adapter={backend.adapter}
|
||||
frameScheduler={frameScheduler}
|
||||
sessionId={sessionId}
|
||||
target={{ type: 'local' }}
|
||||
terminalFactory={emulator.factory}
|
||||
/>
|
||||
)
|
||||
await screen.findByText('PowerShell 7')
|
||||
backend.emit({ sessionId, sequence: 1, type: 'output', data: 'a' })
|
||||
backend.emit({ sessionId, sequence: 2, type: 'output', data: 'b' })
|
||||
await waitFor(() => expect(order).toEqual(['write:a']))
|
||||
await act(async () => {
|
||||
for (let tick = 0; tick < 10; tick += 1) await Promise.resolve()
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: '清屏' }))
|
||||
// The pending 'b' is written before the clear, and the clear waits for xterm.
|
||||
expect(order).toEqual(['write:a', 'write:b'])
|
||||
callbacks.splice(0).forEach((callback) => callback())
|
||||
await waitFor(() => expect(order).toEqual(['write:a', 'write:b', 'clear']))
|
||||
expect(backend.adapter.ack).toHaveBeenLastCalledWith({ sessionId, sequence: 2 })
|
||||
})
|
||||
|
||||
it('keeps writing output for a hidden terminal tab', async () => {
|
||||
@@ -569,8 +617,10 @@ describe('TerminalPanel', () => {
|
||||
await screen.findByText('PowerShell 7')
|
||||
backend.emit({ sessionId, sequence: 1, type: 'output', data: 'a' })
|
||||
backend.emit({ sessionId, sequence: 2, type: 'output', data: 'b' })
|
||||
// 'a' is written at once; 'b' arrives in the same frame window and is
|
||||
// written by the timer fallback (hidden windows run no frames).
|
||||
await waitFor(() =>
|
||||
expect(emulator.write).toHaveBeenCalledWith('ab', expect.any(Function))
|
||||
expect(emulator.write.mock.calls.map(([data]) => data)).toEqual(['a', 'b'])
|
||||
)
|
||||
await waitFor(() =>
|
||||
expect(backend.adapter.ack).toHaveBeenCalledWith({ sessionId, sequence: 2 })
|
||||
|
||||
@@ -279,8 +279,9 @@ export function TerminalPanel({
|
||||
acceptedSequenceRef.current = event.sequence
|
||||
|
||||
if (event.type === 'output') {
|
||||
// Merged with neighbours and written once per frame; the batcher ACKs
|
||||
// the batch's last sequence after xterm has processed it.
|
||||
// Written at once when idle, otherwise merged with the rest of the
|
||||
// frame; the batcher ACKs the batch's last sequence after xterm has
|
||||
// processed it.
|
||||
outputBatcherRef.current?.push(
|
||||
event.sessionId,
|
||||
event.sequence,
|
||||
@@ -700,8 +701,17 @@ export function TerminalPanel({
|
||||
title={t('sidebar.terminal.toolbar.clear')}
|
||||
aria-label={t('sidebar.terminal.toolbar.clear')}
|
||||
onClick={() => {
|
||||
emulatorRef.current?.clear()
|
||||
emulatorRef.current?.focus()
|
||||
// Output that arrived before the click is shown, then cleared:
|
||||
// write what the batcher holds and wait until xterm processed it
|
||||
// (xterm's clear() does not queue behind pending writes).
|
||||
const emulator = emulatorRef.current
|
||||
const flushed = outputBatcherRef.current?.flush() ?? Promise.resolve()
|
||||
void flushed.then(() => {
|
||||
if (emulatorRef.current === emulator) {
|
||||
emulator?.clear()
|
||||
}
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Eraser aria-hidden="true" />
|
||||
|
||||
@@ -614,7 +614,7 @@ export function WorkspaceFilesPanel({
|
||||
</div>
|
||||
</WorkspaceGitTools>}
|
||||
</div>
|
||||
<div className="workspace-files__files-view" hidden={activeView !== 'files'}>
|
||||
<div className={treeWindow.windowed ? 'workspace-files__files-view workspace-files__files-view--windowed' : 'workspace-files__files-view'} hidden={activeView !== 'files'}>
|
||||
{loadingPaths.has(browsedPath) && !root ? (
|
||||
<p className="assistant-sidebar__empty">
|
||||
{t('files.readingWorkspace')}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { changeUiLocale } from './i18n'
|
||||
@@ -109,6 +111,16 @@ describe('WorkspaceFilesPanel tree windowing', () => {
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'a-002.txt 的更多操作' })).toHaveFocus())
|
||||
}, 20_000)
|
||||
|
||||
it('turns native scroll anchoring off on the files view only while the tree is windowed', async () => {
|
||||
const css = readFileSync(join(process.cwd(), 'src/renderer/src/styles.css'), 'utf8')
|
||||
expect(css).toMatch(/\.workspace-files__files-view--windowed\s*\{\s*overflow-anchor:\s*none;/)
|
||||
await setup()
|
||||
expect(body()).toHaveClass('workspace-files__files-view--windowed')
|
||||
// Collapsing everything drops below the threshold: native anchoring is back.
|
||||
for (const directory of directories) fireEvent.click(screen.getByRole('button', { name: directory }))
|
||||
expect(body()).not.toHaveClass('workspace-files__files-view--windowed')
|
||||
}, 20_000)
|
||||
|
||||
it('collapses and expands directories in the windowed tree', async () => {
|
||||
await setup()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'a' }))
|
||||
|
||||
@@ -519,7 +519,7 @@ export function DocumentsView({
|
||||
: t('documents.table.noResults')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="knowledge-documents__table-scroll">
|
||||
<div className={documentWindow.windowed ? 'knowledge-documents__table-scroll knowledge-documents__table-scroll--windowed' : 'knowledge-documents__table-scroll'}>
|
||||
<table aria-label={t('documents.table.title')} aria-rowcount={documentWindow.windowed ? rows.length + 1 : undefined}>
|
||||
<thead>
|
||||
<tr aria-rowindex={documentWindow.windowed ? 1 : undefined}>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import i18n from '../i18n'
|
||||
@@ -126,5 +128,13 @@ describe('DocumentsView windowing', () => {
|
||||
expect(renderedNames()).toHaveLength(10)
|
||||
expect(table().querySelector('.knowledge-documents__spacer')).toBeNull()
|
||||
expect(table()).not.toHaveAttribute('aria-rowcount')
|
||||
expect(document.querySelector('.knowledge-documents__table-scroll')).not.toHaveClass('knowledge-documents__table-scroll--windowed')
|
||||
})
|
||||
|
||||
it('turns native scroll anchoring off on the page only while the table is windowed', () => {
|
||||
const css = readFileSync(join(process.cwd(), 'src/renderer/src/styles.css'), 'utf8')
|
||||
expect(css).toMatch(/\.workspace-panel-scroll:has\(\.knowledge-documents__table-scroll--windowed\),\s*\.knowledge-documents__table-scroll--windowed\s*\{\s*overflow-anchor:\s*none;/)
|
||||
setup()
|
||||
expect(document.querySelector('.knowledge-documents__table-scroll')).toHaveClass('knowledge-documents__table-scroll--windowed')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -35,5 +35,8 @@
|
||||
stand in for unmounted rows. Row spacing moves into each row's padding so
|
||||
spacer heights and measured rows add up exactly. */
|
||||
.magic-notes-list.magic-notes-card-grid.magic-notes-card-grid--windowed { row-gap: 0; }
|
||||
/* The grid keeps the reader's row in place itself; native scroll anchoring
|
||||
could pick a spacer and fight the correction. */
|
||||
.magic-notes-card-grid--windowed { overflow-anchor: none; }
|
||||
.magic-notes-card-grid--windowed > .magic-notes-window-row { display: grid; grid-column: 1 / -1; grid-template-columns: subgrid; align-items: stretch; }
|
||||
.magic-notes-card-grid--windowed > .magic-notes-list__spacer { grid-column: 1 / -1; }
|
||||
|
||||
@@ -34,7 +34,7 @@ describe('TerminalOutputBatcher', () => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('writes once per frame in order and ACKs the last sequence after the write callback', async () => {
|
||||
it('writes idle output at once, merges the rest of the frame, and ACKs the last sequence after the write callback', async () => {
|
||||
const frames = manualScheduler()
|
||||
const writes: Array<{ data: string; done: () => void }> = []
|
||||
const acks: number[] = []
|
||||
@@ -43,43 +43,62 @@ describe('TerminalOutputBatcher', () => {
|
||||
acknowledge: (_, sequence) => acks.push(sequence),
|
||||
schedule: frames.schedule
|
||||
})
|
||||
// Leading edge: a keystroke echo is written without waiting for a frame.
|
||||
batcher.push('s', 2, 'k')
|
||||
expect(writes.map((write) => write.data)).toEqual(['k'])
|
||||
expect(frames.pendingFrames()).toBe(1)
|
||||
writes[0]!.done()
|
||||
expect(acks).toEqual([2])
|
||||
|
||||
// Output in the same frame is merged and written once when the frame runs.
|
||||
batcher.push('s', 3, 'a')
|
||||
batcher.push('s', 4, 'b')
|
||||
batcher.push('s', 5, 'c')
|
||||
expect(writes).toHaveLength(1)
|
||||
frames.runFrame()
|
||||
expect(writes.map((write) => write.data)).toEqual(['k', 'abc'])
|
||||
expect(acks).toEqual([2])
|
||||
writes[1]!.done()
|
||||
expect(acks).toEqual([2, 5])
|
||||
// A busy frame keeps the window open: the next output waits a frame.
|
||||
expect(frames.pendingFrames()).toBe(1)
|
||||
expect(writes).toHaveLength(0)
|
||||
|
||||
frames.runFrame()
|
||||
expect(writes.map((write) => write.data)).toEqual(['abc'])
|
||||
expect(acks).toEqual([])
|
||||
writes[0]!.done()
|
||||
expect(acks).toEqual([5])
|
||||
|
||||
batcher.push('s', 6, 'd')
|
||||
expect(writes).toHaveLength(2)
|
||||
frames.runFrame()
|
||||
writes[1]!.done()
|
||||
writes[1]!.done()
|
||||
expect(writes.map((write) => write.data)).toEqual(['abc', 'd'])
|
||||
expect(acks).toEqual([5, 6])
|
||||
writes[2]!.done()
|
||||
writes[2]!.done()
|
||||
expect(writes.map((write) => write.data)).toEqual(['k', 'abc', 'd'])
|
||||
expect(acks).toEqual([2, 5, 6])
|
||||
|
||||
// A frame without output returns to idle: the next output is immediate.
|
||||
frames.runFrame()
|
||||
expect(frames.pendingFrames()).toBe(0)
|
||||
batcher.push('s', 7, 'e')
|
||||
expect(writes.map((write) => write.data)).toEqual(['k', 'abc', 'd', 'e'])
|
||||
batcher.dispose()
|
||||
})
|
||||
|
||||
it('flush writes pending output immediately and waits for the emulator', async () => {
|
||||
const frames = manualScheduler()
|
||||
let release: (() => void) | undefined
|
||||
const written: string[] = []
|
||||
const batcher = new TerminalOutputBatcher({
|
||||
write: (_, done) => {
|
||||
write: (data, done) => {
|
||||
written.push(data)
|
||||
release = done
|
||||
},
|
||||
acknowledge: vi.fn(),
|
||||
schedule: frames.schedule
|
||||
})
|
||||
batcher.push('s', 1, 'x')
|
||||
batcher.push('s', 2, 'y')
|
||||
expect(written).toEqual(['x'])
|
||||
let flushed = false
|
||||
const flush = batcher.flush().then(() => {
|
||||
flushed = true
|
||||
})
|
||||
expect(frames.pendingFrames()).toBe(0)
|
||||
expect(written).toEqual(['x', 'y'])
|
||||
await Promise.resolve()
|
||||
expect(flushed).toBe(false)
|
||||
release?.()
|
||||
@@ -97,10 +116,12 @@ describe('TerminalOutputBatcher', () => {
|
||||
acknowledge,
|
||||
schedule: frames.schedule
|
||||
})
|
||||
batcher.push('old', 8, 'shown')
|
||||
batcher.push('old', 9, 'stale')
|
||||
batcher.reset()
|
||||
frames.runFrame()
|
||||
expect(write).not.toHaveBeenCalled()
|
||||
expect(write).toHaveBeenCalledOnce()
|
||||
expect(write).toHaveBeenCalledWith('shown', expect.any(Function))
|
||||
expect(acknowledge).toHaveBeenCalledWith('old', 9)
|
||||
batcher.dispose()
|
||||
})
|
||||
@@ -139,9 +160,14 @@ describe('TerminalOutputBatcher', () => {
|
||||
})
|
||||
batcher.push('s', 1, 'hidden ')
|
||||
batcher.push('s', 2, 'output')
|
||||
batcher.push('s', 3, '!')
|
||||
expect(writes).toEqual(['hidden '])
|
||||
vi.advanceTimersByTime(TERMINAL_FRAME_FALLBACK_MS)
|
||||
expect(writes).toEqual(['hidden output'])
|
||||
expect(acks).toEqual([2])
|
||||
expect(writes).toEqual(['hidden ', 'output!'])
|
||||
expect(acks).toEqual([1, 3])
|
||||
// The window closes after an empty fallback tick; no timers linger.
|
||||
vi.advanceTimersByTime(TERMINAL_FRAME_FALLBACK_MS)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
batcher.dispose()
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
/**
|
||||
* Renderer-side terminal output batching (P3): output events are merged and
|
||||
* written to the emulator at most once per animation frame, in sequence order.
|
||||
* Renderer-side terminal output batching (P3): output arriving while idle is
|
||||
* written to the emulator at once (no added echo latency); output arriving
|
||||
* within the same frame after that is merged and written on the next
|
||||
* animation frame, in sequence order.
|
||||
* Each batch is acknowledged with its highest sequence after the emulator's
|
||||
* write callback, which is what Main's ACK-based backpressure expects.
|
||||
*/
|
||||
@@ -71,7 +73,14 @@ export class TerminalOutputBatcher {
|
||||
return this.pending.length > 0
|
||||
}
|
||||
|
||||
/** Queues output; events must be pushed in increasing sequence order. */
|
||||
/**
|
||||
* Queues output; events must be pushed in increasing sequence order.
|
||||
*
|
||||
* Leading edge: output arriving while idle (no write in the current frame
|
||||
* window) is written at once, so keystroke echo does not wait for a frame.
|
||||
* Output arriving after that within the same frame is merged and written
|
||||
* when the frame runs; a steady stream thus writes at most once per frame.
|
||||
*/
|
||||
push(sessionId: string, sequence: number, data: string): void {
|
||||
if (this.disposed) {
|
||||
return
|
||||
@@ -83,9 +92,23 @@ export class TerminalOutputBatcher {
|
||||
this.pendingSessionId = sessionId
|
||||
this.pendingSequence = sequence
|
||||
this.pending.push(data)
|
||||
this.cancelFrame ??= this.schedule(() => {
|
||||
if (this.cancelFrame) {
|
||||
return
|
||||
}
|
||||
void this.writePending()
|
||||
this.openFrameWindow()
|
||||
}
|
||||
|
||||
/** Until the frame runs, further output is merged instead of written. */
|
||||
private openFrameWindow(): void {
|
||||
this.cancelFrame = this.schedule(() => {
|
||||
this.cancelFrame = undefined
|
||||
void this.flush()
|
||||
if (this.disposed || this.pending.length === 0) {
|
||||
// Nothing arrived during the frame: idle again.
|
||||
return
|
||||
}
|
||||
void this.writePending()
|
||||
this.openFrameWindow()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -99,6 +122,10 @@ export class TerminalOutputBatcher {
|
||||
}
|
||||
this.cancelFrame?.()
|
||||
this.cancelFrame = undefined
|
||||
return this.writePending()
|
||||
}
|
||||
|
||||
private writePending(): Promise<void> {
|
||||
if (this.pending.length > 0) {
|
||||
const data = this.pending.length === 1 ? this.pending[0]! : this.pending.join('')
|
||||
const sessionId = this.pendingSessionId!
|
||||
|
||||
@@ -184,6 +184,13 @@ export function useListWindow({
|
||||
const rowRefCallbacks = useRef(new Map<string, (element: HTMLElement | null) => void>())
|
||||
const observerRef = useRef<ResizeObserver | undefined>(undefined)
|
||||
const scrollAnchorRef = useRef<{ id: string; top: number } | undefined>(undefined)
|
||||
// List-relative scroll position seen at the last scroll event or commit, and
|
||||
// the ids and offsets of the last committed windowed layout. Together they
|
||||
// locate the reader's row when ids change, without reading layout per scroll.
|
||||
const scrollTopRef = useRef(0)
|
||||
const committedRef = useRef<
|
||||
{ ids: readonly string[]; offsets: readonly number[]; viewport: number } | undefined
|
||||
>(undefined)
|
||||
const scopeRef = useRef(scope)
|
||||
const scrollParentRef = useRef(scrollParent)
|
||||
useLayoutEffect(() => {
|
||||
@@ -284,6 +291,7 @@ export function useListWindow({
|
||||
const layoutNow = layoutRef.current
|
||||
if (!current || !layoutNow || !activeRef.current || isHidden(current.list)) return
|
||||
const { top, viewport } = position(current)
|
||||
scrollTopRef.current = top
|
||||
if (heights.setViewport(viewport)) setMeasureVersion((version) => version + 1)
|
||||
// Hysteresis: re-window only when the viewport (plus a margin) leaves the
|
||||
// mounted rows, so a steady scroll remounts rows every screen or so.
|
||||
@@ -348,28 +356,45 @@ export function useListWindow({
|
||||
const current = geometry()
|
||||
if (!current) return
|
||||
const { container } = current
|
||||
const committed = committedRef.current
|
||||
committedRef.current = windowed && layoutRef.current
|
||||
? { ids, offsets: layoutRef.current.offsets, viewport: layoutRef.current.viewport }
|
||||
: undefined
|
||||
let idsAnchor: { id: string; top: number } | undefined
|
||||
if (scopeRef.current !== scope) {
|
||||
scopeRef.current = scope
|
||||
scrollAnchorRef.current = undefined
|
||||
// New search results start at the top; short lists keep the browser's
|
||||
// own clamping, as before windowing.
|
||||
if (resetScrollOnScopeChange && windowed && container && container.scrollTop !== 0) container.scrollTop = 0
|
||||
} else if (windowed && committed && committed.ids !== ids) {
|
||||
// Rows were inserted, removed or reordered: keep the reader's row where
|
||||
// it was, like native scroll anchoring did before windowing (which the
|
||||
// windowed list turns off). The top of the list stays the top.
|
||||
idsAnchor = idsChangeAnchor(committed.ids, committed.offsets, ids, scrollTopRef.current, committed.viewport)
|
||||
}
|
||||
const anchor = scrollAnchorRef.current
|
||||
const anchor = idsAnchor ?? scrollAnchorRef.current
|
||||
scrollAnchorRef.current = undefined
|
||||
if (windowed && anchor) {
|
||||
const element = rowElements.current.get(anchor.id)
|
||||
let delta: number | undefined
|
||||
if (element) {
|
||||
const delta = element.getBoundingClientRect().top - viewportTop(container) - anchor.top
|
||||
if (Math.abs(delta) >= 0.5) {
|
||||
if (container) container.scrollTop += delta
|
||||
else window.scrollBy(0, delta)
|
||||
}
|
||||
delta = element.getBoundingClientRect().top - viewportTop(container) - anchor.top
|
||||
} else if (idsAnchor && layoutRef.current) {
|
||||
// The row is not mounted (yet): its laid-out top is its cached offset.
|
||||
const index = ids.indexOf(anchor.id)
|
||||
const offset = index >= 0 ? layoutRef.current.offsets[index] : undefined
|
||||
if (offset !== undefined) delta = offset - scrollTopRef.current - anchor.top
|
||||
}
|
||||
if (delta !== undefined && Math.abs(delta) >= 0.5) {
|
||||
if (container) container.scrollTop += delta
|
||||
else window.scrollBy(0, delta)
|
||||
}
|
||||
}
|
||||
const layoutNow = layoutRef.current
|
||||
if (!windowed || !layoutNow || !active || isHidden(current.list)) return
|
||||
const { top } = position(current)
|
||||
scrollTopRef.current = top
|
||||
if (!coversViewport(layoutNow, top) && Math.abs(top - anchorTop) >= 1) setAnchorTop(top)
|
||||
}, [active, anchorTop, geometry, ids, keepKey, measureVersion, position, resetScrollOnScopeChange, scope, windowed])
|
||||
|
||||
@@ -421,6 +446,45 @@ export function useListWindow({
|
||||
return { windowed, segments, rowRef, onScroll, onFocus, onBlur }
|
||||
}
|
||||
|
||||
/**
|
||||
* The row to hold in place when the ids change: the first row of the previous
|
||||
* layout reaching into the viewport that is still in the list (as native
|
||||
* scroll anchoring skips removed nodes), with its offset from the viewport top.
|
||||
* Uses cached offsets only. None at the top of the list or when nothing changed.
|
||||
*/
|
||||
export function idsChangeAnchor(
|
||||
previousIds: readonly string[],
|
||||
previousOffsets: readonly number[],
|
||||
ids: readonly string[],
|
||||
scrollTop: number,
|
||||
viewportHeight: number,
|
||||
): { id: string; top: number } | undefined {
|
||||
if (scrollTop <= 0 || sameIds(previousIds, ids)) return undefined
|
||||
const count = Math.min(previousIds.length, previousOffsets.length - 1)
|
||||
// First row whose bottom is below the viewport top.
|
||||
let low = 0
|
||||
let high = count
|
||||
while (low < high) {
|
||||
const middle = (low + high) >> 1
|
||||
if (previousOffsets[middle + 1]! > scrollTop) high = middle
|
||||
else low = middle + 1
|
||||
}
|
||||
const present = new Set(ids)
|
||||
// Only rows that were in view: an anchor far below would move the view.
|
||||
for (let index = low; index < count && previousOffsets[index]! < scrollTop + viewportHeight; index += 1) {
|
||||
const id = previousIds[index]!
|
||||
if (present.has(id)) return { id, top: previousOffsets[index]! - scrollTop }
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function sameIds(left: readonly string[], right: readonly string[]): boolean {
|
||||
if (left === right) return true
|
||||
if (left.length !== right.length) return false
|
||||
for (let index = 0; index < left.length; index += 1) if (left[index] !== right[index]) return false
|
||||
return true
|
||||
}
|
||||
|
||||
/** A list inside a `hidden` subtree has no geometry; leave its window alone. */
|
||||
function isHidden(list: HTMLElement): boolean {
|
||||
return list.closest('[hidden]') !== null
|
||||
|
||||
Reference in New Issue
Block a user