perf: window the sidebar, cache Markdown trees and window chat scrolling once per frame
The chat pane decides re-windowing once per frame from scrollTop and cached row heights instead of reading layout per scroll event. Rendered Markdown hast trees are cached, so remounted rows skip parsing. Sidebar lists of 60+ conversations mount only rows near the viewport. The perf driver scrolls the windowed sidebar to reach a row.
This commit is contained in:
@@ -183,7 +183,7 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('ChatHistoryPane windowing', () => {
|
||||
it('keeps the DOM bounded for a 2,000-message conversation at any scroll position', () => {
|
||||
it('keeps the DOM bounded for a 2,000-message conversation at any scroll position', async () => {
|
||||
const messages = makeMessages(2_000)
|
||||
const { container, chat } = setup(messages)
|
||||
measure()
|
||||
@@ -199,6 +199,7 @@ describe('ChatHistoryPane windowing', () => {
|
||||
chat.scrollTop = scrollTop
|
||||
fireEvent.scroll(chat)
|
||||
})
|
||||
await frame()
|
||||
measure()
|
||||
const count = container.querySelectorAll('.message').length
|
||||
expect(count).toBeLessThanOrEqual(maxRenderedMessageCount + 1)
|
||||
@@ -246,11 +247,12 @@ describe('ChatHistoryPane windowing', () => {
|
||||
expect(chat.scrollTop + viewport).toBe(chat.scrollHeight)
|
||||
})
|
||||
|
||||
it('keeps the reader in place while rows above change height or mount', () => {
|
||||
it('keeps the reader in place while rows above change height or mount', async () => {
|
||||
const messages = makeMessages(2_000)
|
||||
const { chat, container } = setup(messages)
|
||||
measure()
|
||||
act(() => { chat.scrollTop = 120_050; fireEvent.scroll(chat) })
|
||||
await frame()
|
||||
measure()
|
||||
const anchorId = renderedIds(container).find((id) => rowTop(container, id) + 100 > 0)!
|
||||
const before = rowTop(container, anchorId)
|
||||
@@ -268,6 +270,7 @@ describe('ChatHistoryPane windowing', () => {
|
||||
if (index % 3 === 0) rowHeights.set(`m${index}`, 250)
|
||||
}
|
||||
act(() => { chat.scrollTop -= 3_000; fireEvent.scroll(chat) })
|
||||
await frame()
|
||||
const anchorAfterScroll = renderedIds(container).find((id) => rowTop(container, id) + (rowHeights.get(id) ?? 100) > 0)!
|
||||
const topAfterScroll = rowTop(container, anchorAfterScroll)
|
||||
measure()
|
||||
@@ -321,14 +324,16 @@ describe('ChatHistoryPane windowing', () => {
|
||||
Reflect.deleteProperty(HTMLElement.prototype, 'scrollIntoView')
|
||||
})
|
||||
|
||||
it('restores the reader position across hide/show and from a saved snapshot', () => {
|
||||
it('restores the reader position across hide/show and from a saved snapshot', async () => {
|
||||
const messages = makeMessages(2_000)
|
||||
const onScrollSnapshotChange = vi.fn()
|
||||
const { chat, container, rerender, props, unmount } = setup(messages, { onScrollSnapshotChange })
|
||||
measure()
|
||||
act(() => { chat.scrollTop = 80_000; fireEvent.scroll(chat) })
|
||||
await frame()
|
||||
measure()
|
||||
act(() => { chat.scrollTop += 10; fireEvent.scroll(chat) })
|
||||
await frame()
|
||||
const scrollTop = chat.scrollTop
|
||||
const anchorId = renderedIds(container).find((id) => rowTop(container, id) + 100 > 0)!
|
||||
const anchorTop = rowTop(container, anchorId)
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
rangeAround,
|
||||
rangeCovers,
|
||||
rowAtOffset,
|
||||
rowAtViewportTop,
|
||||
rowHeightsFor,
|
||||
tailRange,
|
||||
type MessageRowHeights,
|
||||
@@ -63,13 +64,22 @@ type ViewState =
|
||||
|
||||
const bottomView: ViewState = { mode: "bottom" };
|
||||
|
||||
type ScrollAnchor = { id: string; top: number };
|
||||
/**
|
||||
* A row and its top relative to the reader top, as it was when the scroll
|
||||
* container was at `scrollTop`. Until the next commit or resize nothing but
|
||||
* scrollTop moves rows, so the row's current position is
|
||||
* `top - (container.scrollTop - scrollTop)`: the scroll handler derives the
|
||||
* viewport position from this and the cached row heights without reading
|
||||
* layout.
|
||||
*/
|
||||
type ScrollAnchor = { id: string; top: number; scrollTop: number };
|
||||
|
||||
type PaneModel = {
|
||||
messages: Message[];
|
||||
windowStart: number;
|
||||
range: MessageWindowRange;
|
||||
view: ViewState;
|
||||
keptMessageIds: readonly string[];
|
||||
};
|
||||
|
||||
function sizeAtFor(
|
||||
@@ -113,22 +123,20 @@ function scrollRenderOverscan(viewportHeight: number): number {
|
||||
return Math.max(800, Math.round(viewportHeight * 1.5));
|
||||
}
|
||||
|
||||
function hasLayout(element: HTMLElement): boolean {
|
||||
return element.getBoundingClientRect().height > 0;
|
||||
function sameView(left: ViewState, right: ViewState): boolean {
|
||||
if (left.mode === "bottom" || right.mode === "bottom") return left.mode === right.mode;
|
||||
return left.start === right.start && left.end === right.end && left.toEnd === right.toEnd;
|
||||
}
|
||||
|
||||
/**
|
||||
* Top of the reading area: the scroller's top plus the content's top padding.
|
||||
* Frosted glass moves the scroller under the topbar and grows the padding by
|
||||
* the same amount, so anchoring to this edge keeps rows where the reader saw
|
||||
* them when the effect is toggled.
|
||||
*/
|
||||
function readerTop(container: HTMLElement): number {
|
||||
const top = container.getBoundingClientRect().top;
|
||||
function sameIds(left: readonly string[], right: readonly string[]): boolean {
|
||||
return left.length === right.length && left.every((id, index) => id === right[index]);
|
||||
}
|
||||
|
||||
function contentPaddingTop(container: HTMLElement): number {
|
||||
const content = container.querySelector<HTMLElement>(".chat-content");
|
||||
if (!content) return top;
|
||||
if (!content) return 0;
|
||||
const padding = Number.parseFloat(getComputedStyle(content).paddingTop);
|
||||
return Number.isFinite(padding) ? top + padding : top;
|
||||
return Number.isFinite(padding) ? padding : 0;
|
||||
}
|
||||
|
||||
export const ChatHistoryPane = memo(function ChatHistoryPane({
|
||||
@@ -226,9 +234,18 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
|
||||
const observerRef = useRef<ResizeObserver | undefined>(undefined);
|
||||
const previousMessageCountRef = useRef(total);
|
||||
const modelRef = useRef<PaneModel | undefined>(undefined);
|
||||
// Pending rAF that processes the scroll events of one frame.
|
||||
const scrollFrameRef = useRef<number | undefined>(undefined);
|
||||
// Top padding of .chat-content; read once per layout change (resize
|
||||
// observer, activation) instead of getComputedStyle per scroll event.
|
||||
const paddingTopRef = useRef<number | undefined>(undefined);
|
||||
// Layout of the rendered segments at the last positioning pass; commits
|
||||
// that leave it unchanged (streaming text below the reader) skip layout reads.
|
||||
const layoutKeyRef = useRef<string | undefined>(undefined);
|
||||
const [showScrollToBottom, setShowScrollToBottom] = useState(
|
||||
scrollSnapshot ? !scrollSnapshot.pinnedToBottom : false,
|
||||
);
|
||||
const showScrollToBottomRef = useRef(showScrollToBottom);
|
||||
// Bumped when new row heights change spacer sizes or the window size.
|
||||
const [, setMeasureVersion] = useState(0);
|
||||
// Rows kept mounted outside the range: the focused row, the first message
|
||||
@@ -324,47 +341,104 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
|
||||
}
|
||||
rendered.sort((left, right) => left - right);
|
||||
const segments = buildSegments(heights, messages, windowStart, total, rendered);
|
||||
// Everything in the scroll content above or between rows whose size the
|
||||
// pane controls; equal keys mean mounted rows only moved through their own
|
||||
// resizes, which the resize observer reports.
|
||||
const layoutKey = `${hiddenMessageCount > 0 ? 1 : 0}|${isUnusedConversation(conversation) ? 1 : 0}|${segments
|
||||
.map((segment) => (segment.kind === "row" ? segment.index : `s${segment.height}`))
|
||||
.join(",")}`;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
modelRef.current = { messages, windowStart, range, view };
|
||||
modelRef.current = { messages, windowStart, range, view, keptMessageIds };
|
||||
});
|
||||
|
||||
const scrollToBottomNow = useCallback((container: HTMLElement): void => {
|
||||
container.scrollTo({ top: container.scrollHeight, behavior: "auto" });
|
||||
}, []);
|
||||
|
||||
/** First rendered row reaching into the viewport, relative to its top. */
|
||||
const captureAnchor = useCallback((): void => {
|
||||
const container = scrollRef.current;
|
||||
const list = listRef.current;
|
||||
if (!container || !list || !hasLayout(container)) return;
|
||||
const viewportTop = readerTop(container);
|
||||
let fallback: ScrollAnchor | undefined;
|
||||
for (const row of list.querySelectorAll<HTMLElement>(".message-window-row")) {
|
||||
const id = row.dataset.messageId;
|
||||
if (!id) continue;
|
||||
const rect = row.getBoundingClientRect();
|
||||
const anchor = { id, top: rect.top - viewportTop };
|
||||
fallback = anchor;
|
||||
if (rect.bottom > viewportTop) {
|
||||
anchorRef.current = anchor;
|
||||
return;
|
||||
}
|
||||
}
|
||||
anchorRef.current = fallback;
|
||||
/** Content top padding, cached until the next layout change. */
|
||||
const readerPadding = useCallback((container: HTMLElement): number => {
|
||||
paddingTopRef.current ??= contentPaddingTop(container);
|
||||
return paddingTopRef.current;
|
||||
}, []);
|
||||
|
||||
/** Moves the scroll position so the anchor row stays where the reader saw it. */
|
||||
const correctAnchor = useCallback((): void => {
|
||||
/**
|
||||
* Re-anchors on the row at the reader top, given that row `id` currently
|
||||
* sits `top` px below the reader top. Pure arithmetic on cached heights.
|
||||
* Returns the new anchor's index, or undefined when `id` is not loaded.
|
||||
*/
|
||||
const anchorFrom = useCallback(
|
||||
(id: string, top: number, scrollTop: number): number | undefined => {
|
||||
const model = modelRef.current;
|
||||
if (!model) return undefined;
|
||||
const { messages: current, windowStart: start } = model;
|
||||
const index = current.findIndex((message) => message.id === id);
|
||||
if (index < start) {
|
||||
anchorRef.current = undefined;
|
||||
return undefined;
|
||||
}
|
||||
const row = rowAtViewportTop(sizeAtFor(heights, current), start, current.length, index, top);
|
||||
const rowId = current[row.index]?.id;
|
||||
anchorRef.current = rowId ? { id: rowId, top: row.top, scrollTop } : undefined;
|
||||
return rowId ? row.index : undefined;
|
||||
},
|
||||
[heights],
|
||||
);
|
||||
|
||||
/**
|
||||
* Captures the anchor from layout: one rect pair (scroller and one mounted
|
||||
* row near the viewport), then cached heights to the row at the reader top.
|
||||
*/
|
||||
const captureAnchor = useCallback(
|
||||
(preferredId?: string): void => {
|
||||
const container = scrollRef.current;
|
||||
const model = modelRef.current;
|
||||
if (!container || !model) return;
|
||||
const rows = rowElementsRef.current;
|
||||
let id: string | undefined;
|
||||
for (const candidate of [preferredId, anchorRef.current?.id, model.messages[model.range.start]?.id]) {
|
||||
if (candidate && rows.has(candidate)) {
|
||||
id = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
id ??= rows.keys().next().value;
|
||||
const element = id ? rows.get(id) : undefined;
|
||||
if (!id || !element) return;
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
if (containerRect.height <= 0) return;
|
||||
// Captures are rare (unpin, restore, mount), so refresh the padding
|
||||
// here: a style change (frosted glass) may not have reached the resize
|
||||
// observer yet.
|
||||
paddingTopRef.current = undefined;
|
||||
const top = element.getBoundingClientRect().top - containerRect.top - readerPadding(container);
|
||||
anchorFrom(id, top, container.scrollTop);
|
||||
},
|
||||
[anchorFrom, readerPadding],
|
||||
);
|
||||
|
||||
/**
|
||||
* Moves the scroll position so the anchor row stays where the reader saw
|
||||
* it (net of their own scrolling since), then re-anchors. Reads one rect
|
||||
* pair: the scroller and the anchor row.
|
||||
*/
|
||||
const syncAnchor = useCallback((): void => {
|
||||
const container = scrollRef.current;
|
||||
const anchor = anchorRef.current;
|
||||
if (!container || !anchor) return;
|
||||
const element = rowElementsRef.current.get(anchor.id);
|
||||
if (!element || !hasLayout(container)) return;
|
||||
const delta =
|
||||
element.getBoundingClientRect().top - readerTop(container) - anchor.top;
|
||||
if (Math.abs(delta) >= 0.5) container.scrollTop += delta;
|
||||
}, []);
|
||||
const element = anchor ? rowElementsRef.current.get(anchor.id) : undefined;
|
||||
if (!container || !anchor || !element) {
|
||||
captureAnchor();
|
||||
return;
|
||||
}
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
if (containerRect.height <= 0) return;
|
||||
const scrollTop = container.scrollTop;
|
||||
const expected = anchor.top - (scrollTop - anchor.scrollTop);
|
||||
const actual = element.getBoundingClientRect().top - containerRect.top - readerPadding(container);
|
||||
if (Math.abs(actual - expected) >= 0.5) container.scrollTop = scrollTop + actual - expected;
|
||||
const nextScrollTop = container.scrollTop;
|
||||
anchorFrom(anchor.id, actual - (nextScrollTop - scrollTop), nextScrollTop);
|
||||
}, [anchorFrom, captureAnchor, readerPadding]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const context = contextRef.current;
|
||||
@@ -386,6 +460,9 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
|
||||
// Hidden panes have no observer, so they never measure rows.
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
measure();
|
||||
// Frosted glass, the context strip or a window resize may have changed
|
||||
// the content padding; re-read it lazily on the next anchor pass.
|
||||
paddingTopRef.current = undefined;
|
||||
let layoutChanged = false;
|
||||
for (const entry of entries) {
|
||||
const target = entry.target as HTMLElement;
|
||||
@@ -408,11 +485,11 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
|
||||
return;
|
||||
}
|
||||
// A row above the reader changed height (image, Mermaid, KaTeX).
|
||||
correctAnchor();
|
||||
captureAnchor();
|
||||
syncAnchor();
|
||||
});
|
||||
observer.observe(context);
|
||||
observer.observe(content);
|
||||
// Border box, so a padding change (frosted glass) is reported too.
|
||||
observer.observe(content, { box: 'border-box' });
|
||||
observer.observe(scrollContainer);
|
||||
for (const row of rowElementsRef.current.values()) observer.observe(row);
|
||||
observerRef.current = observer;
|
||||
@@ -420,7 +497,7 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
|
||||
observer.disconnect();
|
||||
observerRef.current = undefined;
|
||||
};
|
||||
}, [active, captureAnchor, correctAnchor, heights, scrollToBottomNow]);
|
||||
}, [active, heights, scrollToBottomNow, syncAnchor]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active || !navigationTarget) return;
|
||||
@@ -435,7 +512,7 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
|
||||
element.focus({ preventScroll: true });
|
||||
// Keep the target mounted while it has focus, and remember the place.
|
||||
setKeptMessageIds([navigationTarget.messageId]);
|
||||
captureAnchor();
|
||||
captureAnchor(navigationTarget.messageId);
|
||||
});
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [active, captureAnchor, navigationTarget, view, visibleMessageCount]);
|
||||
@@ -475,10 +552,13 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
|
||||
scrollContainer.clientHeight;
|
||||
const pinnedToBottom = distanceFromBottom <= chatBottomProximity;
|
||||
const anchor = anchorRef.current;
|
||||
const scrollTop = scrollContainer.scrollTop;
|
||||
latestScrollSnapshotRef.current = {
|
||||
pinnedToBottom,
|
||||
scrollTop: scrollContainer.scrollTop,
|
||||
...(anchor ? { anchorMessageId: anchor.id, anchorOffset: anchor.top } : {}),
|
||||
scrollTop,
|
||||
...(anchor
|
||||
? { anchorMessageId: anchor.id, anchorOffset: anchor.top - (scrollTop - anchor.scrollTop) }
|
||||
: {}),
|
||||
};
|
||||
return pinnedToBottom;
|
||||
},
|
||||
@@ -502,48 +582,54 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
|
||||
[conversation.id, onScrollSnapshotChange, saveScrollPosition],
|
||||
);
|
||||
|
||||
const updateScrollPosition = useCallback((): void => {
|
||||
/**
|
||||
* Once per frame: decides whether the mounted rows still cover the
|
||||
* viewport. The viewport position comes from scrollTop and the cached row
|
||||
* heights relative to the last anchor, so a steady scroll reads no element
|
||||
* geometry and commits only when it re-windows.
|
||||
*/
|
||||
const processScroll = useCallback((): void => {
|
||||
scrollFrameRef.current = undefined;
|
||||
const scrollContainer = scrollRef.current;
|
||||
const model = modelRef.current;
|
||||
if (!scrollContainer || !model) {
|
||||
return;
|
||||
}
|
||||
captureAnchor();
|
||||
const atBottom = saveScrollPosition(scrollContainer);
|
||||
pinnedToBottomRef.current = atBottom;
|
||||
setShowScrollToBottom(!atBottom);
|
||||
if (heights.setViewport(scrollContainer.clientHeight)) {
|
||||
if (!scrollContainer || !model) return;
|
||||
const clientHeight = scrollContainer.clientHeight;
|
||||
if (heights.setViewport(clientHeight)) {
|
||||
setMeasureVersion((version) => version + 1);
|
||||
}
|
||||
const { messages: current, windowStart: start, range: currentRange } = model;
|
||||
const { messages: current, windowStart: start, range: currentRange, view: currentView } = model;
|
||||
let next: ViewState | undefined;
|
||||
if (atBottom) {
|
||||
if (pinnedToBottomRef.current) {
|
||||
next = bottomView;
|
||||
// Content keeps moving under a pinned reader; capture afresh on unpin.
|
||||
anchorRef.current = undefined;
|
||||
} else {
|
||||
const topSpacer = listRef.current?.querySelector<HTMLElement>('[data-window-spacer="top"]');
|
||||
if (!topSpacer || !hasLayout(scrollContainer)) {
|
||||
const scrollTop = scrollContainer.scrollTop;
|
||||
if (!anchorRef.current && clientHeight > 0) captureAnchor();
|
||||
const anchor = anchorRef.current;
|
||||
const anchorIndex = anchor && clientHeight > 0
|
||||
? anchorFrom(anchor.id, anchor.top - (scrollTop - anchor.scrollTop), scrollTop)
|
||||
: undefined;
|
||||
const viewportAnchor = anchorRef.current;
|
||||
if (anchorIndex === undefined || !viewportAnchor) {
|
||||
// No layout (hidden pane, tests): keep the mounted rows.
|
||||
next = model.view.mode === "bottom"
|
||||
next = currentView.mode === "bottom"
|
||||
? { mode: "range", start: currentRange.start, end: currentRange.end, toEnd: true }
|
||||
: undefined;
|
||||
} else {
|
||||
const viewportOffset =
|
||||
scrollContainer.getBoundingClientRect().top -
|
||||
topSpacer.getBoundingClientRect().top;
|
||||
const row = rowAtOffset(sizeAtFor(heights, current), start, current.length, viewportOffset);
|
||||
// The anchor is relative to the reader top; windowing covers the
|
||||
// whole scroller viewport.
|
||||
const offset = viewportAnchor.top + readerPadding(scrollContainer);
|
||||
// Hysteresis: re-window only when the viewport plus a small margin
|
||||
// leaves the mounted rows, then mount a wider margin, so a steady
|
||||
// scroll remounts rows every few screens instead of every event.
|
||||
const needed = viewAround(
|
||||
heights, current, start, row.index, row.top - viewportOffset,
|
||||
scrollRewindowMargin,
|
||||
);
|
||||
const needed = viewAround(heights, current, start, anchorIndex, offset, scrollRewindowMargin);
|
||||
if (needed.mode === "range" && !rangeCovers(currentRange, needed)) {
|
||||
next = viewAround(
|
||||
heights, current, start, row.index, row.top - viewportOffset,
|
||||
heights, current, start, anchorIndex, offset,
|
||||
scrollRenderOverscan(heights.viewport),
|
||||
);
|
||||
} else if (model.view.mode === "bottom") {
|
||||
} else if (currentView.mode === "bottom") {
|
||||
next = {
|
||||
mode: "range",
|
||||
start: currentRange.start,
|
||||
@@ -552,8 +638,9 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
|
||||
};
|
||||
}
|
||||
}
|
||||
saveScrollPosition(scrollContainer);
|
||||
}
|
||||
if (next) setView(next);
|
||||
if (next && !sameView(next, currentView)) setView(next);
|
||||
// Keep the focused row mounted while it scrolls out of the range.
|
||||
const focused = document.activeElement;
|
||||
const focusedRow = focused instanceof Element
|
||||
@@ -563,13 +650,38 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
|
||||
focusedRow && listRef.current?.contains(focusedRow)
|
||||
? focusedRow.dataset.messageId
|
||||
: undefined;
|
||||
setKeptMessageIds((kept) => {
|
||||
const nextKept = focusedId ? [focusedId] : [];
|
||||
return kept.length === nextKept.length && kept.every((id, index) => id === nextKept[index])
|
||||
? kept
|
||||
: nextKept;
|
||||
});
|
||||
}, [captureAnchor, heights, saveScrollPosition]);
|
||||
const nextKept = focusedId ? [focusedId] : [];
|
||||
if (!sameIds(nextKept, model.keptMessageIds)) {
|
||||
setKeptMessageIds((kept) => (sameIds(kept, nextKept) ? kept : nextKept));
|
||||
}
|
||||
}, [anchorFrom, captureAnchor, heights, readerPadding, saveScrollPosition]);
|
||||
|
||||
/**
|
||||
* The scroll event: only cheap scroller metrics and state that changes
|
||||
* rarely (the bottom pin). Windowing runs at most once per frame.
|
||||
*/
|
||||
const updateScrollPosition = useCallback((): void => {
|
||||
const scrollContainer = scrollRef.current;
|
||||
if (!scrollContainer) return;
|
||||
const distanceFromBottom =
|
||||
scrollContainer.scrollHeight - scrollContainer.scrollTop - scrollContainer.clientHeight;
|
||||
if (distanceFromBottom <= chatBottomProximity) {
|
||||
// Content keeps moving under a pinned reader; an old anchor is stale.
|
||||
anchorRef.current = undefined;
|
||||
} else if (pinnedToBottomRef.current || !anchorRef.current) {
|
||||
// Leaving the bottom: anchor from layout once, so a snapshot taken
|
||||
// before the next frame (switching away) records where the reader is.
|
||||
anchorRef.current = undefined;
|
||||
captureAnchor();
|
||||
}
|
||||
const atBottom = saveScrollPosition(scrollContainer);
|
||||
pinnedToBottomRef.current = atBottom;
|
||||
if (showScrollToBottomRef.current === atBottom) {
|
||||
showScrollToBottomRef.current = !atBottom;
|
||||
setShowScrollToBottom(!atBottom);
|
||||
}
|
||||
scrollFrameRef.current ??= requestAnimationFrame(processScroll);
|
||||
}, [captureAnchor, processScroll, saveScrollPosition]);
|
||||
|
||||
// Runs before the positioning effect below, so a sent message pins first.
|
||||
useLayoutEffect(() => {
|
||||
@@ -598,8 +710,12 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
|
||||
}
|
||||
const activated = !wasActiveRef.current;
|
||||
wasActiveRef.current = true;
|
||||
// Styles may have changed while hidden (frosted glass toggled).
|
||||
if (activated) paddingTopRef.current = undefined;
|
||||
const measuredLayout = measuredLayoutRef.current;
|
||||
measuredLayoutRef.current = false;
|
||||
const layoutChanged = layoutKeyRef.current !== layoutKey;
|
||||
layoutKeyRef.current = layoutKey;
|
||||
if (
|
||||
navigationTarget &&
|
||||
navigationIndex >= 0 &&
|
||||
@@ -617,14 +733,17 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
|
||||
const anchorElement = snapshot.anchorMessageId
|
||||
? rowElementsRef.current.get(snapshot.anchorMessageId)
|
||||
: undefined;
|
||||
if (anchorElement && hasLayout(scrollContainer)) {
|
||||
const containerRect = anchorElement ? scrollContainer.getBoundingClientRect() : undefined;
|
||||
if (anchorElement && containerRect && containerRect.height > 0) {
|
||||
const delta =
|
||||
anchorElement.getBoundingClientRect().top -
|
||||
scrollContainer.getBoundingClientRect().top -
|
||||
containerRect.top -
|
||||
readerPadding(scrollContainer) -
|
||||
(snapshot.anchorOffset ?? 0);
|
||||
if (Math.abs(delta) >= 0.5) scrollContainer.scrollTop += delta;
|
||||
}
|
||||
captureAnchor();
|
||||
anchorRef.current = undefined;
|
||||
captureAnchor(snapshot.anchorMessageId);
|
||||
};
|
||||
if (restorePendingRef.current) {
|
||||
restorePendingRef.current = false;
|
||||
@@ -655,13 +774,16 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
|
||||
?.focus({ preventScroll: true });
|
||||
}
|
||||
if (!pinnedToBottomRef.current) {
|
||||
// The reader's row is still mounted; read where it landed.
|
||||
captureAnchor();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!pinnedToBottomRef.current) {
|
||||
correctAnchor();
|
||||
captureAnchor();
|
||||
// Only commits that moved rows (mount/unmount, spacer sizes) need the
|
||||
// one rect pair; size changes of mounted rows arrive through the
|
||||
// resize observer, and streaming text below the reader changes nothing.
|
||||
if (layoutChanged || measuredLayout || activated || !anchorRef.current) syncAnchor();
|
||||
return;
|
||||
}
|
||||
if (activated || measuredLayout) {
|
||||
@@ -690,6 +812,10 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
|
||||
cancelAnimationFrame(followFrameRef.current);
|
||||
followFrameRef.current = undefined;
|
||||
}
|
||||
if (scrollFrameRef.current !== undefined) {
|
||||
cancelAnimationFrame(scrollFrameRef.current);
|
||||
scrollFrameRef.current = undefined;
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
@@ -728,6 +854,7 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
|
||||
return;
|
||||
}
|
||||
pinnedToBottomRef.current = true;
|
||||
showScrollToBottomRef.current = false;
|
||||
setShowScrollToBottom(false);
|
||||
setView(bottomView);
|
||||
const reduceMotion =
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import type { ComponentProps } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Conversation } from './chat-conversation'
|
||||
import { ConversationSidebar, type ConversationListRowHandlers } from './ConversationSidebar'
|
||||
import { createConversationStores, type ConversationStore } from './conversation-store'
|
||||
import { conversationListWindowThreshold, maxRenderedConversationRows } from './use-conversation-list-window'
|
||||
|
||||
// --- A tiny fake layout: jsdom has none. ------------------------------------
|
||||
// The list is 400 px tall at viewport top 0. Its children stack vertically:
|
||||
// rows take their height from `rowHeights` (default 40), spacers their inline
|
||||
// height.
|
||||
const viewport = 400
|
||||
let rowHeights = new Map<string, number>()
|
||||
const scrollTops = new WeakMap<Element, number>()
|
||||
|
||||
function childHeight(child: HTMLElement): number {
|
||||
const id = child.dataset.conversationWindowRow
|
||||
if (id) return rowHeights.get(id) ?? 40
|
||||
if (child.classList.contains('conversation-list__spacer')) return Number.parseFloat(child.style.height) || 0
|
||||
return 0
|
||||
}
|
||||
|
||||
function contentHeight(list: HTMLElement): number {
|
||||
return [...list.children].reduce((sum, child) => sum + childHeight(child as HTMLElement), 0)
|
||||
}
|
||||
|
||||
function rect(top: number, height: number): DOMRect {
|
||||
return { top, bottom: top + height, height, left: 0, right: 0, width: 0, x: 0, y: top, toJSON: () => ({}) } as DOMRect
|
||||
}
|
||||
|
||||
function listChildOf(element: HTMLElement, list: HTMLElement): HTMLElement | null {
|
||||
let item: HTMLElement | null = element
|
||||
while (item && item.parentElement !== list) item = item.parentElement
|
||||
return item
|
||||
}
|
||||
|
||||
function fakeRect(this: HTMLElement): DOMRect {
|
||||
if (this.classList.contains('conversation-list')) return rect(0, viewport)
|
||||
const list = this.closest<HTMLElement>('.conversation-list')
|
||||
if (!list) return rect(0, 0)
|
||||
const item = listChildOf(this, list)
|
||||
if (!item) return rect(0, 0)
|
||||
let offset = 0
|
||||
for (const child of list.children) {
|
||||
if (child === item) break
|
||||
offset += childHeight(child as HTMLElement)
|
||||
}
|
||||
return rect(offset - list.scrollTop, childHeight(item))
|
||||
}
|
||||
|
||||
const isList = (element: Element): boolean => element.classList.contains('conversation-list')
|
||||
|
||||
class FakeResizeObserver {
|
||||
static instances: FakeResizeObserver[] = []
|
||||
readonly observed = new Set<Element>()
|
||||
constructor(private readonly callback: ResizeObserverCallback) {
|
||||
FakeResizeObserver.instances.push(this)
|
||||
}
|
||||
observe(element: Element): void { this.observed.add(element) }
|
||||
unobserve(element: Element): void { this.observed.delete(element) }
|
||||
disconnect(): void { this.observed.clear() }
|
||||
fire(): void {
|
||||
const entries = [...this.observed].map((target) => ({ target }) as ResizeObserverEntry)
|
||||
this.callback(entries, this as unknown as ResizeObserver)
|
||||
}
|
||||
}
|
||||
|
||||
/** Delivers row measurements to the list's observer. */
|
||||
function measure(): void {
|
||||
act(() => {
|
||||
for (const observer of FakeResizeObserver.instances) {
|
||||
if ([...observer.observed].some(isList)) observer.fire()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const descriptors = ['clientHeight', 'scrollHeight', 'scrollTop'] as const
|
||||
const originalDescriptors = new Map<string, PropertyDescriptor | undefined>()
|
||||
|
||||
function installGeometry(): void {
|
||||
for (const name of descriptors) {
|
||||
originalDescriptors.set(name, Object.getOwnPropertyDescriptor(HTMLElement.prototype, name))
|
||||
}
|
||||
Object.defineProperties(HTMLElement.prototype, {
|
||||
clientHeight: { configurable: true, get(this: HTMLElement) { return isList(this) ? viewport : 0 } },
|
||||
scrollHeight: { configurable: true, get(this: HTMLElement) { return isList(this) ? contentHeight(this) : 0 } },
|
||||
scrollTop: {
|
||||
configurable: true,
|
||||
get(this: HTMLElement) { return scrollTops.get(this) ?? 0 },
|
||||
set(this: HTMLElement, value: number) {
|
||||
const max = isList(this) ? Math.max(0, contentHeight(this) - viewport) : 0
|
||||
scrollTops.set(this, Math.min(Math.max(0, value), max))
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function restoreGeometry(): void {
|
||||
for (const name of descriptors) {
|
||||
const original = originalDescriptors.get(name)
|
||||
if (original) Object.defineProperty(HTMLElement.prototype, name, original)
|
||||
else Reflect.deleteProperty(HTMLElement.prototype, name)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Fixtures ----------------------------------------------------------------
|
||||
|
||||
const title = (index: number): string => `Conversation ${String(index).padStart(4, '0')}`
|
||||
const idOf = (index: number): string => `c${index}`
|
||||
|
||||
function makeConversations(count: number): Conversation[] {
|
||||
// Newest first: row i is conversation i.
|
||||
return Array.from({ length: count }, (_, index) => ({
|
||||
id: idOf(index), title: title(index), updatedAt: 1_000_000 - index, messages: [],
|
||||
}) as unknown as Conversation)
|
||||
}
|
||||
|
||||
const handlers: ConversationListRowHandlers = {
|
||||
toggleTasks: vi.fn(), select: vi.fn(), toggleActions: vi.fn(), registerActionTrigger: vi.fn(),
|
||||
closeActions: vi.fn(), pin: vi.fn(), branch: vi.fn(), startRename: vi.fn(), copy: vi.fn(),
|
||||
exportConversation: vi.fn(), cancelDelete: vi.fn(), confirmDelete: vi.fn(), requestDelete: vi.fn(),
|
||||
saveTitle: vi.fn(), cancelRename: vi.fn(), openTask: vi.fn(), viewAllTasks: vi.fn(),
|
||||
}
|
||||
|
||||
type SidebarProps = ComponentProps<typeof ConversationSidebar>
|
||||
|
||||
function props(store: ConversationStore, overrides: Partial<SidebarProps> = {}): SidebarProps {
|
||||
return {
|
||||
store,
|
||||
activeId: '',
|
||||
activeProjectId: '',
|
||||
activeProjectKind: undefined,
|
||||
searchQuery: '',
|
||||
conversationStoreReady: false,
|
||||
loadError: undefined,
|
||||
locale: 'zh-CN',
|
||||
sidebarOpen: true,
|
||||
tasksByConversation: new Map(),
|
||||
expandedTaskConversationIds: new Set(),
|
||||
activityByConversationId: new Map(),
|
||||
activeConversationIds: new Set(),
|
||||
queuedConversationIds: new Set(),
|
||||
unreadConversationIds: new Set(),
|
||||
conversationActionsId: '',
|
||||
confirmingConversationId: '',
|
||||
deletingConversationId: '',
|
||||
renamingConversationId: '',
|
||||
branchingConversationId: '',
|
||||
pinningConversationId: '',
|
||||
assistantSchedules: [],
|
||||
selectedAssistantTaskId: undefined,
|
||||
actionsRef: { current: null },
|
||||
handlers,
|
||||
actions: { newConversation: vi.fn(), clearSearch: vi.fn(), retryLoad: vi.fn(), notify: vi.fn() },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function setup(count: number, overrides: Partial<SidebarProps> = {}) {
|
||||
const store = createConversationStores(makeConversations(count), { flushIntervalMs: 1_000 }).conversations
|
||||
const view = render(<ConversationSidebar {...props(store, overrides)} />)
|
||||
const list = view.container.querySelector<HTMLElement>('.conversation-list')!
|
||||
measure()
|
||||
const update = (next: Partial<SidebarProps>): void => {
|
||||
view.rerender(<ConversationSidebar {...props(store, { ...overrides, ...next })} />)
|
||||
}
|
||||
return { ...view, list, store, update }
|
||||
}
|
||||
|
||||
function renderedIds(list: HTMLElement): string[] {
|
||||
return [...list.querySelectorAll<HTMLElement>('[data-conversation-window-row]')]
|
||||
.map((row) => row.dataset.conversationWindowRow ?? '')
|
||||
}
|
||||
|
||||
function scrollList(list: HTMLElement, top: number): void {
|
||||
act(() => {
|
||||
list.scrollTop = top
|
||||
fireEvent.scroll(list)
|
||||
})
|
||||
measure()
|
||||
}
|
||||
|
||||
/** Rows whose box intersects the viewport. */
|
||||
function visibleIds(list: HTMLElement): string[] {
|
||||
return [...list.querySelectorAll<HTMLElement>('[data-conversation-window-row]')].filter((row) => {
|
||||
const box = row.getBoundingClientRect()
|
||||
return box.bottom > 0 && box.top < viewport
|
||||
}).map((row) => row.dataset.conversationWindowRow ?? '')
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
rowHeights = new Map()
|
||||
FakeResizeObserver.instances = []
|
||||
vi.stubGlobal('ResizeObserver', FakeResizeObserver)
|
||||
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(fakeRect)
|
||||
installGeometry()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
restoreGeometry()
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('ConversationSidebar windowing', () => {
|
||||
it('renders every row without spacers below the threshold', () => {
|
||||
const { list } = setup(conversationListWindowThreshold - 1)
|
||||
expect(renderedIds(list)).toHaveLength(conversationListWindowThreshold - 1)
|
||||
expect(list.querySelector('.conversation-list__spacer')).toBeNull()
|
||||
expect(list).not.toHaveClass('conversation-list--windowed')
|
||||
})
|
||||
|
||||
it('keeps the DOM bounded for 1,000 conversations while the full list stays scrollable', () => {
|
||||
const { list } = setup(1_000)
|
||||
expect(list).toHaveClass('conversation-list--windowed')
|
||||
expect(list).toHaveAttribute('role', 'list')
|
||||
const rows = renderedIds(list)
|
||||
expect(rows.length).toBeGreaterThan(0)
|
||||
expect(rows.length).toBeLessThanOrEqual(40)
|
||||
expect(rows[0]).toBe('c0')
|
||||
expect(list.scrollHeight).toBe(1_000 * 40)
|
||||
const first = list.querySelector<HTMLElement>('[data-conversation-window-row="c0"]')!
|
||||
expect(first).toHaveAttribute('role', 'listitem')
|
||||
expect(first).toHaveAttribute('aria-posinset', '1')
|
||||
expect(first).toHaveAttribute('aria-setsize', '1000')
|
||||
})
|
||||
|
||||
it('mounts the rows at the scroll position and keeps them in place', () => {
|
||||
const { list } = setup(1_000)
|
||||
for (const top of [20_000, 39_600, 8_000, 0]) {
|
||||
scrollList(list, top)
|
||||
const rows = renderedIds(list)
|
||||
expect(rows.length).toBeLessThanOrEqual(maxRenderedConversationRows)
|
||||
const firstVisible = Math.floor(list.scrollTop / 40)
|
||||
expect(visibleIds(list)).toEqual(
|
||||
Array.from({ length: Math.min(10, 1_000 - firstVisible) }, (_, offset) => idOf(firstVisible + offset)),
|
||||
)
|
||||
}
|
||||
scrollList(list, 20_000)
|
||||
expect(screen.getByText(title(500))).toBeInTheDocument()
|
||||
expect(list.querySelector('[data-conversation-window-row="c500"]')).toHaveAttribute('aria-posinset', '501')
|
||||
expect(screen.queryByText(title(0))).toBeNull()
|
||||
})
|
||||
|
||||
it('measures variable row heights and keeps the visible row steady', () => {
|
||||
rowHeights = new Map(Array.from({ length: 1_000 }, (_, index) => [idOf(index), index % 3 === 0 ? 80 : 40]))
|
||||
const { list } = setup(1_000)
|
||||
scrollList(list, 12_000)
|
||||
const firstVisible = visibleIds(list)[0]!
|
||||
const before = list.querySelector<HTMLElement>(`[data-conversation-window-row="${firstVisible}"]`)!.getBoundingClientRect().top
|
||||
measure()
|
||||
const after = list.querySelector<HTMLElement>(`[data-conversation-window-row="${firstVisible}"]`)!.getBoundingClientRect().top
|
||||
expect(after).toBeCloseTo(before, 0)
|
||||
})
|
||||
|
||||
it('keeps the active, menu-open, renaming and focused rows mounted when scrolled away', () => {
|
||||
const { list, update } = setup(1_000, { activeId: idOf(900) })
|
||||
expect(renderedIds(list)).toContain('c900')
|
||||
update({ conversationActionsId: idOf(3) })
|
||||
expect(screen.getByRole('menu')).toBeInTheDocument()
|
||||
scrollList(list, 20_000)
|
||||
expect(renderedIds(list)).toEqual(expect.arrayContaining(['c3', 'c900', 'c500']))
|
||||
expect(screen.getByRole('menu')).toBeInTheDocument()
|
||||
// Kept rows sit at their real position, so DOM (and Tab) order follows the list.
|
||||
const rows = renderedIds(list).map((id) => Number(id.slice(1)))
|
||||
expect(rows).toEqual([...rows].sort((left, right) => left - right))
|
||||
|
||||
update({ conversationActionsId: '', renamingConversationId: idOf(510) })
|
||||
scrollList(list, 32_000)
|
||||
expect(screen.getByLabelText(`重命名会话 ${title(510)}`)).toBeInTheDocument()
|
||||
expect(renderedIds(list)).not.toContain('c3')
|
||||
|
||||
scrollList(list, 0)
|
||||
act(() => screen.getByLabelText(`更多会话操作 ${title(4)}`).focus())
|
||||
update({ renamingConversationId: '' })
|
||||
scrollList(list, 30_000)
|
||||
expect(renderedIds(list)).toContain('c4')
|
||||
expect(screen.getByLabelText(`更多会话操作 ${title(4)}`)).toHaveFocus()
|
||||
expect(renderedIds(list)).not.toContain('c510')
|
||||
})
|
||||
|
||||
it('resets the window to the top when search results change', async () => {
|
||||
const { list, update } = setup(1_000)
|
||||
scrollList(list, 20_000)
|
||||
expect(renderedIds(list)).toContain('c500')
|
||||
// "conversation 07" matches Conversation 0700-0799.
|
||||
update({ searchQuery: 'conversation 07' })
|
||||
await act(async () => { await Promise.resolve() })
|
||||
measure()
|
||||
expect(list.scrollTop).toBe(0)
|
||||
const rows = renderedIds(list)
|
||||
expect(rows[0]).toBe('c700')
|
||||
expect(rows.every((id) => Number(id.slice(1)) >= 700 && Number(id.slice(1)) < 800)).toBe(true)
|
||||
expect(list.querySelector('[data-conversation-window-row="c700"]')).toHaveAttribute('aria-setsize', '100')
|
||||
|
||||
scrollList(list, 2_000)
|
||||
expect(renderedIds(list)).toContain('c750')
|
||||
update({ searchQuery: 'conversation 001' })
|
||||
await act(async () => { await Promise.resolve() })
|
||||
// Ten matches: below the threshold, all rendered.
|
||||
expect(renderedIds(list)).toEqual(Array.from({ length: 10 }, (_, index) => idOf(10 + index)))
|
||||
expect(list.querySelector('.conversation-list__spacer')).toBeNull()
|
||||
})
|
||||
|
||||
it('still reorders on the 5 s tick and pauses while hovered', () => {
|
||||
vi.useFakeTimers()
|
||||
const { list, store } = setup(1_000)
|
||||
const bump = (index: number, time: number): void => act(() => store.set((current) => current.map((item) =>
|
||||
item.id === idOf(index) ? { ...item, messageSummary: { count: 1, latestMessageAt: time } } : item)))
|
||||
bump(700, 2_000_000)
|
||||
expect(renderedIds(list)[0]).toBe('c0')
|
||||
fireEvent.mouseEnter(list)
|
||||
act(() => { vi.advanceTimersByTime(10_000) })
|
||||
expect(renderedIds(list)[0]).toBe('c0')
|
||||
fireEvent.mouseLeave(list)
|
||||
act(() => { vi.advanceTimersByTime(5_000) })
|
||||
measure()
|
||||
expect(renderedIds(list).slice(0, 2)).toEqual(['c700', 'c0'])
|
||||
expect(screen.getByText(title(700)).closest('[role="listitem"]')).toHaveAttribute('aria-posinset', '1')
|
||||
|
||||
// Scrolling pauses the tick for 200 ms like before windowing.
|
||||
bump(800, 3_000_000)
|
||||
act(() => { vi.advanceTimersByTime(4_900) })
|
||||
scrollList(list, 40)
|
||||
act(() => { vi.advanceTimersByTime(100) })
|
||||
expect(renderedIds(list)).not.toContain('c800')
|
||||
scrollList(list, 0)
|
||||
act(() => { vi.advanceTimersByTime(5_000) })
|
||||
expect(renderedIds(list)[0]).toBe('c800')
|
||||
})
|
||||
})
|
||||
@@ -32,6 +32,7 @@ import { OverflowMarquee } from "./OverflowMarquee";
|
||||
import { findTaskSchedule } from "./TaskScheduleActions";
|
||||
import { formatConversationListTime, type TimeFormatLocale } from "./time-format";
|
||||
import { useConversationListOrder } from "./use-conversation-list-order";
|
||||
import { useConversationListWindow } from "./use-conversation-list-window";
|
||||
import { DestructiveConfirmActions } from "./WorkspacePrimitives";
|
||||
|
||||
/** Delay before searching message text, so typing does not scan every keystroke. */
|
||||
@@ -105,6 +106,11 @@ type ConversationListRowProps = {
|
||||
listTimeDay: string;
|
||||
actionsRef: React.RefObject<HTMLDivElement | null>;
|
||||
handlers: ConversationListRowHandlers;
|
||||
/** Windowed lists only: measures the row; stable per conversation. */
|
||||
rowRef?: (element: HTMLElement | null) => void;
|
||||
/** Windowed lists only: 1-based position, since not every row is in the DOM. */
|
||||
position?: number;
|
||||
setSize?: number;
|
||||
};
|
||||
|
||||
// Rows skip re-rendering unless their own props change, so streaming into one
|
||||
@@ -132,13 +138,23 @@ const ConversationListRow = memo(function ConversationListRow({
|
||||
listTimeDay,
|
||||
actionsRef,
|
||||
handlers,
|
||||
rowRef,
|
||||
position,
|
||||
setSize,
|
||||
}: ConversationListRowProps): React.JSX.Element {
|
||||
void listTimeDay;
|
||||
const { t } = useTranslation("app");
|
||||
const { t: tWorkspace } = useTranslation("workspace");
|
||||
const branchDisabledReasonId = `conversation-branch-disabled-${conversation.id}`;
|
||||
return (
|
||||
<div className="conversation-entry" key={conversation.id}>
|
||||
<div
|
||||
aria-posinset={position}
|
||||
aria-setsize={setSize}
|
||||
className="conversation-entry"
|
||||
data-conversation-window-row={conversation.id}
|
||||
ref={rowRef}
|
||||
role="listitem"
|
||||
>
|
||||
<div className="conversation-row">
|
||||
{conversationTasks.length > 0 && (
|
||||
<button
|
||||
@@ -610,11 +626,31 @@ export const ConversationSidebar = memo(function ConversationSidebar({
|
||||
conversation.title.toLocaleLowerCase().includes(query) ||
|
||||
(localSearchMatches.query === query && localSearchMatches.ids.has(conversation.id))),
|
||||
), [activeProjectId, activeProjectKind, conversations, query, localSearchMatches, persistedSearchMatches]);
|
||||
const listScope = JSON.stringify([activeProjectId, activeProjectKind, query]);
|
||||
const { conversations: filteredConversations, listProps } = useConversationListOrder(
|
||||
matchingConversations,
|
||||
JSON.stringify([activeProjectId, activeProjectKind, query]),
|
||||
listScope,
|
||||
Boolean(conversationActionsId),
|
||||
);
|
||||
const filteredIds = useMemo(
|
||||
() => filteredConversations.map((conversation) => conversation.id),
|
||||
[filteredConversations],
|
||||
);
|
||||
const listWindow = useConversationListWindow({
|
||||
ids: filteredIds,
|
||||
scope: listScope,
|
||||
scrollRef: listProps.ref,
|
||||
// Rows holding user state stay mounted while scrolled out of the window.
|
||||
keepIds: [
|
||||
activeId,
|
||||
conversationActionsId,
|
||||
confirmingConversationId,
|
||||
deletingConversationId,
|
||||
renamingConversationId,
|
||||
],
|
||||
enabled: !loadError,
|
||||
});
|
||||
|
||||
const defaultTitle = t("conversation.defaultTitle");
|
||||
const conversationById = useMemo(
|
||||
() => new Map(conversations.map((item) => [item.id, item])),
|
||||
@@ -628,9 +664,30 @@ export const ConversationSidebar = memo(function ConversationSidebar({
|
||||
|
||||
return (
|
||||
<section className="sidebar-conversations" aria-label={t("sidebar.recent")}>
|
||||
<div className="conversation-list" {...listProps}>
|
||||
<div
|
||||
className={listWindow.windowed ? "conversation-list conversation-list--windowed" : "conversation-list"}
|
||||
{...listProps}
|
||||
onBlur={listWindow.onBlur}
|
||||
onFocus={listWindow.onFocus}
|
||||
onScroll={() => {
|
||||
listProps.onScroll();
|
||||
listWindow.onScroll();
|
||||
}}
|
||||
role={!loadError && filteredConversations.length > 0 ? "list" : undefined}
|
||||
>
|
||||
{!loadError &&
|
||||
filteredConversations.map((conversation) => {
|
||||
listWindow.segments.map((segment) => {
|
||||
if (segment.kind === "spacer") {
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="conversation-list__spacer"
|
||||
key={`spacer:${segment.key}`}
|
||||
style={{ height: `${segment.height}px` }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const conversation = filteredConversations[segment.index]!;
|
||||
const conversationTasks =
|
||||
tasksByConversation.get(conversation.id) ?? emptyConversationTasks;
|
||||
const tasksExpanded = expandedTaskConversationIds.has(conversation.id);
|
||||
@@ -668,7 +725,10 @@ export const ConversationSidebar = memo(function ConversationSidebar({
|
||||
locale={locale}
|
||||
menuVisible={sidebarOpen && conversationActionsId === conversation.id}
|
||||
pinDisabled={!conversationStoreReady || Boolean(pinningConversationId)}
|
||||
position={listWindow.windowed ? segment.index + 1 : undefined}
|
||||
renaming={renamingConversationId === conversation.id}
|
||||
rowRef={listWindow.windowed ? listWindow.rowRef(conversation.id) : undefined}
|
||||
setSize={listWindow.windowed ? filteredConversations.length : undefined}
|
||||
schedules={tasksExpanded ? assistantSchedules : emptyAssistantSchedules}
|
||||
selectedTaskId={tasksExpanded && conversationTasks.some((task) => task.id === selectedAssistantTaskId) ? selectedAssistantTaskId : undefined}
|
||||
tasksExpanded={tasksExpanded}
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
import { act, cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import { StrictMode } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { changeUiLocale } from './i18n'
|
||||
import {
|
||||
blockMarkdownPipeline,
|
||||
getMarkdown,
|
||||
hastToReact,
|
||||
lookupMarkdown,
|
||||
parseMarkdown,
|
||||
markdownRenderCache,
|
||||
storeMarkdown,
|
||||
type MarkdownPipeline
|
||||
} from './markdown-render-cache'
|
||||
import {
|
||||
MarkdownRenderer,
|
||||
normalizeLatexDelimiters,
|
||||
splitMarkdownSegments,
|
||||
UnsegmentedMarkdownRenderer
|
||||
} from './MarkdownRenderer'
|
||||
|
||||
vi.mock('mermaid', () => ({
|
||||
default: {
|
||||
initialize: vi.fn(),
|
||||
render: vi.fn().mockResolvedValue({
|
||||
svg: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><rect width="10" height="10"/></svg>'
|
||||
})
|
||||
}
|
||||
}))
|
||||
|
||||
/** Lets the deferred unmount store (queueMicrotask) run. */
|
||||
async function flushUnmountStores(): Promise<void> {
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
})
|
||||
}
|
||||
|
||||
function canonicalHtml(element: HTMLElement): string {
|
||||
const ids = new Map<string, string>()
|
||||
const clone = element.cloneNode(true) as HTMLElement
|
||||
for (const node of clone.querySelectorAll('*')) {
|
||||
for (const name of ['id', 'aria-controls', 'aria-labelledby', 'aria-describedby', 'for']) {
|
||||
const value = node.getAttribute(name)
|
||||
if (value && /_r_[a-z0-9]+_/u.test(value)) {
|
||||
if (!ids.has(value)) ids.set(value, `id-${ids.size}`)
|
||||
node.setAttribute(name, ids.get(value)!)
|
||||
}
|
||||
}
|
||||
}
|
||||
return clone.innerHTML
|
||||
}
|
||||
|
||||
function segmentsOf(content: string): string[] {
|
||||
return splitMarkdownSegments(normalizeLatexDelimiters(content))
|
||||
}
|
||||
|
||||
const corpus: { content: string; name: string; renderHtml?: boolean }[] = [
|
||||
{
|
||||
content: '# Title\n\nInline $E = mc^2$ and \\(a+b\\).\n\n$$\n\\int_0^1 x^2\\,dx = \\frac{1}{3}\n$$\n\n\\[x^2\\]\n\nInvalid $\\frac{1}{$ math\n\nend',
|
||||
name: 'KaTeX'
|
||||
},
|
||||
{
|
||||
content: 'Code:\n\n```ts\nconst value = "\\(literal\\)"\n\nconsole.log(value)\n```\n\n~~~\nplain\n~~~\n\n indented\n\nafter `inline`',
|
||||
name: 'code'
|
||||
},
|
||||
{
|
||||
content: '| Name | Count |\n| --- | ---: |\n| Token | 42 |\n\n- [x] done\n- [ ] todo\n\n~~strike~~ https://example.com',
|
||||
name: 'tables'
|
||||
},
|
||||
{
|
||||
content: 'See [ref] and a note[^1].\n\n[ref]: https://example.com\n\n[^1]: Footnote body with $x$.',
|
||||
name: 'footnotes'
|
||||
},
|
||||
{
|
||||
content: '[Safe](https://factory.ai)\n\n[Unsafe](javascript:alert(1))\n\n<script>bad()</script>\n\n',
|
||||
name: 'links and raw HTML'
|
||||
},
|
||||
{
|
||||
content: 'Before\n\n```html\n<section><h1>Preview page</h1></section>\n```\n\nAfter',
|
||||
name: 'HTML fence, HTML mode on',
|
||||
renderHtml: true
|
||||
},
|
||||
{
|
||||
content: 'Before\n\n```html\n<section><h1>Preview page</h1></section>\n```\n\nAfter',
|
||||
name: 'HTML fence, HTML mode off',
|
||||
renderHtml: false
|
||||
},
|
||||
{
|
||||
content: '```markdown\n# Wrapped\n\n- a\n- b\n```',
|
||||
name: 'whole Markdown fence'
|
||||
},
|
||||
{
|
||||
content: Array.from({ length: 20 }, (_, i) => `## Part ${i}\n\nParagraph **${i}** with $x_${i}$.\n\n> quote ${i}`).join('\n\n'),
|
||||
name: 'many segments'
|
||||
}
|
||||
]
|
||||
|
||||
describe('Markdown render cache', () => {
|
||||
beforeEach(() => {
|
||||
markdownRenderCache.clear()
|
||||
markdownRenderCache.resetLimits()
|
||||
Object.defineProperty(window, 'goodbuddy', {
|
||||
configurable: true,
|
||||
value: { app: { onPreviewEscape: vi.fn(() => () => undefined) } }
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
markdownRenderCache.clear()
|
||||
markdownRenderCache.resetLimits()
|
||||
})
|
||||
|
||||
it.each(corpus)('renders a cache hit identically to an uncached render: $name', async ({ content, renderHtml }) => {
|
||||
const reference = render(<div><UnsegmentedMarkdownRenderer renderHtml={renderHtml}>{content}</UnsegmentedMarkdownRenderer></div>)
|
||||
const expected = canonicalHtml(reference.container)
|
||||
reference.unmount()
|
||||
|
||||
const first = render(<div><MarkdownRenderer renderHtml={renderHtml}>{content}</MarkdownRenderer></div>)
|
||||
expect(canonicalHtml(first.container)).toBe(expected)
|
||||
const parsesAfterFirst = markdownRenderCache.stats().parses
|
||||
first.unmount()
|
||||
await flushUnmountStores()
|
||||
|
||||
expect(markdownRenderCache.stats().entries).toBeGreaterThan(0)
|
||||
|
||||
const second = render(<div><MarkdownRenderer renderHtml={renderHtml}>{content}</MarkdownRenderer></div>)
|
||||
expect(canonicalHtml(second.container)).toBe(expected)
|
||||
// The remount did not run unified at all.
|
||||
expect(markdownRenderCache.stats().parses).toBe(parsesAfterFirst)
|
||||
})
|
||||
|
||||
it('applies per-mount options to shared trees without leaking them between mounts', async () => {
|
||||
const content = 'Intro\n\n```html\n<section><h1>Shared</h1></section>\n```\n\n| a |\n| - |\n| 1 |'
|
||||
render(<MarkdownRenderer>{content}</MarkdownRenderer>).unmount()
|
||||
await flushUnmountStores()
|
||||
const parses = markdownRenderCache.stats().parses
|
||||
|
||||
// HTML mode is applied by per-mount components, so the cached tree can
|
||||
// be reused while the output follows the prop.
|
||||
const htmlOn = render(<MarkdownRenderer renderHtml>{content}</MarkdownRenderer>)
|
||||
const htmlOff = render(<MarkdownRenderer>{content}</MarkdownRenderer>)
|
||||
expect(markdownRenderCache.stats().parses).toBe(parses)
|
||||
expect(htmlOn.container.querySelector('iframe[sandbox]')).not.toBeNull()
|
||||
expect(htmlOn.container.querySelector('code.language-html')).toBeNull()
|
||||
expect(htmlOff.container.querySelector('iframe')).toBeNull()
|
||||
expect(htmlOff.container.querySelector('code.language-html')).toHaveTextContent('<section>')
|
||||
|
||||
// Locale-dependent labels are not frozen into cache entries.
|
||||
await act(async () => {
|
||||
await changeUiLocale('en-US')
|
||||
})
|
||||
const english = render(<MarkdownRenderer>{content}</MarkdownRenderer>)
|
||||
const label = english.container.querySelector('.markdown-table-scroll')!.getAttribute('aria-label')
|
||||
await act(async () => {
|
||||
await changeUiLocale('zh-CN')
|
||||
})
|
||||
const chinese = render(<MarkdownRenderer>{content}</MarkdownRenderer>)
|
||||
expect(chinese.container.querySelector('.markdown-table-scroll')!.getAttribute('aria-label')).not.toBe(label)
|
||||
expect(markdownRenderCache.stats().parses).toBe(parses)
|
||||
})
|
||||
|
||||
it('keys entries by pipeline so different configurations never share trees', () => {
|
||||
const other: MarkdownPipeline = {
|
||||
...blockMarkdownPipeline,
|
||||
id: 'other-config',
|
||||
skipHtml: false
|
||||
}
|
||||
const source = 'a <b>raw</b> c'
|
||||
storeMarkdown(source, blockMarkdownPipeline, getMarkdown(source, blockMarkdownPipeline))
|
||||
expect(lookupMarkdown(source, other)).toBeUndefined()
|
||||
const raw = getMarkdown(source, other)
|
||||
storeMarkdown(source, other, raw)
|
||||
expect(markdownRenderCache.keys()).toHaveLength(2)
|
||||
expect(lookupMarkdown(source, other)!.tree).toBe(raw.tree)
|
||||
expect(lookupMarkdown(source, blockMarkdownPipeline)!.tree).not.toBe(raw.tree)
|
||||
expect(JSON.stringify(raw.tree)).toContain('<b>')
|
||||
expect(JSON.stringify(lookupMarkdown(source, blockMarkdownPipeline)!.tree)).not.toContain('<b>')
|
||||
})
|
||||
|
||||
it('does not cache streaming prefixes of the growing final segment', async () => {
|
||||
const content = Array.from({ length: 12 }, (_, i) => `Paragraph ${i} with **bold** and $x_${i}$ math.`).join('\n\n')
|
||||
const view = render(<MarkdownRenderer>{''}</MarkdownRenderer>)
|
||||
let renders = 0
|
||||
for (let end = 1; end <= content.length; end += 3) {
|
||||
view.rerender(<MarkdownRenderer>{content.slice(0, end)}</MarkdownRenderer>)
|
||||
renders++
|
||||
}
|
||||
view.rerender(<MarkdownRenderer>{content}</MarkdownRenderer>)
|
||||
await flushUnmountStores()
|
||||
expect(renders).toBeGreaterThan(100)
|
||||
|
||||
const segments = segmentsOf(content)
|
||||
expect(segments).toHaveLength(12)
|
||||
// Only complete segments are stored while the message is mounted.
|
||||
expect(markdownRenderCache.keys()).toHaveLength(segments.length - 1)
|
||||
for (const segment of segments.slice(0, -1)) {
|
||||
expect(markdownRenderCache.has(segment)).toBe(true)
|
||||
}
|
||||
expect(markdownRenderCache.has(segments.at(-1)!)).toBe(false)
|
||||
|
||||
// Unmounting (scrolled out of the window) stores the final text once.
|
||||
view.unmount()
|
||||
await flushUnmountStores()
|
||||
expect(markdownRenderCache.keys()).toHaveLength(segments.length)
|
||||
expect(markdownRenderCache.has(segments.at(-1)!)).toBe(true)
|
||||
})
|
||||
|
||||
it('does not store the final segment on StrictMode effect replays', async () => {
|
||||
const view = render(<StrictMode><MarkdownRenderer>{'first\n\nsecond'}</MarkdownRenderer></StrictMode>)
|
||||
view.rerender(<StrictMode><MarkdownRenderer>{'first\n\nsecond, longer'}</MarkdownRenderer></StrictMode>)
|
||||
await flushUnmountStores()
|
||||
expect(markdownRenderCache.has('first\n\n')).toBe(true)
|
||||
expect(markdownRenderCache.has('second')).toBe(false)
|
||||
expect(markdownRenderCache.has('second, longer')).toBe(false)
|
||||
view.unmount()
|
||||
await flushUnmountStores()
|
||||
expect(markdownRenderCache.has('second')).toBe(false)
|
||||
expect(markdownRenderCache.has('second, longer')).toBe(true)
|
||||
})
|
||||
|
||||
it('respects entry, character and node bounds with LRU eviction', async () => {
|
||||
markdownRenderCache.configure({ maxChars: 1_000_000, maxEntries: 5, maxNodes: 1_000_000 })
|
||||
for (let i = 0; i < 12; i++) {
|
||||
render(<MarkdownRenderer>{`message ${i}`}</MarkdownRenderer>).unmount()
|
||||
}
|
||||
await flushUnmountStores()
|
||||
expect(markdownRenderCache.stats().entries).toBe(5)
|
||||
expect(markdownRenderCache.has('message 6')).toBe(false)
|
||||
expect(markdownRenderCache.has('message 7')).toBe(true)
|
||||
|
||||
// A hit refreshes recency: message 7 survives the next insertion.
|
||||
render(<MarkdownRenderer>{'message 7'}</MarkdownRenderer>)
|
||||
cleanup()
|
||||
render(<MarkdownRenderer>{'message 12'}</MarkdownRenderer>).unmount()
|
||||
await flushUnmountStores()
|
||||
expect(markdownRenderCache.has('message 7')).toBe(true)
|
||||
expect(markdownRenderCache.has('message 8')).toBe(false)
|
||||
|
||||
markdownRenderCache.clear()
|
||||
markdownRenderCache.configure({ maxChars: 400, maxEntries: 1000 })
|
||||
const text = (i: number) => `${String(i).padStart(3, '0')} ${'x'.repeat(96)}`
|
||||
for (let i = 0; i < 20; i++) {
|
||||
render(<MarkdownRenderer>{text(i)}</MarkdownRenderer>).unmount()
|
||||
}
|
||||
await flushUnmountStores()
|
||||
expect(markdownRenderCache.stats().totalChars).toBeLessThanOrEqual(400)
|
||||
expect(markdownRenderCache.stats().entries).toBe(4)
|
||||
expect(markdownRenderCache.has(text(19))).toBe(true)
|
||||
expect(markdownRenderCache.has(text(15))).toBe(false)
|
||||
|
||||
// A source over a quarter of the budget is never stored.
|
||||
render(<MarkdownRenderer>{'y'.repeat(150)}</MarkdownRenderer>).unmount()
|
||||
await flushUnmountStores()
|
||||
expect(markdownRenderCache.has('y'.repeat(150))).toBe(false)
|
||||
expect(markdownRenderCache.stats().entries).toBe(4)
|
||||
|
||||
markdownRenderCache.clear()
|
||||
markdownRenderCache.configure({ maxChars: 1_000_000, maxNodes: 200 })
|
||||
for (let i = 0; i < 10; i++) {
|
||||
render(<MarkdownRenderer>{`$x_${i}$`}</MarkdownRenderer>).unmount()
|
||||
}
|
||||
await flushUnmountStores()
|
||||
expect(markdownRenderCache.stats().totalNodes).toBeLessThanOrEqual(200)
|
||||
expect(markdownRenderCache.stats().evictions).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('keeps interactive elements working after a cache hit', async () => {
|
||||
const content = '[Factory](https://factory.ai)\n\n```html\n<section><h1>Interactive</h1></section>\n```\n\n```mermaid\ngraph TD\nA-->B\n```\n\nend'
|
||||
render(<MarkdownRenderer renderHtml>{content}</MarkdownRenderer>).unmount()
|
||||
await flushUnmountStores()
|
||||
const parses = markdownRenderCache.stats().parses
|
||||
|
||||
const { container } = render(<div className="app-shell"><MarkdownRenderer renderHtml>{content}</MarkdownRenderer></div>)
|
||||
expect(markdownRenderCache.stats().parses).toBe(parses)
|
||||
|
||||
const link = container.querySelector('a')!
|
||||
expect(link).toHaveAttribute('target', '_blank')
|
||||
expect(link).toHaveAttribute('rel', 'noopener noreferrer')
|
||||
|
||||
// HTML preview: source toggle and full-screen dialog keep their state.
|
||||
const buttons = () => [...container.querySelectorAll<HTMLButtonElement>('.static-html-preview button, button')]
|
||||
const sourceToggle = buttons().find((button) => button.hasAttribute('aria-expanded'))!
|
||||
expect(sourceToggle).toHaveAttribute('aria-expanded', 'false')
|
||||
fireEvent.click(sourceToggle)
|
||||
expect(sourceToggle).toHaveAttribute('aria-expanded', 'true')
|
||||
expect(container).toHaveTextContent('<section><h1>Interactive</h1></section>')
|
||||
fireEvent.click(sourceToggle)
|
||||
expect(sourceToggle).toHaveAttribute('aria-expanded', 'false')
|
||||
|
||||
const before = document.querySelectorAll('[role="dialog"]').length
|
||||
const fullScreen = buttons().find((button) => !button.hasAttribute('aria-expanded'))!
|
||||
fireEvent.click(fullScreen)
|
||||
expect(document.querySelectorAll('[role="dialog"]').length).toBe(before + 1)
|
||||
|
||||
// Mermaid still mounts its own lazy component per instance.
|
||||
await vi.waitFor(() => {
|
||||
expect(container.querySelector('.mermaid-diagram')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
it('makes remounting a large message cheaper (micro-benchmark)', async () => {
|
||||
const block = (i: number) => [
|
||||
`## Section ${i}`,
|
||||
`Text with **bold**, \`code\`, [link](https://example.com/${i}) and $a_${i}^2 + b^2$.`,
|
||||
`$$\n\\sum_{k=1}^{${i + 2}} k = \\frac{n(n+1)}{2}\n$$`,
|
||||
'```ts\n' + `const value${i} = ${i}\nexport default value${i}\n` + '```',
|
||||
'| A | B |\n| - | - |\n| 1 | 2 |',
|
||||
`- item ${i}\n- item ${i + 1}`
|
||||
].join('\n\n')
|
||||
let content = ''
|
||||
for (let i = 0; content.length < 40_000; i++) {
|
||||
content += (i ? '\n\n' : '') + block(i)
|
||||
}
|
||||
|
||||
const measure = (): { html: string; ms: number } => {
|
||||
const start = performance.now()
|
||||
const view = render(<MarkdownRenderer>{content}</MarkdownRenderer>)
|
||||
const ms = performance.now() - start
|
||||
const html = view.container.innerHTML
|
||||
view.unmount()
|
||||
return { html, ms }
|
||||
}
|
||||
const cold = measure()
|
||||
await flushUnmountStores()
|
||||
const warm = measure()
|
||||
expect(warm.html).toBe(cold.html)
|
||||
|
||||
// The pipeline alone, without jsdom DOM creation and React commit.
|
||||
const segments = segmentsOf(content)
|
||||
let start = performance.now()
|
||||
for (const segment of segments) parseMarkdown(segment, blockMarkdownPipeline)
|
||||
const parseMs = performance.now() - start
|
||||
start = performance.now()
|
||||
for (const segment of segments) hastToReact(lookupMarkdown(segment, blockMarkdownPipeline)!.tree, undefined)
|
||||
const hitMs = performance.now() - start
|
||||
console.info(
|
||||
`[markdown-render-cache] ${content.length} chars, ${segments.length} segments: ` +
|
||||
`mount cold ${cold.ms.toFixed(1)} ms, mount cache hit ${warm.ms.toFixed(1)} ms; ` +
|
||||
`pipeline parse ${parseMs.toFixed(1)} ms, cache hit + hast->React ${hitMs.toFixed(1)} ms`
|
||||
)
|
||||
expect(warm.ms).toBeLessThan(cold.ms)
|
||||
}, 30000)
|
||||
})
|
||||
@@ -5,14 +5,22 @@ import {
|
||||
lazy,
|
||||
memo,
|
||||
Suspense,
|
||||
useMemo
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef
|
||||
} from 'react'
|
||||
import rehypeKatex from 'rehype-katex'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import type { Components } from 'react-markdown'
|
||||
import rehypeKatex from 'rehype-katex'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import remarkMath from 'remark-math'
|
||||
import type { Components } from 'react-markdown'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
blockMarkdownPipeline,
|
||||
getMarkdown,
|
||||
hastToReact,
|
||||
storeMarkdown
|
||||
} from './markdown-render-cache'
|
||||
import { StaticHtmlPreview } from './StaticHtmlPreview'
|
||||
|
||||
const MermaidDiagram = lazy(() =>
|
||||
@@ -367,38 +375,63 @@ export function splitMarkdownSegments(content: string): string[] {
|
||||
return segments
|
||||
}
|
||||
|
||||
const markdownRehypePlugins: NonNullable<
|
||||
React.ComponentProps<typeof ReactMarkdown>['rehypePlugins']
|
||||
> = [
|
||||
[
|
||||
rehypeKatex,
|
||||
{
|
||||
output: 'htmlAndMathml',
|
||||
strict: 'warn',
|
||||
trust: false
|
||||
}
|
||||
]
|
||||
]
|
||||
const markdownRemarkPlugins: NonNullable<
|
||||
React.ComponentProps<typeof ReactMarkdown>['remarkPlugins']
|
||||
> = [remarkGfm, [remarkMath, { singleDollarTextMath: true }]]
|
||||
|
||||
/**
|
||||
* Renders one segment from a hast tree shared through the module-level cache
|
||||
* (markdown-render-cache.ts). Only the hast to React step runs per mount, with
|
||||
* this instance's `components`, so callbacks and state are never shared.
|
||||
*
|
||||
* Cache-write rule (the renderer does not know whether a message is still
|
||||
* streaming):
|
||||
* - A segment followed by another segment (`complete`) is stored: the split
|
||||
* rules guarantee its text and blocks no longer depend on what comes next.
|
||||
* - The final segment, which may still be growing, is stored only when it
|
||||
* unmounts (the windowed timeline scrolls it away, or the view closes).
|
||||
* That is exactly the text a remount asks for, and a stream adds at most
|
||||
* one prefix per unmount instead of one entry per token.
|
||||
* Reads always consult the cache; keys are the exact segment text, so any
|
||||
* entry is correct for its key regardless of when it was stored.
|
||||
*/
|
||||
const MarkdownSegment = memo(function MarkdownSegment({
|
||||
complete,
|
||||
components,
|
||||
content
|
||||
}: {
|
||||
complete: boolean
|
||||
components: Components
|
||||
content: string
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<ReactMarkdown
|
||||
components={components}
|
||||
rehypePlugins={markdownRehypePlugins}
|
||||
remarkPlugins={markdownRemarkPlugins}
|
||||
skipHtml
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
const parsed = useMemo(
|
||||
() => getMarkdown(content, blockMarkdownPipeline),
|
||||
[content]
|
||||
)
|
||||
const latest = useRef({ content, parsed })
|
||||
const mounted = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
latest.current = { content, parsed }
|
||||
if (complete) {
|
||||
storeMarkdown(content, blockMarkdownPipeline, parsed)
|
||||
}
|
||||
}, [complete, content, parsed])
|
||||
|
||||
useEffect(() => {
|
||||
mounted.current = true
|
||||
return () => {
|
||||
mounted.current = false
|
||||
// StrictMode re-runs effects right after this cleanup; storing only
|
||||
// when the segment stays unmounted keeps that from caching a prefix.
|
||||
queueMicrotask(() => {
|
||||
if (!mounted.current) {
|
||||
const last = latest.current
|
||||
storeMarkdown(last.content, blockMarkdownPipeline, last.parsed)
|
||||
}
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
|
||||
return useMemo(
|
||||
() => hastToReact(parsed.tree, components),
|
||||
[components, parsed]
|
||||
)
|
||||
})
|
||||
|
||||
@@ -408,7 +441,20 @@ export const MarkdownRenderer = memo(function MarkdownRenderer(
|
||||
return <MarkdownDocument {...props} segmented />
|
||||
})
|
||||
|
||||
/** Whole-document reference rendering; used to verify segmentation. */
|
||||
// Must stay in sync with blockMarkdownPipeline in markdown-render-cache.ts.
|
||||
const referenceRehypePlugins: NonNullable<
|
||||
React.ComponentProps<typeof ReactMarkdown>['rehypePlugins']
|
||||
> = [
|
||||
[rehypeKatex, { output: 'htmlAndMathml', strict: 'warn', trust: false }]
|
||||
]
|
||||
const referenceRemarkPlugins: NonNullable<
|
||||
React.ComponentProps<typeof ReactMarkdown>['remarkPlugins']
|
||||
> = [remarkGfm, [remarkMath, { singleDollarTextMath: true }]]
|
||||
|
||||
/**
|
||||
* Whole-document reference rendering through plain react-markdown, without
|
||||
* the cache; used to verify segmentation and caching.
|
||||
*/
|
||||
export const UnsegmentedMarkdownRenderer = memo(
|
||||
function UnsegmentedMarkdownRenderer(
|
||||
props: MarkdownRendererProps
|
||||
@@ -450,15 +496,31 @@ function MarkdownDocument({
|
||||
// Completed segments keep identical strings, so streaming updates re-parse
|
||||
// only the growing tail. The "\n" matches the text node ReactMarkdown
|
||||
// places between top-level blocks, keeping the DOM identical.
|
||||
const segments = segmented
|
||||
? splitMarkdownSegments(normalizedContent)
|
||||
: [normalizedContent]
|
||||
if (!segmented) {
|
||||
// Plain react-markdown, bypassing the cache: the reference also checks
|
||||
// that the cached pipeline matches react-markdown.
|
||||
return (
|
||||
<ReactMarkdown
|
||||
components={components}
|
||||
rehypePlugins={referenceRehypePlugins}
|
||||
remarkPlugins={referenceRemarkPlugins}
|
||||
skipHtml
|
||||
>
|
||||
{normalizedContent}
|
||||
</ReactMarkdown>
|
||||
)
|
||||
}
|
||||
const segments = splitMarkdownSegments(normalizedContent)
|
||||
return (
|
||||
<>
|
||||
{segments.map((segment, index) => (
|
||||
<Fragment key={index}>
|
||||
{index > 0 && '\n'}
|
||||
<MarkdownSegment components={components} content={segment} />
|
||||
<MarkdownSegment
|
||||
complete={index < segments.length - 1}
|
||||
components={components}
|
||||
content={segment}
|
||||
/>
|
||||
</Fragment>
|
||||
))}
|
||||
</>
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
rangeAround,
|
||||
rangeCovers,
|
||||
rowAtOffset,
|
||||
rowAtViewportTop,
|
||||
rowHeightsFor,
|
||||
tailRange
|
||||
} from './chat-message-window'
|
||||
@@ -73,6 +74,16 @@ describe('window ranges', () => {
|
||||
expect(rowAtOffset(sizeAt, 10, 2_000, 250)).toEqual({ index: 12, top: 200 })
|
||||
expect(rowAtOffset(sizeAt, 10, 20, 99_999)).toEqual({ index: 19, top: 900 })
|
||||
})
|
||||
|
||||
it('finds the row at the viewport top from a known row position', () => {
|
||||
// Anchor row 50 sits 250 px below the viewport top: rows 48..49 lie above it.
|
||||
expect(rowAtViewportTop(sizeAt, 0, 2_000, 50, 250)).toEqual({ index: 47, top: -50 })
|
||||
// Anchor scrolled 1,030 px above the viewport top.
|
||||
expect(rowAtViewportTop(sizeAt, 0, 2_000, 50, -1_030)).toEqual({ index: 60, top: -30 })
|
||||
// Clamped to the loaded window.
|
||||
expect(rowAtViewportTop(sizeAt, 40, 2_000, 50, 5_000)).toEqual({ index: 40, top: 4_000 })
|
||||
expect(rowAtViewportTop(sizeAt, 0, 55, 50, -99_999)).toEqual({ index: 54, top: -99_999 + 400 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildSegments', () => {
|
||||
|
||||
@@ -170,6 +170,34 @@ export function rowAtOffset(
|
||||
return { index: windowStart, top: 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Row at the viewport top, found by walking from a known row whose top is
|
||||
* `anchorTop` pixels below the viewport top (negative when above). Returns
|
||||
* that row and its top relative to the viewport top (<= 0 unless the window
|
||||
* starts below the viewport top). Lets the scroll handler locate the viewport
|
||||
* from cached heights instead of reading layout.
|
||||
*/
|
||||
export function rowAtViewportTop(
|
||||
sizeAt: (index: number) => number,
|
||||
windowStart: number,
|
||||
count: number,
|
||||
anchorIndex: number,
|
||||
anchorTop: number,
|
||||
): { index: number; top: number } {
|
||||
if (count <= windowStart) return { index: windowStart, top: anchorTop };
|
||||
let index = Math.min(Math.max(anchorIndex, windowStart), count - 1);
|
||||
let top = anchorTop;
|
||||
while (top > 0 && index > windowStart) {
|
||||
index -= 1;
|
||||
top -= sizeAt(index);
|
||||
}
|
||||
while (index < count - 1 && top + sizeAt(index) <= 0) {
|
||||
top += sizeAt(index);
|
||||
index += 1;
|
||||
}
|
||||
return { index, top };
|
||||
}
|
||||
|
||||
export function rangeCovers(
|
||||
outer: MessageWindowRange,
|
||||
inner: MessageWindowRange,
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
import type { Nodes, Root } from 'hast'
|
||||
import { toJsxRuntime } from 'hast-util-to-jsx-runtime'
|
||||
import { urlAttributes } from 'html-url-attributes'
|
||||
import { Fragment, jsx, jsxs } from 'react/jsx-runtime'
|
||||
import { defaultUrlTransform } from 'react-markdown'
|
||||
import type { Components } from 'react-markdown'
|
||||
import rehypeKatex from 'rehype-katex'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import remarkMath from 'remark-math'
|
||||
import remarkParse from 'remark-parse'
|
||||
import remarkRehype from 'remark-rehype'
|
||||
import { unified } from 'unified'
|
||||
import { visit } from 'unist-util-visit'
|
||||
import { VFile } from 'vfile'
|
||||
|
||||
/**
|
||||
* Markdown to hast pipeline with a module-level LRU cache.
|
||||
*
|
||||
* The expensive part of rendering Markdown (micromark, remark, rehype, KaTeX)
|
||||
* produces a hast tree that depends only on the source text and the pipeline
|
||||
* configuration. The cache stores that finished tree; every mount still runs
|
||||
* the cheap hast to React step with its own `components`, so per-instance
|
||||
* callbacks, state, locale-dependent labels and the HTML preview mode are
|
||||
* never captured by a cache entry.
|
||||
*
|
||||
* The output matches `react-markdown` 10 (`Markdown`): same processor, same
|
||||
* post-processing (raw HTML handling, URL sanitizing) and the same
|
||||
* `toJsxRuntime` options. Cached trees are immutable after `parse`;
|
||||
* `toJsxRuntime` only reads them.
|
||||
*/
|
||||
|
||||
export type MarkdownPipeline = Readonly<{
|
||||
/**
|
||||
* Identifies every option that changes the hast output (plugins, plugin
|
||||
* options, raw HTML handling). Part of each cache key, so pipelines with
|
||||
* different output never share entries.
|
||||
*/
|
||||
id: string
|
||||
run: (file: VFile) => Root
|
||||
skipHtml: boolean
|
||||
}>
|
||||
|
||||
const blockProcessor = unified()
|
||||
.use(remarkParse)
|
||||
.use(remarkGfm)
|
||||
.use(remarkMath, { singleDollarTextMath: true })
|
||||
.use(remarkRehype, { allowDangerousHtml: true })
|
||||
.use(rehypeKatex, {
|
||||
output: 'htmlAndMathml',
|
||||
strict: 'warn',
|
||||
trust: false
|
||||
})
|
||||
.freeze()
|
||||
|
||||
/** GFM + math (single dollar) + KaTeX, raw HTML skipped. */
|
||||
export const blockMarkdownPipeline: MarkdownPipeline = {
|
||||
id: 'gfm|math(singleDollar)|katex(htmlAndMathml,warn,untrusted)|skipHtml',
|
||||
run: (file) => blockProcessor.runSync(blockProcessor.parse(file), file),
|
||||
skipHtml: true
|
||||
}
|
||||
|
||||
/**
|
||||
* Same post-processing as react-markdown's `post` before `toJsxRuntime`.
|
||||
* Returns the number of visited nodes, used as the memory cost of a tree.
|
||||
*/
|
||||
function postProcess(tree: Root, pipeline: MarkdownPipeline): number {
|
||||
let nodes = 0
|
||||
visit(tree as Nodes, (node, index, parent) => {
|
||||
nodes++
|
||||
if (node.type === 'raw' && parent && typeof index === 'number') {
|
||||
if (pipeline.skipHtml) {
|
||||
parent.children.splice(index, 1)
|
||||
} else {
|
||||
parent.children[index] = { type: 'text', value: node.value }
|
||||
}
|
||||
return index
|
||||
}
|
||||
if (node.type === 'element') {
|
||||
for (const key in urlAttributes) {
|
||||
if (
|
||||
Object.hasOwn(urlAttributes, key) &&
|
||||
Object.hasOwn(node.properties, key)
|
||||
) {
|
||||
const test = urlAttributes[key]
|
||||
if (test === null || test?.includes(node.tagName)) {
|
||||
node.properties[key] = defaultUrlTransform(
|
||||
String(node.properties[key] || '')
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
return nodes
|
||||
}
|
||||
|
||||
export type ParsedMarkdown = Readonly<{
|
||||
/** hast node count, the memory cost of the tree. */
|
||||
nodes: number
|
||||
tree: Root
|
||||
}>
|
||||
|
||||
/** Uncached Markdown to hast with react-markdown semantics. */
|
||||
export function parseMarkdown(
|
||||
source: string,
|
||||
pipeline: MarkdownPipeline
|
||||
): ParsedMarkdown {
|
||||
const file = new VFile()
|
||||
file.value = source
|
||||
const tree = pipeline.run(file)
|
||||
const nodes = postProcess(tree, pipeline)
|
||||
stats.parses++
|
||||
return { nodes, tree }
|
||||
}
|
||||
|
||||
/** Cheap per-mount step; same toJsxRuntime options as react-markdown. */
|
||||
export function hastToReact(
|
||||
tree: Root,
|
||||
components: Components | undefined
|
||||
): React.JSX.Element {
|
||||
return toJsxRuntime(tree, {
|
||||
Fragment,
|
||||
components,
|
||||
ignoreInvalidStyle: true,
|
||||
jsx,
|
||||
jsxs,
|
||||
passKeys: true,
|
||||
passNode: true
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LRU cache
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type MarkdownCacheLimits = {
|
||||
/** Total source characters across all entries. */
|
||||
maxChars: number
|
||||
maxEntries: number
|
||||
/**
|
||||
* Total hast nodes across all entries. KaTeX output is far larger than its
|
||||
* source, so characters alone do not bound memory.
|
||||
*/
|
||||
maxNodes: number
|
||||
}
|
||||
|
||||
const defaultLimits: Readonly<MarkdownCacheLimits> = {
|
||||
maxChars: 2_000_000,
|
||||
maxEntries: 2_000,
|
||||
maxNodes: 600_000
|
||||
}
|
||||
|
||||
let limits: MarkdownCacheLimits = { ...defaultLimits }
|
||||
// Map iteration order is insertion order: the first key is least recently
|
||||
// used.
|
||||
const entries = new Map<string, ParsedMarkdown & { chars: number }>()
|
||||
let totalChars = 0
|
||||
let totalNodes = 0
|
||||
const stats = { evictions: 0, hits: 0, misses: 0, parses: 0, stores: 0 }
|
||||
|
||||
function cacheKey(source: string, pipeline: MarkdownPipeline): string {
|
||||
return `${pipeline.id}\u0000${source}`
|
||||
}
|
||||
|
||||
function overLimit(): boolean {
|
||||
return (
|
||||
entries.size > limits.maxEntries ||
|
||||
totalChars > limits.maxChars ||
|
||||
totalNodes > limits.maxNodes
|
||||
)
|
||||
}
|
||||
|
||||
function evict(): void {
|
||||
while (entries.size > 0 && overLimit()) {
|
||||
const [oldest, entry] = entries.entries().next().value!
|
||||
entries.delete(oldest)
|
||||
totalChars -= entry.chars
|
||||
totalNodes -= entry.nodes
|
||||
stats.evictions++
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns a cached tree, marking it most recently used. */
|
||||
export function lookupMarkdown(
|
||||
source: string,
|
||||
pipeline: MarkdownPipeline
|
||||
): ParsedMarkdown | undefined {
|
||||
const key = cacheKey(source, pipeline)
|
||||
const entry = entries.get(key)
|
||||
if (!entry) {
|
||||
stats.misses++
|
||||
return undefined
|
||||
}
|
||||
entries.delete(key)
|
||||
entries.set(key, entry)
|
||||
stats.hits++
|
||||
return entry
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores the result of `parseMarkdown(source, pipeline)`. A source that
|
||||
* would take more than a quarter of any budget is skipped, so one huge
|
||||
* message cannot flush everything else.
|
||||
*/
|
||||
export function storeMarkdown(
|
||||
source: string,
|
||||
pipeline: MarkdownPipeline,
|
||||
parsed: ParsedMarkdown
|
||||
): void {
|
||||
if (
|
||||
source.length > limits.maxChars / 4 ||
|
||||
parsed.nodes > limits.maxNodes / 4
|
||||
) {
|
||||
return
|
||||
}
|
||||
const key = cacheKey(source, pipeline)
|
||||
const existing = entries.get(key)
|
||||
if (existing) {
|
||||
entries.delete(key)
|
||||
totalChars -= existing.chars
|
||||
totalNodes -= existing.nodes
|
||||
}
|
||||
entries.set(key, { chars: source.length, ...parsed })
|
||||
totalChars += source.length
|
||||
totalNodes += parsed.nodes
|
||||
stats.stores++
|
||||
evict()
|
||||
}
|
||||
|
||||
/** Cache lookup falling back to a parse. Never stores. */
|
||||
export function getMarkdown(
|
||||
source: string,
|
||||
pipeline: MarkdownPipeline
|
||||
): ParsedMarkdown {
|
||||
return lookupMarkdown(source, pipeline) ?? parseMarkdown(source, pipeline)
|
||||
}
|
||||
|
||||
/** Test and diagnostics hooks. */
|
||||
export const markdownRenderCache = {
|
||||
clear(): void {
|
||||
entries.clear()
|
||||
totalChars = 0
|
||||
totalNodes = 0
|
||||
for (const key of Object.keys(stats) as (keyof typeof stats)[]) {
|
||||
stats[key] = 0
|
||||
}
|
||||
},
|
||||
configure(next: Partial<MarkdownCacheLimits>): void {
|
||||
limits = { ...limits, ...next }
|
||||
evict()
|
||||
},
|
||||
has(source: string, pipeline = blockMarkdownPipeline): boolean {
|
||||
return entries.has(cacheKey(source, pipeline))
|
||||
},
|
||||
keys(): string[] {
|
||||
return [...entries.keys()]
|
||||
},
|
||||
resetLimits(): void {
|
||||
limits = { ...defaultLimits }
|
||||
evict()
|
||||
},
|
||||
stats(): Readonly<
|
||||
typeof stats & { entries: number; totalChars: number; totalNodes: number }
|
||||
> {
|
||||
return { ...stats, entries: entries.size, totalChars, totalNodes }
|
||||
}
|
||||
}
|
||||
@@ -4342,6 +4342,27 @@ textarea:focus-visible {
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
|
||||
/* Windowed lists (PERF-14) measure rows with getBoundingClientRect, which
|
||||
excludes margins, so the row gap becomes padding. The list keeps the
|
||||
reader's row in place itself; native scroll anchoring could pick a spacer. */
|
||||
.conversation-list--windowed {
|
||||
overflow-anchor: none;
|
||||
}
|
||||
|
||||
.conversation-list--windowed > .conversation-entry {
|
||||
padding-top: var(--space-1);
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.conversation-list--windowed > .conversation-entry[aria-posinset="1"] {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.conversation-list__spacer {
|
||||
flex: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
padding: 0 var(--space-3);
|
||||
margin: 0 0 var(--space-2);
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
import { useCallback, useLayoutEffect, useRef, useState } from 'react'
|
||||
|
||||
/**
|
||||
* Windowing for the sidebar conversation list (PERF-14).
|
||||
*
|
||||
* Long lists mount only the rows near the viewport; spacers stand in for the
|
||||
* rest, sized from measured row heights (or the mean measured height for rows
|
||||
* never rendered). The list is top-anchored like any normal list. Rows that
|
||||
* hold user state (focus, open menu, rename, the active conversation) stay
|
||||
* mounted at their real position even when scrolled away.
|
||||
*/
|
||||
|
||||
/** Lists shorter than this render every row (no windowing). */
|
||||
export const conversationListWindowThreshold = 60
|
||||
/** Height assumed for rows before any row has been measured. */
|
||||
export const estimatedConversationRowHeight = 40
|
||||
/** Upper bound for rows mounted from the range (kept rows come on top). */
|
||||
export const maxRenderedConversationRows = 120
|
||||
const fallbackViewportHeight = 800
|
||||
|
||||
export function conversationListOverscan(viewportHeight: number): number {
|
||||
return Math.max(320, Math.round(viewportHeight))
|
||||
}
|
||||
|
||||
export type ConversationListSegment =
|
||||
| { kind: 'spacer'; key: string; height: number }
|
||||
| { kind: 'row'; index: number }
|
||||
|
||||
class ConversationRowHeights {
|
||||
private readonly sizes = new Map<string, number>()
|
||||
private estimateValue = estimatedConversationRowHeight
|
||||
private viewportValue = 0
|
||||
|
||||
get viewport(): number {
|
||||
return this.viewportValue > 0 ? this.viewportValue : fallbackViewportHeight
|
||||
}
|
||||
|
||||
setViewport(height: number): boolean {
|
||||
if (height <= 0 || Math.abs(height - this.viewportValue) < 1) return false
|
||||
this.viewportValue = height
|
||||
return true
|
||||
}
|
||||
|
||||
size(id: string): number {
|
||||
return this.sizes.get(id) ?? this.estimateValue
|
||||
}
|
||||
|
||||
/** Records a height; returns whether layout may have changed. */
|
||||
set(id: string, height: number): boolean {
|
||||
const previous = this.sizes.get(id)
|
||||
if (previous !== undefined) {
|
||||
if (Math.abs(previous - height) < 0.5) return false
|
||||
this.sizes.set(id, height)
|
||||
return true
|
||||
}
|
||||
const count = this.sizes.size
|
||||
this.estimateValue = count === 0 ? height : (this.estimateValue * count + height) / (count + 1)
|
||||
this.sizes.set(id, height)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
type Layout = {
|
||||
/** offsets[i] is the top of row i; offsets[count] is the total height. */
|
||||
offsets: number[]
|
||||
start: number
|
||||
end: number
|
||||
viewport: number
|
||||
}
|
||||
|
||||
/** Rows covering the viewport whose top is `scrollTop`, plus `overscan` on both sides. */
|
||||
export function conversationWindowRange(
|
||||
offsets: readonly number[],
|
||||
scrollTop: number,
|
||||
viewportHeight: number,
|
||||
overscan: number,
|
||||
maxCount = maxRenderedConversationRows,
|
||||
): { start: number; end: number } {
|
||||
const count = offsets.length - 1
|
||||
const total = offsets[count] ?? 0
|
||||
const top = Math.min(Math.max(0, scrollTop), Math.max(0, total - viewportHeight))
|
||||
let start = 0
|
||||
while (start < count && offsets[start + 1]! <= top - overscan) start += 1
|
||||
let end = start
|
||||
while (end < count && offsets[end]! < top + viewportHeight + overscan) end += 1
|
||||
if (end - start > maxCount) {
|
||||
// Keep the rows at the viewport top; trim the overscan evenly.
|
||||
let first = start
|
||||
while (first < end && offsets[first + 1]! <= top) first += 1
|
||||
start = Math.max(start, Math.min(first - Math.floor(maxCount / 4), end - maxCount))
|
||||
end = Math.min(end, start + maxCount)
|
||||
}
|
||||
return { start, end }
|
||||
}
|
||||
|
||||
export function useConversationListWindow({
|
||||
ids,
|
||||
scope,
|
||||
scrollRef,
|
||||
keepIds,
|
||||
enabled,
|
||||
}: {
|
||||
/** Row ids in display order. */
|
||||
ids: readonly string[]
|
||||
/** Changing the scope (project, search) returns the list to the top. */
|
||||
scope: string
|
||||
scrollRef: React.RefObject<HTMLElement | null>
|
||||
/** Ids that must stay mounted (active, menu open, renaming...). */
|
||||
keepIds: readonly (string | undefined)[]
|
||||
enabled: boolean
|
||||
}) {
|
||||
const windowed = enabled && ids.length >= conversationListWindowThreshold
|
||||
const [heights] = useState(() => new ConversationRowHeights())
|
||||
const [measureVersion, setMeasureVersion] = useState(0)
|
||||
// scrollTop at the last re-window; the range is derived from it.
|
||||
const [anchorTop, setAnchorTop] = useState(0)
|
||||
const [focusedId, setFocusedId] = useState<string | undefined>(undefined)
|
||||
const [seenScope, setSeenScope] = useState(scope)
|
||||
if (seenScope !== scope) {
|
||||
setSeenScope(scope)
|
||||
setAnchorTop(0)
|
||||
}
|
||||
|
||||
const layoutRef = useRef<Layout | undefined>(undefined)
|
||||
const rowElements = useRef(new Map<string, HTMLElement>())
|
||||
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)
|
||||
const scopeRef = useRef(scope)
|
||||
|
||||
const keep = new Set([...keepIds, focusedId].filter((id): id is string => Boolean(id)))
|
||||
// Kept rows change the mounted set (and spacers), so the layout effect reruns.
|
||||
const keepKey = [...keep].join('\n')
|
||||
let segments: ConversationListSegment[]
|
||||
let layout: Layout | undefined
|
||||
if (!windowed) {
|
||||
segments = ids.map((_, index) => ({ kind: 'row', index }))
|
||||
} else {
|
||||
const offsets = new Array<number>(ids.length + 1)
|
||||
offsets[0] = 0
|
||||
for (let index = 0; index < ids.length; index += 1) {
|
||||
offsets[index + 1] = offsets[index]! + heights.size(ids[index]!)
|
||||
}
|
||||
const viewport = heights.viewport
|
||||
const range = conversationWindowRange(offsets, anchorTop, viewport, conversationListOverscan(viewport))
|
||||
layout = { offsets, start: range.start, end: range.end, viewport }
|
||||
const rendered: number[] = []
|
||||
for (let index = range.start; index < range.end; index += 1) rendered.push(index)
|
||||
if (keep.size) {
|
||||
ids.forEach((id, index) => {
|
||||
if (keep.has(id) && (index < range.start || index >= range.end)) rendered.push(index)
|
||||
})
|
||||
rendered.sort((left, right) => left - right)
|
||||
}
|
||||
// The top spacer is always present so it keeps its identity while the window moves.
|
||||
segments = [{ kind: 'spacer', key: 'top', height: 0 }]
|
||||
let previous = 0
|
||||
for (const index of rendered) {
|
||||
if (index > previous) {
|
||||
const height = offsets[index]! - offsets[previous]!
|
||||
if (previous === 0) segments[0] = { kind: 'spacer', key: 'top', height }
|
||||
else segments.push({ kind: 'spacer', key: `gap:${ids[previous]}`, height })
|
||||
}
|
||||
segments.push({ kind: 'row', index })
|
||||
previous = index + 1
|
||||
}
|
||||
if (previous < ids.length) {
|
||||
segments.push({ kind: 'spacer', key: 'bottom', height: offsets[ids.length]! - offsets[previous]! })
|
||||
}
|
||||
}
|
||||
|
||||
useLayoutEffect(() => {
|
||||
layoutRef.current = layout
|
||||
})
|
||||
|
||||
/** First mounted row reaching into the viewport, relative to the viewport top. */
|
||||
const captureScrollAnchor = useCallback((): void => {
|
||||
const container = scrollRef.current
|
||||
if (!container || container.scrollTop <= 0) {
|
||||
scrollAnchorRef.current = undefined
|
||||
return
|
||||
}
|
||||
const viewportTop = container.getBoundingClientRect().top
|
||||
for (const element of container.querySelectorAll<HTMLElement>('[data-conversation-window-row]')) {
|
||||
const rect = element.getBoundingClientRect()
|
||||
if (rect.bottom > viewportTop) {
|
||||
scrollAnchorRef.current = { id: element.dataset.conversationWindowRow ?? '', top: rect.top - viewportTop }
|
||||
return
|
||||
}
|
||||
}
|
||||
scrollAnchorRef.current = undefined
|
||||
}, [scrollRef])
|
||||
|
||||
// Row and viewport measurement.
|
||||
useLayoutEffect(() => {
|
||||
const container = scrollRef.current
|
||||
if (!windowed || !container) return
|
||||
if (heights.setViewport(container.clientHeight)) setMeasureVersion((version) => version + 1)
|
||||
if (typeof ResizeObserver !== 'function') return
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
let changed = false
|
||||
for (const entry of entries) {
|
||||
const target = entry.target as HTMLElement
|
||||
if (target === container) {
|
||||
if (heights.setViewport(container.clientHeight)) changed = true
|
||||
continue
|
||||
}
|
||||
const id = target.dataset?.conversationWindowRow
|
||||
if (!id) continue
|
||||
const height = target.getBoundingClientRect().height
|
||||
if (height > 0 && heights.set(id, height)) changed = true
|
||||
}
|
||||
if (changed) {
|
||||
// Spacer sizes may change with the new estimate; keep the visible row in place.
|
||||
captureScrollAnchor()
|
||||
setMeasureVersion((version) => version + 1)
|
||||
}
|
||||
})
|
||||
observer.observe(container)
|
||||
for (const element of rowElements.current.values()) observer.observe(element)
|
||||
observerRef.current = observer
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
observerRef.current = undefined
|
||||
}
|
||||
}, [captureScrollAnchor, heights, scrollRef, windowed])
|
||||
|
||||
// After each commit: restore the visible row's position after a re-layout,
|
||||
// return to the top on a scope change, and re-window when the scroll
|
||||
// position left the mounted rows without a scroll event (content shrank).
|
||||
useLayoutEffect(() => {
|
||||
const container = scrollRef.current
|
||||
if (!container) return
|
||||
if (scopeRef.current !== scope) {
|
||||
scopeRef.current = scope
|
||||
scrollAnchorRef.current = undefined
|
||||
// New search results or another project start at the top; short lists
|
||||
// keep the browser's own clamping, as before windowing.
|
||||
if (windowed && container.scrollTop !== 0) container.scrollTop = 0
|
||||
}
|
||||
const anchor = scrollAnchorRef.current
|
||||
scrollAnchorRef.current = undefined
|
||||
if (windowed && anchor) {
|
||||
const element = rowElements.current.get(anchor.id)
|
||||
if (element) {
|
||||
const delta = element.getBoundingClientRect().top - container.getBoundingClientRect().top - anchor.top
|
||||
if (Math.abs(delta) >= 0.5) container.scrollTop += delta
|
||||
}
|
||||
}
|
||||
const current = layoutRef.current
|
||||
if (!windowed || !current) return
|
||||
const scrollTop = container.scrollTop
|
||||
if (!coversViewport(current, scrollTop) && Math.abs(scrollTop - anchorTop) >= 1) setAnchorTop(scrollTop)
|
||||
}, [anchorTop, ids, keepKey, measureVersion, scope, scrollRef, windowed])
|
||||
|
||||
const onScroll = useCallback((): void => {
|
||||
const container = scrollRef.current
|
||||
const current = layoutRef.current
|
||||
if (!container || !current) return
|
||||
if (heights.setViewport(container.clientHeight)) setMeasureVersion((version) => version + 1)
|
||||
const scrollTop = container.scrollTop
|
||||
// Hysteresis: re-window only when the viewport (plus a margin) leaves the
|
||||
// mounted rows, so a steady scroll remounts rows every screen or so.
|
||||
if (!coversViewport(current, scrollTop)) setAnchorTop(scrollTop)
|
||||
}, [heights, scrollRef])
|
||||
|
||||
const rowRef = useCallback((id: string): ((element: HTMLElement | null) => void) => {
|
||||
let callback = rowRefCallbacks.current.get(id)
|
||||
if (!callback) {
|
||||
callback = (element) => {
|
||||
const previous = rowElements.current.get(id)
|
||||
if (previous && previous !== element) {
|
||||
observerRef.current?.unobserve(previous)
|
||||
rowElements.current.delete(id)
|
||||
}
|
||||
if (element && previous !== element) {
|
||||
rowElements.current.set(id, element)
|
||||
observerRef.current?.observe(element)
|
||||
}
|
||||
if (!element) rowRefCallbacks.current.delete(id)
|
||||
}
|
||||
rowRefCallbacks.current.set(id, callback)
|
||||
}
|
||||
return callback
|
||||
}, [])
|
||||
|
||||
const onFocus = useCallback((event: React.FocusEvent<HTMLElement>): void => {
|
||||
const row = event.target instanceof Element
|
||||
? event.target.closest<HTMLElement>('[data-conversation-window-row]')
|
||||
: null
|
||||
setFocusedId(row?.dataset.conversationWindowRow)
|
||||
}, [])
|
||||
const onBlur = useCallback((event: React.FocusEvent<HTMLElement>): void => {
|
||||
const next = event.relatedTarget
|
||||
// Focus leaving the window (null) keeps the row; moving elsewhere releases it.
|
||||
if (next instanceof Node && !event.currentTarget.contains(next)) setFocusedId(undefined)
|
||||
}, [])
|
||||
|
||||
return { windowed, segments, rowRef, onScroll, onFocus, onBlur }
|
||||
}
|
||||
|
||||
function coversViewport(layout: Layout, scrollTop: number): boolean {
|
||||
const count = layout.offsets.length - 1
|
||||
const margin = Math.round(layout.viewport / 4)
|
||||
const renderedTop = layout.offsets[layout.start] ?? 0
|
||||
const renderedBottom = layout.offsets[layout.end] ?? 0
|
||||
const topCovered = layout.start === 0 || renderedTop <= scrollTop - margin
|
||||
const bottomCovered = layout.end >= count || renderedBottom >= scrollTop + layout.viewport + margin
|
||||
return topCovered && bottomCovered
|
||||
}
|
||||
@@ -386,16 +386,37 @@ async function seed() {
|
||||
|
||||
const rowSelectorByTitle = title => `[...document.querySelectorAll('button.conversation-item')].find(b => b.querySelector('.conversation-item__title')?.textContent.includes(${JSON.stringify(title)}))`
|
||||
|
||||
// The sidebar mounts only the rows near its viewport (PERF-14). Scroll it a
|
||||
// screen at a time from the top, letting it re-window after each step, until
|
||||
// the row is mounted; then centre it and wait for the re-window again.
|
||||
async function revealConversation(title) {
|
||||
const list = "document.querySelector('.conversation-list')"
|
||||
const present = `!!(${rowSelectorByTitle(title)})`
|
||||
await js(`(() => { const l = ${list}; if (l) l.scrollTop = 0; return true; })()`)
|
||||
await settle()
|
||||
for (let step = 0; step < 200; step += 1) {
|
||||
await settle()
|
||||
if (await js(present)) break
|
||||
const moved = await js(`(() => { const l = ${list}; if (!l) return false; const before = l.scrollTop; l.scrollTop += Math.max(200, Math.round(l.clientHeight * 0.8)); return l.scrollTop !== before; })()`)
|
||||
if (!moved) break
|
||||
}
|
||||
await js(`(() => { ${rowSelectorByTitle(title)}?.scrollIntoView({ block: 'center' }); return true; })()`)
|
||||
}
|
||||
|
||||
async function clickConversation(title) {
|
||||
await focusWindow()
|
||||
const point = await js(`(() => {
|
||||
const e = ${rowSelectorByTitle(title)};
|
||||
if (!e) return null;
|
||||
e.scrollIntoView({ block: 'center' });
|
||||
const r = e.getBoundingClientRect();
|
||||
const x = Math.round(r.x + Math.min(r.width / 2, 80)), y = Math.round(r.y + r.height / 2);
|
||||
return { x, y, hit: e.contains(document.elementFromPoint(x, y)) };
|
||||
})()`)
|
||||
await revealConversation(title)
|
||||
let point
|
||||
for (let attempt = 0; attempt < 10 && !point?.hit; attempt += 1) {
|
||||
await settle()
|
||||
point = await js(`(() => {
|
||||
const e = ${rowSelectorByTitle(title)};
|
||||
if (!e) return null;
|
||||
const r = e.getBoundingClientRect();
|
||||
const x = Math.round(r.x + Math.min(r.width / 2, 80)), y = Math.round(r.y + r.height / 2);
|
||||
return { x, y, hit: e.contains(document.elementFromPoint(x, y)) };
|
||||
})()`)
|
||||
}
|
||||
if (!point?.hit) throw new Error(`Conversation row not clickable: ${title}`)
|
||||
await settle()
|
||||
win.webContents.sendInputEvent({ type: 'mouseMove', x: point.x, y: point.y })
|
||||
@@ -508,7 +529,7 @@ try {
|
||||
// Reload so the App loads the seeded history through its normal startup path.
|
||||
const reloadStart = performance.now()
|
||||
win.webContents.reload()
|
||||
await waitFor(`!!document.querySelector(${JSON.stringify(composer)}) && document.querySelectorAll('button.conversation-item').length >= ${Math.min(50, config.seedConversations)}`, 60_000, 'seeded sidebar')
|
||||
await waitFor(`!!document.querySelector(${JSON.stringify(composer)}) && (() => { const rows = document.querySelectorAll('button.conversation-item'); const entry = document.querySelector('.conversation-list [aria-setsize]'); const total = entry ? Number(entry.getAttribute('aria-setsize')) : rows.length; return total >= ${config.seedConversations} && rows.length >= ${Math.min(10, config.seedConversations)}; })()`, 60_000, 'seeded sidebar')
|
||||
report.startup.reloadWithSeedMs = performance.now() - reloadStart
|
||||
await applyCpuThrottle()
|
||||
await dismissStartupDialogs({ expect: false })
|
||||
|
||||
Reference in New Issue
Block a user