perf: move conversations into an external store and window the message timeline

This commit is contained in:
mesalogo
2026-10-03 02:53:51 +08:00
parent f571301764
commit fdf7d83b20
17 changed files with 2922 additions and 1065 deletions
+56 -28
View File
@@ -4455,7 +4455,7 @@ describe("App", () => {
expect(scrollTo).not.toHaveBeenCalled();
});
it("renders the latest 80 messages and preserves scroll when revealing earlier messages", async () => {
it("windows the latest 80 messages and preserves scroll when revealing earlier messages", async () => {
vi.mocked(api.conversations.list).mockResolvedValueOnce([
{
id: "00000000-0000-4000-8000-000000000421",
@@ -4474,25 +4474,28 @@ describe("App", () => {
const { container } = render(<App />);
expect(await screen.findByText("历史消息 160")).toBeInTheDocument();
// The first paint shows only the trailing messages; the rest of the batch
// follows in steps.
expect(container.querySelectorAll(".message").length).toBeLessThanOrEqual(20);
// Only the rows near the bottom are mounted (PERF-14); a spacer stands in
// for the rest of the 80-message batch.
const mounted = container.querySelectorAll(".message").length;
expect(mounted).toBeGreaterThan(0);
expect(mounted).toBeLessThan(80);
expect(
screen.getByRole("button", { name: "加载更早的消息(还剩 81 条)" }),
).toBeInTheDocument();
await waitFor(() =>
expect(container.querySelectorAll(".message")).toHaveLength(80),
);
expect(screen.queryByText("历史消息 080")).not.toBeInTheDocument();
const chat = container.querySelector<HTMLElement>(".chat");
if (!chat) {
throw new Error("Missing chat scroll container");
}
const topSpacerHeight = (): number =>
Number.parseFloat(
container.querySelector<HTMLElement>('[data-window-spacer="top"]')?.style.height ?? "0",
) || 0;
Object.defineProperties(chat, {
clientHeight: { configurable: true, value: 400 },
scrollHeight: {
configurable: true,
get: () => container.querySelectorAll(".message").length * 10,
get: () => container.querySelectorAll(".message").length * 10 + topSpacerHeight(),
},
scrollTop: {
configurable: true,
@@ -4507,24 +4510,26 @@ describe("App", () => {
}),
);
expect(container.querySelectorAll(".message")).toHaveLength(160);
expect(screen.getByText("历史消息 001")).toBeInTheDocument();
expect(screen.queryByText("历史消息 000")).not.toBeInTheDocument();
expect(chat.scrollTop).toBe(925);
expect(
screen.getByRole("button", { name: "加载更早的消息(还剩 1 条)" }),
).toBeInTheDocument();
expect(container.querySelectorAll(".message")).toHaveLength(mounted);
// The 80 earlier messages land above the reader, who keeps their place.
expect(chat.scrollTop).toBe(125 + 80 * 160);
fireEvent.click(
screen.getByRole("button", {
name: "加载更早的消息(还剩 1 条)",
}),
);
expect(container.querySelectorAll(".message")).toHaveLength(161);
expect(screen.getByText("历史消息 000")).toBeInTheDocument();
expect(
screen.queryByRole("button", { name: /加载更早的消息/u }),
).not.toBeInTheDocument();
expect(screen.getByText("历史消息 000").closest("article")).toHaveFocus();
expect(container.querySelectorAll(".message").length).toBeLessThanOrEqual(mounted + 1);
});
it("catches up the first message batch in several small steps", async () => {
it("opens a long conversation in one bounded commit without catch-up steps", async () => {
vi.mocked(api.conversations.list).mockResolvedValueOnce([
{
id: "00000000-0000-4000-8000-000000000422",
@@ -4551,21 +4556,17 @@ describe("App", () => {
try {
expect(await screen.findByText("分步消息 160")).toBeInTheDocument();
record();
await waitFor(() =>
expect(container.querySelectorAll(".message")).toHaveLength(80),
await act(
() =>
new Promise<void>((resolve) => requestAnimationFrame(() => setTimeout(resolve, 20))),
);
record();
} finally {
observer.disconnect();
}
expect(counts[0]).toBeLessThanOrEqual(20);
expect(counts.at(-1)).toBe(80);
// More than one catch-up commit, each adding at most 20 messages.
expect(counts.filter((count) => count > 20 && count < 80).length).toBeGreaterThan(0);
counts.slice(1).forEach((count, index) => {
expect(count - (counts[index] ?? 0)).toBeLessThanOrEqual(20);
});
expect(counts).toHaveLength(1);
expect(counts[0]).toBeLessThan(80);
expect(
screen.getByRole("button", { name: "加载更早的消息(还剩 81 条)" }),
).toBeInTheDocument();
@@ -4820,6 +4821,27 @@ describe("App", () => {
expect(composer).toHaveValue("");
});
it("re-renders only the previous and next sidebar rows when switching conversations", async () => {
const conversations = Array.from({ length: 30 }, (_, c) => ({
id: `00000000-0000-4000-9${String(c).padStart(3, "0")}-000000000000`,
projectId, title: `Switch row ${c}`, updatedAt: 1_000 + c,
messages: [{
id: `00000000-0000-4000-9${String(c).padStart(3, "0")}-000000000001`,
role: "assistant" as const, content: `Switch body ${c}`, createdAt: 1_000 + c, state: "complete" as const,
}],
}));
vi.mocked(api.conversations.list).mockResolvedValueOnce(conversations);
render(<App />);
fireEvent.click((await screen.findByText("Switch row 3")).closest("button")!);
await screen.findByText("Switch body 3");
sidebarRenderProbes.row.mockClear();
fireEvent.click(screen.getByText("Switch row 7").closest("button")!);
await screen.findByText("Switch body 7");
expect(document.querySelector(".conversation-item--active")).toHaveTextContent("Switch row 7");
// Each row renders one title marquee; unrelated rows must be skipped.
expect(sidebarRenderProbes.row.mock.calls.length).toBeLessThanOrEqual(4);
});
it("isolates history and task strips while typing during a running request", async () => {
const conversationId = crypto.randomUUID();
vi.mocked(api.conversations.list).mockResolvedValueOnce([{
@@ -4929,7 +4951,13 @@ describe("App", () => {
const firstPane = container.querySelector<HTMLElement>(
`[data-conversation-id="${firstConversationId}"]`,
);
expect(firstPane?.querySelectorAll(".message")).toHaveLength(160);
expect(
screen.getByRole("button", { name: "加载更早的消息(还剩 1 条)" }),
).toBeInTheDocument();
// Windowed (PERF-14): the mounted rows stay bounded and keep their nodes.
const mountedRows = firstPane?.querySelectorAll(".message").length ?? 0;
expect(mountedRows).toBeGreaterThan(0);
expect(mountedRows).toBeLessThan(80);
const firstChat = firstPane?.querySelector<HTMLElement>(".chat");
if (!firstChat) {
throw new Error("Missing first chat scroll container");
@@ -4951,7 +4979,7 @@ describe("App", () => {
target: { value: "第一段会话草稿" },
});
expect(messageRenderProbe).not.toHaveBeenCalled();
expect(firstPane?.querySelectorAll(".message")).toHaveLength(160);
expect(firstPane?.querySelectorAll(".message")).toHaveLength(mountedRows);
fireEvent.click(screen.getByLabelText("添加附件"));
expect(await screen.findByText(draftAttachment.name)).toBeInTheDocument();
@@ -4964,7 +4992,7 @@ describe("App", () => {
target: { value: "第二段会话草稿" },
});
expect(messageRenderProbe).not.toHaveBeenCalled();
expect(firstPane?.querySelectorAll(".message")).toHaveLength(160);
expect(firstPane?.querySelectorAll(".message")).toHaveLength(mountedRows);
const paneOrder = (): string[] =>
[...container.querySelectorAll<HTMLElement>(".chat-history-pane")].map(
(pane) => pane.dataset.conversationId ?? "",
@@ -4972,14 +5000,14 @@ describe("App", () => {
const warmPaneOrder = paneOrder();
fireEvent.click(screen.getByText("第一段长会话").closest("button")!);
expect(await screen.findByText("第一段历史 001")).toBeInTheDocument();
expect(await screen.findByText("第一段历史 160")).toBeInTheDocument();
// Chromium resets scrollTop when React moves a kept pane; jsdom does not.
expect(paneOrder()).toEqual(warmPaneOrder);
const restoredFirstPane = container.querySelector<HTMLElement>(
`[data-conversation-id="${firstConversationId}"]`,
);
expect(restoredFirstPane).toBe(firstPane);
expect(restoredFirstPane?.querySelectorAll(".message")).toHaveLength(160);
expect(restoredFirstPane?.querySelectorAll(".message")).toHaveLength(mountedRows);
expect(restoredFirstPane?.querySelector(".chat")).toBe(firstChat);
expect(firstChat.scrollTop).toBe(225);
expect(reasoningDetails).toHaveAttribute("open");
+156 -856
View File
File diff suppressed because it is too large Load Diff
+378
View File
@@ -0,0 +1,378 @@
import { act, cleanup, fireEvent, render } from '@testing-library/react'
import type { ComponentProps } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ChatHistoryPane, type ChatScrollSnapshot } from './ChatHistoryPane'
import type { Message } from './ChatTimeline'
import type { Conversation } from './chat-conversation'
import { clearRowHeightCache, maxRenderedMessageCount } from './chat-message-window'
vi.mock('./MarkdownRenderer', () => ({
MarkdownRenderer: ({ children }: { children: string }) => <span>{children}</span>
}))
// --- A tiny fake layout: jsdom has none. ------------------------------------
// The scroll container is 400 px tall at viewport top 0. Children of the
// message list stack vertically: rows take their height from `rowHeights`
// (default 100), spacers their inline height, the "load earlier" button 30.
const viewport = 400
let rowHeights = new Map<string, number>()
let layoutEnabled = true
function chatOf(element: Element): HTMLElement | null {
return element.closest<HTMLElement>('.chat')
}
function childHeight(child: HTMLElement): number {
if (child.classList.contains('message-window-row')) {
return rowHeights.get(child.dataset.messageId ?? '') ?? 100
}
if (child.classList.contains('message-window-spacer')) {
return Number.parseFloat(child.style.height) || 0
}
if (child.classList.contains('load-earlier-messages')) return 30
return 0
}
function contentHeight(chat: HTMLElement): number {
const list = chat.querySelector<HTMLElement>('.message-list')
return list ? [...list.children].reduce((sum, child) => sum + childHeight(child as HTMLElement), 0) : 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 fakeRect(this: HTMLElement): DOMRect {
if (!layoutEnabled) return rect(0, 0)
if (this.classList.contains('chat')) return rect(0, viewport)
const chat = chatOf(this)
const list = chat?.querySelector<HTMLElement>('.message-list')
if (!chat || !list) return rect(0, 0)
const item = this.parentElement === list ? this : this.closest<HTMLElement>('.message-window-row')
if (!item || item.parentElement !== list) 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 - chat.scrollTop, childHeight(item))
}
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() }
/** Delivers entries for the observed elements matching `filter`. */
fire(filter: (element: Element) => boolean = () => true): void {
const entries = [...this.observed].filter(filter).map((target) => ({ target }) as ResizeObserverEntry)
this.callback(entries, this as unknown as ResizeObserver)
}
}
function latestObserver(): FakeResizeObserver {
const observer = FakeResizeObserver.instances.at(-1)
if (!observer) throw new Error('Missing ResizeObserver')
return observer
}
function installScrollContainer(chat: HTMLElement): void {
let scrollTop = 0
const max = (): number => Math.max(0, contentHeight(chat) - viewport)
Object.defineProperties(chat, {
clientHeight: { configurable: true, get: () => viewport },
scrollHeight: { configurable: true, get: () => contentHeight(chat) },
scrollTop: {
configurable: true,
get: () => scrollTop,
set: (value: number) => { scrollTop = Math.min(Math.max(0, value), max()) }
}
})
chat.scrollTo = vi.fn((options?: ScrollToOptions | number) => {
if (typeof options === 'object' && options.top !== undefined) chat.scrollTop = options.top
}) as typeof chat.scrollTo
}
// Rows are measured, then the pane re-lays out with the real heights.
function measure(): void {
act(() => latestObserver().fire())
}
function makeMessages(count: number, prefix = 'm'): Message[] {
return Array.from({ length: count }, (_, index) => ({
id: `${prefix}${index}`,
role: index % 2 === 0 ? 'user' : 'assistant',
content: `message ${index}`,
createdAt: 1_775_000_000_000 + index,
state: 'complete'
}))
}
function conversationOf(messages: Message[], id = 'conversation-1'): Conversation {
return { id, title: 'Long', updatedAt: 1, messages } as unknown as Conversation
}
type PaneProps = ComponentProps<typeof ChatHistoryPane>
function baseProps(messages: Message[], overrides: Partial<PaneProps> = {}): PaneProps {
return {
active: true,
artifactById: new Map(),
conversation: conversationOf(messages),
conversationHtmlRenderingEnabled: false,
locale: 'zh-CN',
onCopyMessage: vi.fn(async () => true),
onDownloadImage: vi.fn(),
onEditImage: vi.fn(),
onOpenCitationContext: vi.fn(async () => undefined),
onOpenCitationSource: vi.fn(async () => undefined),
onOpenImage: vi.fn(),
onOpenImageModelSettings: vi.fn(),
onReselectImageSources: vi.fn(),
onRespondApproval: vi.fn(async () => undefined),
onRespondQuestion: vi.fn(async () => undefined),
onRetry: vi.fn(),
onScrollSnapshotChange: vi.fn(),
onSetInput: vi.fn(),
onVisibleMessageCountChange: vi.fn(),
quickActions: [],
visibleMessageCount: messages.length,
...overrides
}
}
function setup(messages: Message[], overrides: Partial<PaneProps> = {}) {
// The scroll container exists only after the first render, so install the
// fake geometry right after mounting and lay out once more.
const props = baseProps(messages, overrides)
const view = render(<ChatHistoryPane {...props} />)
const chat = view.container.querySelector<HTMLElement>('.chat')!
installScrollContainer(chat)
return { ...view, chat, props }
}
function frame(): Promise<void> {
return act(() => new Promise<void>((resolve) => requestAnimationFrame(() => resolve())))
}
function renderedIds(container: HTMLElement): string[] {
return [...container.querySelectorAll<HTMLElement>('.message-window-row')].map((row) => row.dataset.messageId ?? '')
}
function rowTop(container: HTMLElement, id: string): number {
return container.querySelector<HTMLElement>(`[data-message-id="${id}"]`)!.getBoundingClientRect().top
}
beforeEach(() => {
rowHeights = new Map()
layoutEnabled = true
clearRowHeightCache()
FakeResizeObserver.instances = []
vi.stubGlobal('ResizeObserver', FakeResizeObserver)
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(fakeRect)
})
afterEach(() => {
cleanup()
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
describe('ChatHistoryPane windowing', () => {
it('keeps the DOM bounded for a 2,000-message conversation at any scroll position', () => {
const messages = makeMessages(2_000)
const { container, chat } = setup(messages)
measure()
const atBottom = container.querySelectorAll('.message').length
expect(atBottom).toBeGreaterThan(0)
expect(atBottom).toBeLessThanOrEqual(maxRenderedMessageCount)
expect(renderedIds(container).at(-1)).toBe('m1999')
// The spacer stands in for the rest, so the full history is scrollable.
expect(chat.scrollHeight).toBeGreaterThanOrEqual(2_000 * 100)
for (const scrollTop of [100_000, 0, 150_000]) {
act(() => {
chat.scrollTop = scrollTop
fireEvent.scroll(chat)
})
measure()
const count = container.querySelectorAll('.message').length
expect(count).toBeLessThanOrEqual(maxRenderedMessageCount + 1)
const visible = renderedIds(container).filter((id) => {
const top = rowTop(container, id)
return top < viewport && top + 100 > 0
})
expect(visible.length).toBeGreaterThan(0)
}
expect(renderedIds(container)).toContain('m1500')
})
it('does not observe or measure rows while the pane is hidden', () => {
const messages = makeMessages(200)
const props = baseProps(messages, { active: false })
const { container } = render(<ChatHistoryPane {...props} />)
expect(FakeResizeObserver.instances).toHaveLength(0)
expect(container.querySelectorAll('.message').length).toBeLessThanOrEqual(maxRenderedMessageCount)
})
it('follows a growing streaming reply once per frame while pinned', async () => {
const messages = makeMessages(300)
const { chat, container, rerender, props } = setup(messages)
measure()
await frame()
act(() => { chat.scrollTop = chat.scrollHeight; fireEvent.scroll(chat) })
const scrollTo = vi.mocked(chat.scrollTo)
scrollTo.mockClear()
let streaming = [...messages, { id: 'reply', role: 'assistant', content: 'a', createdAt: 2, state: 'streaming' } as Message]
for (const chunk of [' b', ' c', ' d']) {
const last = streaming.at(-1)!
streaming = [...streaming.slice(0, -1), { ...last, content: last.content + chunk }]
rowHeights.set('reply', (rowHeights.get('reply') ?? 100) + 120)
rerender(<ChatHistoryPane {...props} conversation={conversationOf(streaming)} visibleMessageCount={streaming.length} />)
}
await frame()
expect(scrollTo.mock.calls.length).toBeLessThanOrEqual(2)
expect(scrollTo).toHaveBeenLastCalledWith({ top: chat.scrollHeight, behavior: 'auto' })
expect(chat.scrollTop + viewport).toBe(chat.scrollHeight)
expect(renderedIds(container).at(-1)).toBe('reply')
// The last row keeps growing (layout settles): still pinned.
rowHeights.set('reply', 900)
act(() => latestObserver().fire((element) => (element as HTMLElement).dataset?.messageId === 'reply'))
expect(chat.scrollTop + viewport).toBe(chat.scrollHeight)
})
it('keeps the reader in place while rows above change height or mount', () => {
const messages = makeMessages(2_000)
const { chat, container } = setup(messages)
measure()
act(() => { chat.scrollTop = 120_050; fireEvent.scroll(chat) })
measure()
const anchorId = renderedIds(container).find((id) => rowTop(container, id) + 100 > 0)!
const before = rowTop(container, anchorId)
// A row above the anchor (rendered overscan) grows, e.g. an image loads.
const above = renderedIds(container)[0]!
expect(above).not.toBe(anchorId)
rowHeights.set(above, 400)
act(() => latestObserver().fire((element) => (element as HTMLElement).dataset?.messageId === above))
expect(rowTop(container, anchorId)).toBe(before)
// Scrolling up mounts new rows above with measured heights that differ
// from the estimate; what the reader sees does not jump.
for (let index = 0; index < 2_000; index += 1) {
if (index % 3 === 0) rowHeights.set(`m${index}`, 250)
}
act(() => { chat.scrollTop -= 3_000; fireEvent.scroll(chat) })
const anchorAfterScroll = renderedIds(container).find((id) => rowTop(container, id) + (rowHeights.get(id) ?? 100) > 0)!
const topAfterScroll = rowTop(container, anchorAfterScroll)
measure()
expect(rowTop(container, anchorAfterScroll)).toBe(topAfterScroll)
})
it('keeps the reader in place when earlier messages are revealed', () => {
const messages = makeMessages(161)
const onVisibleMessageCountChange = vi.fn()
const { chat, container, rerender, props } = setup(messages, { visibleMessageCount: 80, onVisibleMessageCountChange })
measure()
act(() => { chat.scrollTop = 5_000; fireEvent.scroll(chat) })
measure()
const anchorId = renderedIds(container).find((id) => rowTop(container, id) + 100 > 0)!
const before = rowTop(container, anchorId)
fireEvent.click(container.querySelector('.load-earlier-messages')!)
expect(onVisibleMessageCountChange).toHaveBeenCalledWith('conversation-1', 160)
rerender(<ChatHistoryPane {...props} visibleMessageCount={160} />)
measure()
expect(rowTop(container, anchorId)).toBe(before)
expect(container.querySelector('.load-earlier-messages')).toHaveTextContent('1')
fireEvent.click(container.querySelector('.load-earlier-messages')!)
rerender(<ChatHistoryPane {...props} visibleMessageCount={240} />)
expect(container.querySelector('.load-earlier-messages')).toBeNull()
// Focus moves to the first message, which is mounted for that.
expect(container.querySelector('[data-message-id="m0"] article')).toHaveFocus()
expect(container.querySelectorAll('.message').length).toBeLessThanOrEqual(maxRenderedMessageCount + 1)
})
it('renders an off-screen note target on demand, then scrolls to it', async () => {
const messages = makeMessages(2_000)
const scrollIntoView = vi.fn()
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', { configurable: true, value: scrollIntoView })
const { container, rerender, props } = setup(messages, { visibleMessageCount: 80 })
measure()
expect(renderedIds(container)).not.toContain('m42')
rerender(
<ChatHistoryPane
{...props}
noteMessageNavigation={{ conversationId: 'conversation-1', messageId: 'm42', requestId: 1 }}
/>
)
expect(renderedIds(container)).toContain('m42')
await frame()
const target = container.querySelector<HTMLElement>('[data-message-id="m42"] article')!
expect(scrollIntoView).toHaveBeenCalledWith({ block: 'center' })
expect(scrollIntoView.mock.contexts.at(-1)).toBe(target)
expect(target).toHaveFocus()
expect(container.querySelectorAll('.message').length).toBeLessThanOrEqual(maxRenderedMessageCount + 1)
Reflect.deleteProperty(HTMLElement.prototype, 'scrollIntoView')
})
it('restores the reader position across hide/show and from a saved snapshot', () => {
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) })
measure()
act(() => { chat.scrollTop += 10; fireEvent.scroll(chat) })
const scrollTop = chat.scrollTop
const anchorId = renderedIds(container).find((id) => rowTop(container, id) + 100 > 0)!
const anchorTop = rowTop(container, anchorId)
rerender(<ChatHistoryPane {...props} active={false} />)
// Browsers may drop scrollTop of a hidden pane.
layoutEnabled = false
chat.scrollTop = 0
layoutEnabled = true
rerender(<ChatHistoryPane {...props} active />)
expect(chat.scrollTop).toBe(scrollTop)
expect(rowTop(container, anchorId)).toBe(anchorTop)
unmount()
const snapshot = onScrollSnapshotChange.mock.calls.at(-1)?.[1] as ChatScrollSnapshot
expect(snapshot).toMatchObject({ pinnedToBottom: false, scrollTop, anchorMessageId: anchorId })
// A remounted pane (keep-alive eviction) restores from the snapshot.
const remounted = render(<ChatHistoryPane {...baseProps(messages, { scrollSnapshot: snapshot })} />)
expect(renderedIds(remounted.container)).toContain(anchorId)
expect(remounted.container.querySelectorAll('.message').length).toBeLessThanOrEqual(maxRenderedMessageCount)
})
it('returns to the bottom when the user sends a message', async () => {
const messages = makeMessages(500)
const { chat, container, rerender, props } = setup(messages)
measure()
act(() => { chat.scrollTop = 1_000; fireEvent.scroll(chat) })
measure()
expect(container.querySelector('.chat-scroll-to-bottom')).not.toBeNull()
const next = [...messages, { id: 'sent', role: 'user', content: 'new', createdAt: 3, state: 'complete' } as Message]
rerender(<ChatHistoryPane {...props} conversation={conversationOf(next)} visibleMessageCount={next.length} />)
await frame()
expect(renderedIds(container).at(-1)).toBe('sent')
expect(chat.scrollTop + viewport).toBe(chat.scrollHeight)
})
it('keeps messageIndex-based props for windowed rows', () => {
const messages = makeMessages(300)
messages[299] = { ...messages[299]!, state: 'error', content: 'failed' }
messages[298] = { ...messages[298]!, role: 'user', content: 'prompt' }
const { container } = setup(messages)
measure()
expect(container.querySelector('[data-message-id="m299"]')).toHaveAttribute('data-message-index', '299')
expect(container.querySelector('[data-message-id="m299"]')?.textContent).toContain('重新编辑')
})
})
+448 -163
View File
@@ -4,7 +4,6 @@ import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
type ReactNode,
@@ -19,21 +18,26 @@ import type {
import type { ImageOperation } from "../../shared/image-generation-contracts";
import { ChatTimeline, type ImageViewerItem, type Message } from "./ChatTimeline";
import { isUnusedConversation, type Conversation } from "./chat-conversation";
import {
buildSegments,
maxRenderedMessageCount,
overscanFor,
rangeAround,
rangeCovers,
rowAtOffset,
rowHeightsFor,
tailRange,
type MessageRowHeights,
type MessageWindowRange,
} from "./chat-message-window";
import type { TimeFormatLocale } from "./time-format";
/**
* Messages added to the scrollable history per "load earlier" step. Only the
* rows near the viewport are mounted (PERF-14), so this bounds how far the
* reader can scroll, not how much DOM exists.
*/
export const messageRenderBatchSize = 80;
/**
* A freshly mounted pane first renders only this many trailing messages, which
* fill the viewport, and renders the rest of its batch after the first paint.
* Opening a long conversation then costs roughly a quarter of the Markdown and
* layout work before anything is shown.
*/
export const initialMessageRenderCount = 20;
/**
* The rest of the batch is caught up in steps of this many messages, one step
* per frame, so no single catch-up commit becomes a long frame.
*/
export const catchUpMessageRenderStep = 20;
const chatBottomProximity = 96;
type ChatQuickAction = {
@@ -45,8 +49,88 @@ type ChatQuickAction = {
export type ChatScrollSnapshot = {
pinnedToBottom: boolean;
scrollTop: number;
/**
* First row visible at the viewport top and its offset from the viewport
* top. Restoring by row survives estimated spacer heights changing.
*/
anchorMessageId?: string;
anchorOffset?: number;
};
type ViewState =
| { mode: "bottom" }
| { mode: "range"; start: number; end: number; toEnd: boolean };
const bottomView: ViewState = { mode: "bottom" };
type ScrollAnchor = { id: string; top: number };
type PaneModel = {
messages: Message[];
windowStart: number;
range: MessageWindowRange;
view: ViewState;
};
function sizeAtFor(
heights: MessageRowHeights,
messages: readonly Message[],
): (index: number) => number {
return (index) => heights.size(messages[index]?.id ?? "");
}
function viewAround(
heights: MessageRowHeights,
messages: readonly Message[],
windowStart: number,
anchorIndex: number,
anchorOffset: number,
overscan: number = overscanFor(heights.viewport),
): ViewState {
const viewportHeight = heights.viewport;
const range = rangeAround({
sizeAt: sizeAtFor(heights, messages),
windowStart,
count: messages.length,
viewportHeight,
overscan,
anchorIndex,
anchorOffset,
});
return {
mode: "range",
start: range.start,
end: range.end,
toEnd: range.end >= messages.length,
};
}
/** Margin past the viewport that must stay mounted before re-windowing. */
const scrollRewindowMargin = 160;
/** Margin mounted on each side when the scroll position re-windows. */
function scrollRenderOverscan(viewportHeight: number): number {
return Math.max(800, Math.round(viewportHeight * 1.5));
}
function hasLayout(element: HTMLElement): boolean {
return element.getBoundingClientRect().height > 0;
}
/**
* 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;
const content = container.querySelector<HTMLElement>(".chat-content");
if (!content) return top;
const padding = Number.parseFloat(getComputedStyle(content).paddingTop);
return Number.isFinite(padding) ? top + padding : top;
}
export const ChatHistoryPane = memo(function ChatHistoryPane({
onAddToNote,
noteMessageNavigation,
@@ -115,101 +199,172 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
}): React.JSX.Element {
const { t } = useTranslation("app");
const headingId = `chat-heading-${conversation.id}`;
const messages = conversation.messages;
const total = messages.length;
// Measured row heights outlive the pane, so a conversation reopened after
// keep-alive eviction lays out with real heights.
const heights = rowHeightsFor(conversation.id);
const scrollRef = useRef<HTMLElement>(null);
const contextRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const pinnedToBottomRef = useRef(scrollSnapshot?.pinnedToBottom ?? true);
const latestScrollSnapshotRef = useRef(scrollSnapshot);
const restorePendingRef = useRef(true);
const wasActiveRef = useRef(false);
const followFrameRef = useRef<number | undefined>(undefined);
const anchorRef = useRef<ScrollAnchor | undefined>(undefined);
const prependScrollPositionRef = useRef<
| {
scrollHeight: number;
scrollTop: number;
}
| undefined
{ scrollHeight: number; scrollTop: number; windowStart: number } | undefined
>(undefined);
// Set when measured heights re-laid out the spacers; a pinned pane then
// corrects in the same frame instead of waiting for the follow frame.
const measuredLayoutRef = useRef(false);
const finalRevealedMessageIdRef = useRef<string | undefined>(undefined);
const navigationScrolledRef = useRef<number | undefined>(undefined);
const messageArticleRefs = useRef(new Map<string, HTMLElement>());
const previousMessageCountRef = useRef(conversation.messages.length);
const rowElementsRef = useRef(new Map<string, HTMLElement>());
const observerRef = useRef<ResizeObserver | undefined>(undefined);
const previousMessageCountRef = useRef(total);
const modelRef = useRef<PaneModel | undefined>(undefined);
const [showScrollToBottom, setShowScrollToBottom] = useState(
scrollSnapshot ? !scrollSnapshot.pinnedToBottom : false,
);
// Only a pane that opens at the bottom renders in steps. A saved scroll
// position or a note target refers to the full batch, so those render it
// at once. `undefined` means the full batch is rendered.
const [initialRenderLimit, setInitialRenderLimit] = useState<
number | undefined
>(() =>
(scrollSnapshot === undefined || scrollSnapshot.pinnedToBottom) &&
noteMessageNavigation?.conversationId !== conversation.id &&
Math.min(visibleMessageCount, conversation.messages.length) >
initialMessageRenderCount
? initialMessageRenderCount
: undefined,
// 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
// after the final "load earlier" step.
const [keptMessageIds, setKeptMessageIds] = useState<readonly string[]>([]);
const navigationTarget =
noteMessageNavigation?.conversationId === conversation.id
? noteMessageNavigation
: undefined;
const navigationIndex = navigationTarget
? messages.findIndex((message) => message.id === navigationTarget.messageId)
: -1;
// The scrollable history: the trailing `visibleMessageCount` messages, plus
// anything up to a note navigation target.
let windowStart = Math.max(0, total - visibleMessageCount);
if (navigationIndex >= 0 && navigationIndex < windowStart) {
windowStart = navigationIndex;
}
const hiddenMessageCount = windowStart;
const [view, setView] = useState<ViewState>(() => {
if (navigationIndex >= 0) {
return viewAround(heights, messages, windowStart, navigationIndex, heights.viewport / 3);
}
if (scrollSnapshot && !scrollSnapshot.pinnedToBottom) {
const anchorIndex = scrollSnapshot.anchorMessageId
? messages.findIndex((message) => message.id === scrollSnapshot.anchorMessageId)
: -1;
if (anchorIndex >= windowStart) {
return viewAround(heights, messages, windowStart, anchorIndex, scrollSnapshot.anchorOffset ?? 0);
}
const row = rowAtOffset(sizeAtFor(heights, messages), windowStart, total, scrollSnapshot.scrollTop);
return viewAround(heights, messages, windowStart, row.index, row.top - scrollSnapshot.scrollTop);
}
return bottomView;
});
// A new note navigation request renders the target before scrolling to it.
const [handledNavigation, setHandledNavigation] = useState(
navigationIndex >= 0 ? navigationTarget?.requestId : undefined,
);
const [steppedVisibleMessageCount] = useState(visibleMessageCount);
// Stepping only covers the batch the pane opened with. A changed batch
// ("load earlier", note navigation) or a note target for this pane ends the
// stepping and renders the full batch at once. Adjusting state during
// render avoids committing a partial step first.
const stopStepping =
initialRenderLimit !== undefined &&
(visibleMessageCount !== steppedVisibleMessageCount ||
noteMessageNavigation?.conversationId === conversation.id);
if (stopStepping) setInitialRenderLimit(undefined);
const steppingLimit = stopStepping ? undefined : initialRenderLimit;
const initialRenderScrollRef = useRef<
{ scrollHeight: number; scrollTop: number } | undefined
>(undefined);
const initialRenderTarget = Math.min(
visibleMessageCount,
conversation.messages.length,
);
useEffect(() => {
if (steppingLimit === undefined) return;
// Grow by one step once the previous step has been painted. A plain
// update, not a transition: streaming rows commit synchronously many times
// per second and would keep restarting a transition (see
// live-message-store).
let timer: ReturnType<typeof setTimeout> | undefined;
const frame = requestAnimationFrame(() => {
timer = setTimeout(() => {
const scrollContainer = scrollRef.current;
initialRenderScrollRef.current = scrollContainer
? {
scrollHeight: scrollContainer.scrollHeight,
scrollTop: scrollContainer.scrollTop,
}
: undefined;
const nextLimit = steppingLimit + catchUpMessageRenderStep;
setInitialRenderLimit(
nextLimit >= initialRenderTarget ? undefined : nextLimit,
);
}, 0);
if (
navigationTarget &&
navigationIndex >= 0 &&
navigationTarget.requestId !== handledNavigation
) {
setHandledNavigation(navigationTarget.requestId);
setView(viewAround(heights, messages, windowStart, navigationIndex, heights.viewport / 3));
}
// A message the user just sent brings the view back to the bottom.
const [seenMessages, setSeenMessages] = useState(messages);
if (seenMessages !== messages) {
setSeenMessages(messages);
if (
messages.slice(seenMessages.length).some((message) => message.role === "user") &&
view.mode !== "bottom"
) {
setView(bottomView);
}
}
const sizeAt = sizeAtFor(heights, messages);
const viewportHeight = heights.viewport;
let range: MessageWindowRange;
if (view.mode === "bottom") {
range = tailRange({
sizeAt,
windowStart,
count: total,
viewportHeight,
overscan: overscanFor(viewportHeight),
});
return () => {
cancelAnimationFrame(frame);
clearTimeout(timer);
};
}, [steppingLimit, initialRenderTarget]);
const renderedMessageCount =
steppingLimit === undefined
? visibleMessageCount
: Math.min(visibleMessageCount, steppingLimit);
const visibleMessageStartIndex = Math.max(
0,
conversation.messages.length - renderedMessageCount,
);
const visibleMessages = useMemo(
() => conversation.messages.slice(visibleMessageStartIndex),
[conversation.messages, visibleMessageStartIndex],
);
// The "load earlier" control reflects the real batch, not the partial step.
const hiddenMessageCount = Math.max(
0,
conversation.messages.length - visibleMessageCount,
);
} else {
const start = Math.min(Math.max(view.start, windowStart), total);
// A reader scrolled up near the end still sees rows appended below, up
// to a bound; the next scroll recomputes the range.
const end = view.toEnd
? Math.min(total, start + maxRenderedMessageCount * 2)
: Math.min(Math.max(view.end, start), total);
range = end > start
? { start, end }
: tailRange({ sizeAt, windowStart, count: total, viewportHeight, overscan: overscanFor(viewportHeight) });
}
const rendered: number[] = [];
for (let index = range.start; index < range.end; index += 1) rendered.push(index);
for (const id of keptMessageIds) {
const index = messages.findIndex((message) => message.id === id);
if (index >= windowStart && (index < range.start || index >= range.end)) {
rendered.push(index);
}
}
rendered.sort((left, right) => left - right);
const segments = buildSegments(heights, messages, windowStart, total, rendered);
useLayoutEffect(() => {
modelRef.current = { messages, windowStart, range, view };
});
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;
}, []);
/** Moves the scroll position so the anchor row stays where the reader saw it. */
const correctAnchor = 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;
}, []);
useLayoutEffect(() => {
const context = contextRef.current;
@@ -228,27 +383,62 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
};
measure();
if (typeof ResizeObserver !== 'function') return;
const observer = new ResizeObserver(() => {
// Hidden panes have no observer, so they never measure rows.
const observer = new ResizeObserver((entries) => {
measure();
if (restorePendingRef.current || !pinnedToBottomRef.current) return;
scrollContainer.scrollTo({ top: scrollContainer.scrollHeight, behavior: 'auto' });
let layoutChanged = false;
for (const entry of entries) {
const target = entry.target as HTMLElement;
if (target === scrollContainer) {
if (heights.setViewport(scrollContainer.clientHeight)) layoutChanged = true;
continue;
}
const id = target.dataset?.messageId;
if (!id) continue;
const height = target.getBoundingClientRect().height;
if (height > 0 && heights.set(id, height) === 'new') layoutChanged = true;
}
if (layoutChanged) {
measuredLayoutRef.current = true;
setMeasureVersion((version) => version + 1);
}
if (restorePendingRef.current) return;
if (pinnedToBottomRef.current) {
scrollToBottomNow(scrollContainer);
return;
}
// A row above the reader changed height (image, Mermaid, KaTeX).
correctAnchor();
captureAnchor();
});
observer.observe(context);
observer.observe(content);
return () => observer.disconnect();
}, [active]);
observer.observe(scrollContainer);
for (const row of rowElementsRef.current.values()) observer.observe(row);
observerRef.current = observer;
return () => {
observer.disconnect();
observerRef.current = undefined;
};
}, [active, captureAnchor, correctAnchor, heights, scrollToBottomNow]);
useEffect(() => {
if (!active || noteMessageNavigation?.conversationId !== conversation.id) return;
const element = messageArticleRefs.current.get(noteMessageNavigation.messageId);
if (!active || !navigationTarget) return;
if (navigationScrolledRef.current === navigationTarget.requestId) return;
const element = messageArticleRefs.current.get(navigationTarget.messageId);
if (!element) return;
const frame = requestAnimationFrame(() => {
navigationScrolledRef.current = navigationTarget.requestId;
pinnedToBottomRef.current = false;
element.scrollIntoView?.({ block: 'center' });
element.tabIndex = -1;
element.focus({ preventScroll: true });
// Keep the target mounted while it has focus, and remember the place.
setKeptMessageIds([navigationTarget.messageId]);
captureAnchor();
});
return () => cancelAnimationFrame(frame);
}, [active, conversation.id, noteMessageNavigation, visibleMessageCount]);
}, [active, captureAnchor, navigationTarget, view, visibleMessageCount]);
const handleArticleRef = useCallback(
(messageId: string, element: HTMLElement | null): void => {
@@ -261,6 +451,22 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
[],
);
const handleRowRef = useCallback(
(messageId: string, element: HTMLElement | null): void => {
const rows = rowElementsRef.current;
const previous = rows.get(messageId);
if (previous && previous !== element) {
observerRef.current?.unobserve(previous);
rows.delete(messageId);
}
if (element && previous !== element) {
rows.set(messageId, element);
observerRef.current?.observe(element);
}
},
[],
);
const saveScrollPosition = useCallback(
(scrollContainer: HTMLElement): boolean => {
const distanceFromBottom =
@@ -268,9 +474,11 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
scrollContainer.scrollTop -
scrollContainer.clientHeight;
const pinnedToBottom = distanceFromBottom <= chatBottomProximity;
const anchor = anchorRef.current;
latestScrollSnapshotRef.current = {
pinnedToBottom,
scrollTop: scrollContainer.scrollTop,
...(anchor ? { anchorMessageId: anchor.id, anchorOffset: anchor.top } : {}),
};
return pinnedToBottom;
},
@@ -296,26 +504,89 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
const updateScrollPosition = useCallback((): void => {
const scrollContainer = scrollRef.current;
if (!scrollContainer) {
const model = modelRef.current;
if (!scrollContainer || !model) {
return;
}
captureAnchor();
const atBottom = saveScrollPosition(scrollContainer);
pinnedToBottomRef.current = atBottom;
setShowScrollToBottom(!atBottom);
}, [saveScrollPosition]);
if (heights.setViewport(scrollContainer.clientHeight)) {
setMeasureVersion((version) => version + 1);
}
const { messages: current, windowStart: start, range: currentRange } = model;
let next: ViewState | undefined;
if (atBottom) {
next = bottomView;
} else {
const topSpacer = listRef.current?.querySelector<HTMLElement>('[data-window-spacer="top"]');
if (!topSpacer || !hasLayout(scrollContainer)) {
// No layout (hidden pane, tests): keep the mounted rows.
next = model.view.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);
// 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,
);
if (needed.mode === "range" && !rangeCovers(currentRange, needed)) {
next = viewAround(
heights, current, start, row.index, row.top - viewportOffset,
scrollRenderOverscan(heights.viewport),
);
} else if (model.view.mode === "bottom") {
next = {
mode: "range",
start: currentRange.start,
end: currentRange.end,
toEnd: currentRange.end >= current.length,
};
}
}
}
if (next) setView(next);
// Keep the focused row mounted while it scrolls out of the range.
const focused = document.activeElement;
const focusedRow = focused instanceof Element
? focused.closest<HTMLElement>(".message-window-row")
: null;
const focusedId =
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]);
// Runs before the positioning effect below, so a sent message pins first.
useLayoutEffect(() => {
const previousMessageCount = previousMessageCountRef.current;
if (
conversation.messages
messages
.slice(previousMessageCount)
.some((message) => message.role === "user")
) {
pinnedToBottomRef.current = true;
}
previousMessageCountRef.current = conversation.messages.length;
}, [conversation.messages]);
previousMessageCountRef.current = messages.length;
}, [messages]);
// Positions the scroll container after every commit: restore on
// activation, keep the reader's row in place while rows mount, unmount or
// change size, or follow the bottom.
useLayoutEffect(() => {
if (!active) {
wasActiveRef.current = false;
@@ -327,11 +598,38 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
}
const activated = !wasActiveRef.current;
wasActiveRef.current = true;
const measuredLayout = measuredLayoutRef.current;
measuredLayoutRef.current = false;
if (
navigationTarget &&
navigationIndex >= 0 &&
navigationScrolledRef.current !== navigationTarget.requestId
) {
// The navigation effect scrolls the target into view; do not follow
// the bottom or restore an older position meanwhile.
restorePendingRef.current = false;
pinnedToBottomRef.current = false;
return;
}
const restore = (snapshot: ChatScrollSnapshot): void => {
pinnedToBottomRef.current = false;
scrollContainer.scrollTop = snapshot.scrollTop;
const anchorElement = snapshot.anchorMessageId
? rowElementsRef.current.get(snapshot.anchorMessageId)
: undefined;
if (anchorElement && hasLayout(scrollContainer)) {
const delta =
anchorElement.getBoundingClientRect().top -
scrollContainer.getBoundingClientRect().top -
(snapshot.anchorOffset ?? 0);
if (Math.abs(delta) >= 0.5) scrollContainer.scrollTop += delta;
}
captureAnchor();
};
if (restorePendingRef.current) {
restorePendingRef.current = false;
if (scrollSnapshot && !scrollSnapshot.pinnedToBottom) {
pinnedToBottomRef.current = false;
scrollContainer.scrollTop = scrollSnapshot.scrollTop;
restore(scrollSnapshot);
return;
}
} else if (activated && !pinnedToBottomRef.current) {
@@ -339,18 +637,35 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
// them out; restore the reader's last position instead of the top.
const saved = latestScrollSnapshotRef.current;
if (saved && !saved.pinnedToBottom) {
scrollContainer.scrollTop = saved.scrollTop;
restore(saved);
return;
}
}
const prepend = prependScrollPositionRef.current;
if (prepend && prepend.windowStart !== windowStart) {
// Earlier messages were added above everything the reader sees.
prependScrollPositionRef.current = undefined;
scrollContainer.scrollTop =
prepend.scrollTop + (scrollContainer.scrollHeight - prepend.scrollHeight);
const finalRevealedMessageId = finalRevealedMessageIdRef.current;
finalRevealedMessageIdRef.current = undefined;
if (finalRevealedMessageId) {
messageArticleRefs.current
.get(finalRevealedMessageId)
?.focus({ preventScroll: true });
}
if (!pinnedToBottomRef.current) {
captureAnchor();
return;
}
}
if (!pinnedToBottomRef.current) {
correctAnchor();
captureAnchor();
return;
}
if (activated) {
scrollContainer.scrollTo({
top: scrollContainer.scrollHeight,
behavior: "auto",
});
if (activated || measuredLayout) {
scrollToBottomNow(scrollContainer);
return;
}
// Streaming updates commit many times per frame. Reading scrollHeight here
@@ -365,9 +680,9 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
if (!container || !pinnedToBottomRef.current) {
return;
}
container.scrollTo({ top: container.scrollHeight, behavior: "auto" });
scrollToBottomNow(container);
});
}, [active, conversation.messages, scrollSnapshot, visibleMessageCount]);
});
useEffect(
() => () => {
@@ -379,55 +694,21 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
[],
);
useLayoutEffect(() => {
const previous = prependScrollPositionRef.current;
if (!previous) {
return;
}
prependScrollPositionRef.current = undefined;
const scrollContainer = scrollRef.current;
if (!scrollContainer) {
return;
}
scrollContainer.scrollTop =
previous.scrollTop +
(scrollContainer.scrollHeight - previous.scrollHeight);
const finalRevealedMessageId = finalRevealedMessageIdRef.current;
finalRevealedMessageIdRef.current = undefined;
if (finalRevealedMessageId) {
messageArticleRefs.current
.get(finalRevealedMessageId)
?.focus({ preventScroll: true });
}
}, [visibleMessageCount]);
// Each catch-up step lands above what the reader sees: stay at the bottom,
// or keep the reader's place if they already scrolled up.
useLayoutEffect(() => {
const previous = initialRenderScrollRef.current;
initialRenderScrollRef.current = undefined;
const scrollContainer = scrollRef.current;
if (!previous || !scrollContainer) return;
scrollContainer.scrollTop = pinnedToBottomRef.current
? scrollContainer.scrollHeight
: previous.scrollTop +
(scrollContainer.scrollHeight - previous.scrollHeight);
}, [initialRenderLimit]);
const revealEarlierMessages = useCallback((): void => {
setInitialRenderLimit(undefined);
const scrollContainer = scrollRef.current;
if (scrollContainer) {
prependScrollPositionRef.current = {
scrollHeight: scrollContainer.scrollHeight,
scrollTop: scrollContainer.scrollTop,
windowStart,
};
}
if (
visibleMessageCount + messageRenderBatchSize >=
conversation.messages.length
) {
finalRevealedMessageIdRef.current = conversation.messages[0]?.id;
if (visibleMessageCount + messageRenderBatchSize >= messages.length) {
const firstId = messages[0]?.id;
finalRevealedMessageIdRef.current = firstId;
// The control disappears; focus moves to the first message, which
// must be mounted for that.
if (firstId) setKeptMessageIds([firstId]);
}
onVisibleMessageCountChange(
conversation.id,
@@ -435,7 +716,8 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
);
}, [
conversation.id,
conversation.messages,
windowStart,
messages,
onVisibleMessageCountChange,
visibleMessageCount,
]);
@@ -447,6 +729,7 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
}
pinnedToBottomRef.current = true;
setShowScrollToBottom(false);
setView(bottomView);
const reduceMotion =
typeof window.matchMedia === "function" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
@@ -476,7 +759,7 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
onScroll={updateScrollPosition}
ref={handleScrollRef}
>
<div className="chat-content">
<div className="chat-content" ref={listRef}>
{isUnusedConversation(conversation) && (
<div className="welcome">
<div className="welcome__badge">
@@ -509,8 +792,8 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
hiddenMessageCount={hiddenMessageCount}
isUnusedConversation={isUnusedConversation(conversation)}
locale={locale}
messages={visibleMessages}
messageStartIndex={visibleMessageStartIndex}
messages={messages}
messageStartIndex={0}
onArticleRef={handleArticleRef}
onCopyMessage={onCopyMessage}
onAddToNote={onAddToNote}
@@ -522,13 +805,15 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
onRespondQuestion={onRespondQuestion}
onRetry={onRetry}
onRevealEarlier={revealEarlierMessages}
onRowRef={handleRowRef}
renderAssistantHtml={conversationHtmlRenderingEnabled}
retryContent={
conversation.messages.at(-2)?.role === "user"
? conversation.messages.at(-2)?.content
messages.at(-2)?.role === "user"
? messages.at(-2)?.content
: undefined
}
totalMessageCount={conversation.messages.length}
segments={segments}
totalMessageCount={total}
/>
</div>
</section>
+118 -1
View File
@@ -43,6 +43,8 @@ import { useLiveMessage } from './live-message-store'
import { formatTime, type TimeFormatLocale } from './time-format'
import { formatCompactTokens } from './token-format'
import { isCancelledMessage } from './message-terminal-status'
import type { MessageWindowSegment } from './chat-message-window'
export type ToolActivity = ConversationToolActivity
@@ -1343,6 +1345,39 @@ export const ChatMessageRow = memo(function ChatMessageRow(
return <ChatMessageRowView {...props} message={useLiveMessage(props.message)} />
})
type ChatMessageWindowRowProps = ChatMessageRowProps & {
index: number
onRowRef?: (messageId: string, element: HTMLElement | null) => void
}
// A measured wrapper around one row of the windowed timeline. The wrapper
// holds the row's article and its compression markers, so its height is the
// row's full height in the list.
const ChatMessageWindowRow = memo(function ChatMessageWindowRow({
index,
onRowRef,
...props
}: ChatMessageWindowRowProps): React.JSX.Element {
const messageId = props.message.id
const rowRef = useMemo(
() =>
onRowRef
? (element: HTMLDivElement | null) => onRowRef(messageId, element)
: undefined,
[messageId, onRowRef]
)
return (
<div
className="message-window-row"
data-message-id={messageId}
data-message-index={index}
ref={rowRef}
>
<ChatMessageRow {...props} />
</div>
)
})
type ChatTimelineProps = {
onAddToNote?: ChatMessageRowProps['onAddToNote']
onOpenImageModelSettings?: () => void
@@ -1382,6 +1417,14 @@ type ChatTimelineProps = {
renderAssistantHtml?: boolean
retryContent?: string
totalMessageCount: number
/**
* Windowed layout (PERF-14). When set, `messages` holds every loaded
* message starting at `messageStartIndex`, and only the rows listed here
* are mounted; spacers stand in for the rest. Each row is wrapped in an
* element reported through `onRowRef` so the pane can measure it.
*/
segments?: readonly MessageWindowSegment[]
onRowRef?: (messageId: string, element: HTMLElement | null) => void
}
export const ChatTimeline = memo(function ChatTimeline({
@@ -1408,10 +1451,84 @@ export const ChatTimeline = memo(function ChatTimeline({
onRevealEarlier,
renderAssistantHtml = false,
retryContent,
totalMessageCount
totalMessageCount,
segments,
onRowRef
}: ChatTimelineProps): React.JSX.Element {
const { t } = useTranslation('app')
if (segments) {
const rowProps = {
onAddToNote,
onOpenImageModelSettings,
onReselectImageSources,
onEditImage,
artifactById,
conversationId,
locale,
onArticleRef,
onCopyMessage,
onDownloadImage,
onOpenCitationContext,
onOpenCitationSource,
onOpenImage,
onRespondApproval,
onRespondQuestion,
onRetry,
renderAssistantHtml,
retryContent
}
// One flat keyed list, so the "load earlier" control keeps its DOM node
// (and focus) while the rows around it change. The control sits above
// the top spacer, at the start of the scrollable history.
const children: React.JSX.Element[] = []
if (hiddenMessageCount > 0) {
children.push(
<button
className="load-earlier-messages"
key="load-earlier"
onClick={onRevealEarlier}
type="button"
>
{t('chat.loadEarlierMessages', { count: hiddenMessageCount })}
</button>
)
}
for (const segment of segments) {
if (segment.kind === 'spacer') {
children.push(
<div
aria-hidden="true"
className="message-window-spacer"
data-window-spacer={segment.top ? 'top' : 'gap'}
key={`spacer:${segment.key}`}
style={{ height: segment.height }}
/>
)
continue
}
const message = messages[segment.index - messageStartIndex]
if (!message) continue
children.push(
<ChatMessageWindowRow
{...rowProps}
canRetry={
message.state === 'error' &&
segment.index === totalMessageCount - 1
}
greeting={segment.index === 0 && isUnusedConversation}
index={segment.index}
key={message.id}
message={message}
onRowRef={onRowRef}
/>
)
}
return (
<div className="message-list message-list--windowed">{children}</div>
)
}
return (
<div className="message-list">
{hiddenMessageCount > 0 && (
+728
View File
@@ -0,0 +1,728 @@
import {
Check,
ChevronDown,
ChevronRight,
Copy,
Download,
Edit3,
GitFork,
MessageSquarePlus,
MoreHorizontal,
Pin,
PinOff,
RefreshCw,
Trash2,
X,
} from "lucide-react";
import { memo, useDeferredValue, useEffect, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import {
projectChannelLabels,
type AssistantProject,
type AssistantSchedule,
type AssistantTask,
} from "../../shared/assistant-contracts";
import { getConversationDisplayTitle, type Conversation } from "./chat-conversation";
import type { ConversationActivity } from "./conversation-activity";
import { useConversationList, type ConversationStore } from "./conversation-store";
import { formatMediumDateTime } from "./locale-formatters";
import type { AppNotificationInput } from "./notifications";
import { OverflowMarquee } from "./OverflowMarquee";
import { findTaskSchedule } from "./TaskScheduleActions";
import { formatConversationListTime, type TimeFormatLocale } from "./time-format";
import { useConversationListOrder } from "./use-conversation-list-order";
import { DestructiveConfirmActions } from "./WorkspacePrimitives";
/** Delay before searching message text, so typing does not scan every keystroke. */
const conversationSearchSnapshotDelayMs = 250;
const emptyConversationTasks: AssistantTask[] = [];
const emptyAssistantSchedules: AssistantSchedule[] = [];
type SearchMatches = { query: string; ids: ReadonlySet<string> };
const noSearchMatches: SearchMatches = { query: "", ids: new Set() };
export function ConversationBranchBadge({
sourceTitle,
}: {
sourceTitle: string;
}): React.JSX.Element {
const { t } = useTranslation("app");
const label = t("conversation.branch.badge", {
title: sourceTitle,
});
return (
<span
aria-label={label}
className="conversation-branch-badge"
title={label}
>
<GitFork aria-hidden="true" size={13} />
</span>
);
}
export type ConversationListRowHandlers = {
toggleTasks: (conversationId: string) => void;
select: (conversationId: string) => void;
toggleActions: (conversationId: string) => void;
registerActionTrigger: (conversationId: string, element: HTMLButtonElement | null) => void;
closeActions: (conversationId: string) => void;
pin: (conversation: Conversation) => void;
branch: (conversation: Conversation, disabledReason: string | undefined) => void;
startRename: (conversationId: string) => void;
copy: (conversation: Conversation) => void;
exportConversation: (conversation: Conversation) => void;
cancelDelete: () => void;
confirmDelete: (conversationId: string) => void;
requestDelete: (conversationId: string) => void;
saveTitle: (conversationId: string, title: string) => void;
cancelRename: (conversationId: string) => void;
openTask: (task: AssistantTask) => void;
viewAllTasks: (conversationId: string, firstTaskId: string | undefined) => void;
};
type ConversationListRowProps = {
conversation: Conversation;
conversationTitle: string;
conversationTasks: readonly AssistantTask[];
tasksExpanded: boolean;
activity: ConversationActivity | undefined;
branchSourceTitle: string | undefined;
branchDisabledReason: string | undefined;
branching: boolean;
active: boolean;
unread: boolean;
actionsOpen: boolean;
menuVisible: boolean;
pinDisabled: boolean;
renaming: boolean;
confirmingDelete: boolean;
deleting: boolean;
selectedTaskId: string | undefined;
schedules: readonly AssistantSchedule[];
locale: TimeFormatLocale;
/** Current calendar day; re-renders rows so "today" times stay correct. */
listTimeDay: string;
actionsRef: React.RefObject<HTMLDivElement | null>;
handlers: ConversationListRowHandlers;
};
// Rows skip re-rendering unless their own props change, so streaming into one
// conversation re-renders only that row instead of the whole list.
const ConversationListRow = memo(function ConversationListRow({
conversation,
conversationTitle,
conversationTasks,
tasksExpanded,
activity,
branchSourceTitle,
branchDisabledReason,
branching,
active,
unread,
actionsOpen,
menuVisible,
pinDisabled,
renaming,
confirmingDelete,
deleting,
selectedTaskId,
schedules,
locale,
listTimeDay,
actionsRef,
handlers,
}: 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 className="conversation-row">
{conversationTasks.length > 0 && (
<button
aria-expanded={tasksExpanded}
aria-label={t("conversation.tasks.toggle", {
title: conversationTitle,
count: conversationTasks.length,
})}
className="conversation-task-toggle"
onClick={() => handlers.toggleTasks(conversation.id)}
type="button"
>
{tasksExpanded ? (
<ChevronDown aria-hidden="true" size={13} />
) : (
<ChevronRight aria-hidden="true" size={13} />
)}
</button>
)}
<button
className={
active
? "conversation-item conversation-item--active"
: "conversation-item"
}
type="button"
onClick={() => handlers.select(conversation.id)}
>
<span className="conversation-item__primary">
{conversation.pinned && (
<span className="conversation-pin">
<Pin size={13} role="img" aria-label={t("conversation.actions.pinned")}><title>{t("conversation.actions.pinned")}</title></Pin>
</span>
)}
{branchSourceTitle && (
<ConversationBranchBadge
sourceTitle={branchSourceTitle}
/>
)}
{conversation.remote && (
<b className="conversation-source-badge">
{projectChannelLabels[conversation.remote.channel]}
</b>
)}
<OverflowMarquee
className="conversation-item__title"
text={conversationTitle}
/>
{unread && (
<i
aria-label={t("conversation.unread")}
className="conversation-unread"
title={t("conversation.unreadRemote")}
/>
)}
</span>
<small>
<time
dateTime={new Date(
conversation.updatedAt,
).toISOString()}
title={formatMediumDateTime(
conversation.updatedAt,
locale,
)}
>
{formatConversationListTime(
conversation.updatedAt,
locale,
)}
</time>
</small>
</button>
{activity && (
<span
aria-label={activity.status === "running"
? t("conversation.active")
: tWorkspace(`projectActivity.status.${activity.status}`)}
className={activity.status === "running"
? "conversation-activity-indicator"
: "conversation-activity-indicator conversation-activity-indicator--attention"}
role="status"
title={tWorkspace(`projectActivity.status.${activity.status}`)}
/>
)}
<button
aria-controls={`conversation-actions-${conversation.id}`}
aria-haspopup="menu"
aria-expanded={actionsOpen}
aria-label={t("conversation.actions.more", {
title: conversationTitle,
})}
className="conversation-more"
onClick={() => handlers.toggleActions(conversation.id)}
ref={(element) => handlers.registerActionTrigger(conversation.id, element)}
type="button"
>
<MoreHorizontal size={14} />
</button>
</div>
{menuVisible && createPortal(
<div
ref={actionsRef}
role="menu"
onKeyDown={(event) => {
if (event.defaultPrevented) return;
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
handlers.closeActions(conversation.id);
return;
}
if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return;
event.preventDefault();
const items = Array.from(event.currentTarget.querySelectorAll<HTMLButtonElement>('button:not(:disabled):not([aria-disabled="true"])'));
const index = items.indexOf(document.activeElement as HTMLButtonElement);
const next = event.key === "Home" ? 0 : event.key === "End" ? items.length - 1 : (index + (event.key === "ArrowDown" ? 1 : -1) + items.length) % items.length;
items[next]?.focus();
}}
aria-label={t("conversation.actions.region", {
title: conversationTitle,
})}
className="conversation-actions"
id={`conversation-actions-${conversation.id}`}
>
<button role="menuitem" type="button" disabled={pinDisabled} onClick={() => handlers.pin(conversation)}>
{conversation.pinned ? <PinOff size={14} /> : <Pin size={14} />}
{t(conversation.pinned ? "conversation.actions.unpin" : "conversation.actions.pin")}
</button>
{!conversation.remote && (
<>
<button
role="menuitem"
aria-describedby={
branchDisabledReason
? branchDisabledReasonId
: undefined
}
aria-disabled={
branchDisabledReason ? true : undefined
}
onClick={() => handlers.branch(conversation, branchDisabledReason)}
title={branchDisabledReason}
type="button"
>
<GitFork
aria-hidden="true"
className="conversation-branch-icon"
size={14}
/>
{branching
? t("conversation.branch.creating")
: t("conversation.actions.branch")}
</button>
{branchDisabledReason && (
<span
className="sr-only"
id={branchDisabledReasonId}
>
{branchDisabledReason}
</span>
)}
</>
)}
{!conversation.remote && (
<button
role="menuitem"
onClick={() => handlers.startRename(conversation.id)}
type="button"
>
<Edit3 size={14} />
{t("conversation.actions.rename")}
</button>
)}
<button
role="menuitem"
onClick={() => handlers.copy(conversation)}
type="button"
>
<Copy size={14} />
{t("conversation.actions.copy")}
</button>
<button
role="menuitem"
onClick={() => handlers.exportConversation(conversation)}
type="button"
>
<Download size={14} />
{t("conversation.actions.export")}
</button>
{!conversation.remote && (
<DestructiveConfirmActions
triggerRole="menuitem"
cancelAriaLabel={t("conversation.delete.cancelAria", {
title: conversationTitle,
})}
confirmAriaLabel={t(
"conversation.delete.confirmAria",
{
title: conversationTitle,
},
)}
confirmLabel={t("conversation.delete.confirm")}
confirming={confirmingDelete}
disabled={deleting}
icon={<Trash2 aria-hidden="true" size={14} />}
onCancel={handlers.cancelDelete}
onConfirm={() => handlers.confirmDelete(conversation.id)}
onRequestConfirm={() => handlers.requestDelete(conversation.id)}
triggerAriaLabel={t(
"conversation.delete.triggerAria",
{
title: conversationTitle,
},
)}
triggerLabel={t("conversation.delete.trigger")}
/>
)}
</div>, document.body
)}
{!conversation.remote &&
renaming && (
<form
className="conversation-rename"
onSubmit={(event) => {
event.preventDefault();
const input =
event.currentTarget.elements.namedItem("title");
if (input instanceof HTMLInputElement) {
handlers.saveTitle(conversation.id, input.value);
}
}}
>
<input
aria-label={t("conversation.renameAria", {
title: conversationTitle,
})}
autoFocus
defaultValue={conversation.title}
maxLength={80}
name="title"
onKeyDown={(event) => {
if (event.key === "Escape") {
handlers.cancelRename(conversation.id);
}
}}
pattern=".*\S.*"
required
/>
<button
aria-label={t("conversation.saveName")}
type="submit"
>
<Check size={14} />
</button>
<button
aria-label={t("conversation.cancelRename")}
onClick={() => {
handlers.cancelRename(conversation.id);
}}
type="button"
>
<X size={14} />
</button>
</form>
)}
{tasksExpanded && conversationTasks.length > 0 && (
<ul
aria-label={t("conversation.tasks.list", {
title: conversationTitle,
})}
className="conversation-task-children"
>
{conversationTasks.slice(0, 3).map((task) => {
const schedule = findTaskSchedule(task, schedules);
const statusLabel = tWorkspace(
`task.status.${task.status}`,
);
const metadata = schedule
? [
tWorkspace(
`sidebar.tasks.schedule.recurrence.${schedule.recurrence}`,
),
task.status === "completed"
? undefined
: statusLabel,
]
.filter((value) => value !== undefined)
.join(" · ")
: task.status === "completed"
? ""
: statusLabel;
return (
<li key={task.id}>
<button
className={
selectedTaskId === task.id
? "conversation-task-child conversation-task-child--active"
: "conversation-task-child"
}
onClick={() => handlers.openTask(task)}
type="button"
>
<span className="conversation-task-child__title">
{task.title}
</span>
{metadata && (
<small className="conversation-task-child__meta">
{metadata}
</small>
)}
{task.status === "completed" ? (
<span
className="task-status-dot task-status-dot--completed conversation-task-child__completed-status"
title={statusLabel}
>
<Check
aria-hidden="true"
size={7}
strokeWidth={3}
/>
<span className="sr-only">{statusLabel}</span>
</span>
) : (
<span
aria-hidden="true"
className={`task-status-dot task-status-dot--${task.status}`}
/>
)}
</button>
</li>
);
})}
{conversationTasks.length > 3 && (
<li>
<button
className="conversation-task-view-all"
onClick={() => handlers.viewAllTasks(conversation.id, conversationTasks[0]?.id)}
type="button"
>
{t("conversation.tasks.viewAll", {
count: conversationTasks.length,
})}
</button>
</li>
)}
</ul>
)}
</div>
);
});
export type ConversationSidebarActions = {
newConversation: () => void;
clearSearch: () => void;
retryLoad: () => void;
notify: (notification: AppNotificationInput) => void;
};
type ConversationSidebarProps = {
store: ConversationStore;
activeId: string;
activeProjectId: string;
activeProjectKind: AssistantProject["kind"] | undefined;
searchQuery: string;
conversationStoreReady: boolean;
loadError: string | undefined;
locale: TimeFormatLocale;
sidebarOpen: boolean;
tasksByConversation: ReadonlyMap<string, readonly AssistantTask[]>;
expandedTaskConversationIds: ReadonlySet<string>;
activityByConversationId: ReadonlyMap<string, ConversationActivity>;
activeConversationIds: ReadonlySet<string>;
queuedConversationIds: ReadonlySet<string>;
unreadConversationIds: ReadonlySet<string>;
conversationActionsId: string;
confirmingConversationId: string;
deletingConversationId: string;
renamingConversationId: string;
branchingConversationId: string;
pinningConversationId: string;
assistantSchedules: readonly AssistantSchedule[];
selectedAssistantTaskId: string | undefined;
actionsRef: React.RefObject<HTMLDivElement | null>;
handlers: ConversationListRowHandlers;
actions: ConversationSidebarActions;
};
/**
* The sidebar's conversation list. It reads the conversation store itself, so
* conversation updates re-render this list (and only the changed rows), not
* App; switching conversations re-renders only the previous and next row.
*/
export const ConversationSidebar = memo(function ConversationSidebar({
store,
activeId,
activeProjectId,
activeProjectKind,
searchQuery,
conversationStoreReady,
loadError,
locale,
sidebarOpen,
tasksByConversation,
expandedTaskConversationIds,
activityByConversationId,
activeConversationIds,
queuedConversationIds,
unreadConversationIds,
conversationActionsId,
confirmingConversationId,
deletingConversationId,
renamingConversationId,
branchingConversationId,
pinningConversationId,
assistantSchedules,
selectedAssistantTaskId,
actionsRef,
handlers,
actions,
}: ConversationSidebarProps): React.JSX.Element {
const { t } = useTranslation("app");
const conversations = useConversationList(store);
const deferredSearchQuery = useDeferredValue(searchQuery);
const [localSearchMatches, setLocalSearchMatches] = useState<SearchMatches>(noSearchMatches);
const [persistedSearchMatches, setPersistedSearchMatches] = useState<SearchMatches>(noSearchMatches);
useEffect(() => {
if (!searchQuery.trim()) {
return;
}
const timeout = window.setTimeout(
() => {
const query = searchQuery.trim().toLocaleLowerCase();
setLocalSearchMatches({ query, ids: new Set(conversations.filter(conversation =>
conversation.messages.some(message => message.content.toLocaleLowerCase().includes(query))
).map(conversation => conversation.id)) });
},
conversationSearchSnapshotDelayMs,
);
return () => window.clearTimeout(timeout);
}, [conversations, searchQuery]);
useEffect(() => {
const query = deferredSearchQuery.trim().toLocaleLowerCase();
if (!query || !conversationStoreReady) return;
let active = true;
const timeout = window.setTimeout(() => {
void window.goodbuddy.conversations.search(query, store.getState().map(item => item.id)).then(ids => {
if (active) setPersistedSearchMatches({ query, ids: new Set(ids) });
}).catch(() => {
if (active) actions.notify({
tone: "error", message: t("notices.remoteConversationRefreshFailed"),
dedupeKey: "conversation-search",
});
});
}, conversationSearchSnapshotDelayMs);
return () => { active = false; window.clearTimeout(timeout); };
// Local matches settling reruns the search, as before the extraction.
}, [deferredSearchQuery, conversationStoreReady, localSearchMatches, store, actions, t]);
const query = deferredSearchQuery.trim().toLocaleLowerCase();
const matchingConversations = useMemo(() => conversations.filter(
(conversation) =>
(!activeProjectId || conversation.projectId === activeProjectId) &&
(activeProjectKind !== "channel" || conversation.remote !== undefined) &&
(!query ||
(persistedSearchMatches.query === query && persistedSearchMatches.ids.has(conversation.id)) ||
conversation.title.toLocaleLowerCase().includes(query) ||
(localSearchMatches.query === query && localSearchMatches.ids.has(conversation.id))),
), [activeProjectId, activeProjectKind, conversations, query, localSearchMatches, persistedSearchMatches]);
const { conversations: filteredConversations, listProps } = useConversationListOrder(
matchingConversations,
JSON.stringify([activeProjectId, activeProjectKind, query]),
Boolean(conversationActionsId),
);
const defaultTitle = t("conversation.defaultTitle");
const conversationById = useMemo(
() => new Map(conversations.map((item) => [item.id, item])),
[conversations],
);
const titleById = (conversationId: string): string | undefined => {
const source = conversationById.get(conversationId);
return source ? getConversationDisplayTitle(source, defaultTitle) : undefined;
};
const listTimeDay = new Date().toDateString();
return (
<section className="sidebar-conversations" aria-label={t("sidebar.recent")}>
<div className="conversation-list" {...listProps}>
{!loadError &&
filteredConversations.map((conversation) => {
const conversationTasks =
tasksByConversation.get(conversation.id) ?? emptyConversationTasks;
const tasksExpanded = expandedTaskConversationIds.has(conversation.id);
const branchUnavailable =
activeConversationIds.has(conversation.id) ||
queuedConversationIds.has(conversation.id);
const branchDisabledReason = !conversationStoreReady
? t("conversation.branch.storageUnavailable")
: branchingConversationId
? branchingConversationId === conversation.id
? t("conversation.branch.creating")
: t("conversation.branch.anotherCreating")
: branchUnavailable
? t("conversation.branch.unavailable")
: undefined;
return (
<ConversationListRow
key={conversation.id}
actionsOpen={conversationActionsId === conversation.id}
actionsRef={actionsRef}
active={conversation.id === activeId}
activity={activityByConversationId.get(conversation.id)}
branchDisabledReason={branchDisabledReason}
branchSourceTitle={conversation.branch
? titleById(conversation.branch.sourceConversationId) ?? conversation.branch.sourceTitle
: undefined}
branching={branchingConversationId === conversation.id}
confirmingDelete={confirmingConversationId === conversation.id}
conversation={conversation}
conversationTasks={conversationTasks}
conversationTitle={getConversationDisplayTitle(conversation, defaultTitle)}
deleting={deletingConversationId === conversation.id}
handlers={handlers}
listTimeDay={listTimeDay}
locale={locale}
menuVisible={sidebarOpen && conversationActionsId === conversation.id}
pinDisabled={!conversationStoreReady || Boolean(pinningConversationId)}
renaming={renamingConversationId === conversation.id}
schedules={tasksExpanded ? assistantSchedules : emptyAssistantSchedules}
selectedTaskId={tasksExpanded && conversationTasks.some((task) => task.id === selectedAssistantTaskId) ? selectedAssistantTaskId : undefined}
tasksExpanded={tasksExpanded}
unread={unreadConversationIds.has(conversation.id)}
/>
);
})}
{loadError ? (
<div className="conversation-empty" role="alert">
<strong>{t("conversation.loadFailed")}</strong>
<span>{loadError}</span>
<button
className="secondary-button"
onClick={actions.retryLoad}
type="button"
>
<RefreshCw aria-hidden="true" size={13} />
{t("conversation.retryLoad")}
</button>
</div>
) : filteredConversations.length === 0 ? (
deferredSearchQuery.trim() ? (
<div className="conversation-empty">
<strong>{t("conversation.noMatches")}</strong>
<span>{t("conversation.noMatchesDescription")}</span>
<button
className="secondary-button"
onClick={actions.clearSearch}
type="button"
>
{t("conversation.clearSearch")}
</button>
</div>
) : activeProjectKind === "channel" ? (
<div className="conversation-empty">
<strong>{t("conversation.noRemote")}</strong>
<span>{t("chat.remote.waiting")}</span>
</div>
) : (
<div className="conversation-empty">
<strong>{t("conversation.empty")}</strong>
<span>{t("conversation.emptyDescription")}</span>
<button
className="secondary-button"
onClick={actions.newConversation}
type="button"
>
<MessageSquarePlus aria-hidden="true" size={13} />
{t("sidebar.newConversation")}
</button>
</div>
)
) : null}
</div>
</section>
);
});
+7
View File
@@ -44,3 +44,10 @@ export function isUnusedConversation(conversation: Conversation): boolean {
(conversation.messageSummary?.firstRole ?? conversation.messages[0]?.role) === "assistant"
);
}
export function getConversationDisplayTitle(
conversation: Conversation,
defaultTitle: string,
): string {
return isUnusedConversation(conversation) ? defaultTitle : conversation.title;
}
@@ -0,0 +1,92 @@
import { describe, expect, it } from 'vitest'
import {
MessageRowHeights,
buildSegments,
estimatedMessageRowHeight,
maxRenderedMessageCount,
rangeAround,
rangeCovers,
rowAtOffset,
rowHeightsFor,
tailRange
} from './chat-message-window'
const ids = Array.from({ length: 2_000 }, (_, index) => ({ id: `m${index}` }))
describe('MessageRowHeights', () => {
it('estimates unmeasured rows from first measurements only', () => {
const heights = new MessageRowHeights()
expect(heights.size('m0')).toBe(estimatedMessageRowHeight)
expect(heights.set('m0', 100)).toBe('new')
expect(heights.set('m1', 300)).toBe('new')
expect(heights.estimate()).toBe(200)
// A streaming row growing does not move the estimate (and the spacers).
expect(heights.set('m1', 900)).toBe('changed')
expect(heights.estimate()).toBe(200)
expect(heights.set('m1', 900.2)).toBeUndefined()
expect(heights.sum(ids, 0, 4)).toBe(100 + 900 + 200 + 200)
})
it('tracks the viewport with a fallback', () => {
const heights = new MessageRowHeights()
expect(heights.viewport).toBeGreaterThan(0)
expect(heights.setViewport(600)).toBe(true)
expect(heights.setViewport(600.4)).toBe(false)
expect(heights.viewport).toBe(600)
})
it('keeps heights per conversation across lookups', () => {
const first = rowHeightsFor('window-test-conversation')
first.set('x', 42)
expect(rowHeightsFor('window-test-conversation').size('x')).toBe(42)
})
})
describe('window ranges', () => {
const sizeAt = (): number => 100
it('fills the viewport plus overscan from the bottom', () => {
expect(tailRange({ sizeAt, windowStart: 0, count: 2_000, viewportHeight: 600, overscan: 400 }))
.toEqual({ start: 1_990, end: 2_000 })
expect(tailRange({ sizeAt, windowStart: 1_995, count: 2_000, viewportHeight: 600, overscan: 400 }))
.toEqual({ start: 1_995, end: 2_000 })
})
it('bounds the range for tiny rows', () => {
const range = tailRange({ sizeAt: () => 1, windowStart: 0, count: 2_000, viewportHeight: 600, overscan: 400 })
expect(range.end - range.start).toBe(maxRenderedMessageCount)
})
it('covers the viewport around an anchor row', () => {
const range = rangeAround({
sizeAt, windowStart: 0, count: 2_000, viewportHeight: 600, overscan: 300,
anchorIndex: 1_000, anchorOffset: -40
})
expect(range.start).toBeLessThanOrEqual(997)
expect(range.end).toBeGreaterThanOrEqual(1_009)
expect(range.end - range.start).toBeLessThanOrEqual(maxRenderedMessageCount)
expect(rangeCovers({ start: 0, end: 2_000 }, range)).toBe(true)
expect(rangeCovers({ start: 999, end: 2_000 }, range)).toBe(false)
})
it('finds the row at an offset', () => {
expect(rowAtOffset(sizeAt, 10, 2_000, 250)).toEqual({ index: 12, top: 200 })
expect(rowAtOffset(sizeAt, 10, 20, 99_999)).toEqual({ index: 19, top: 900 })
})
})
describe('buildSegments', () => {
it('replaces unrendered rows with sized spacers', () => {
const heights = new MessageRowHeights()
heights.set('m5', 50)
const segments = buildSegments(heights, ids.slice(0, 10), 2, 10, [4, 5, 8])
expect(segments).toEqual([
{ kind: 'spacer', key: 'top', height: 2 * 50, top: true, start: 2 },
{ kind: 'row', index: 4 },
{ kind: 'row', index: 5 },
{ kind: 'spacer', key: 'gap:m6', height: 2 * 50, top: false, start: 6 },
{ kind: 'row', index: 8 },
{ kind: 'spacer', key: 'bottom', height: 50, top: false, start: 9 }
])
})
})
+257
View File
@@ -0,0 +1,257 @@
/**
* Windowing model for the chat message timeline (PERF-14).
*
* The timeline only mounts the rows near the viewport. Rows above and below
* the rendered range are replaced by spacers whose heights come from measured
* row heights, or from the mean measured height for rows never rendered yet.
* The pane keeps the reader's place by anchoring a visible row (see
* ChatHistoryPane), so estimate errors never move what the reader sees.
*/
export type MessageWindowRange = { start: number; end: number };
/** Used for rows that were never measured while nothing has been measured. */
export const estimatedMessageRowHeight = 160;
/** Upper bound for mounted rows from the range (extras such as the focused row come on top). */
export const maxRenderedMessageCount = 60;
/** Viewport height assumed before the scroll container has been measured. */
export const fallbackViewportHeight = 800;
export function overscanFor(viewportHeight: number): number {
return Math.max(320, Math.round(viewportHeight * 0.75));
}
export class MessageRowHeights {
private readonly sizes = new Map<string, number>();
private estimateValue = estimatedMessageRowHeight;
private viewportValue = 0;
/** Last measured viewport height, or the fallback before any measurement. */
get viewport(): number {
return this.viewportValue > 0 ? this.viewportValue : fallbackViewportHeight;
}
/** Records the viewport height; returns whether it changed noticeably. */
setViewport(height: number): boolean {
if (height <= 0 || Math.abs(height - this.viewportValue) < 1) return false;
this.viewportValue = height;
return true;
}
get measuredCount(): number {
return this.sizes.size;
}
/**
* Height assumed for rows never measured: the mean of the heights recorded
* when rows were first measured. Re-measuring a row (a streaming reply
* growing) does not move it, so spacers only change when new rows are seen.
*/
estimate(): number {
return this.estimateValue;
}
size(id: string): number {
return this.sizes.get(id) ?? this.estimateValue;
}
has(id: string): boolean {
return this.sizes.has(id);
}
/**
* Records a measured height. Returns 'new' for a row measured for the first
* time, 'changed' for a re-measured row, undefined when nothing changed.
*/
set(id: string, height: number): 'new' | 'changed' | undefined {
const previous = this.sizes.get(id);
if (previous !== undefined) {
if (Math.abs(previous - height) < 0.5) return undefined;
this.sizes.set(id, height);
return 'changed';
}
const count = this.sizes.size;
this.estimateValue =
count === 0 ? height : (this.estimateValue * count + height) / (count + 1);
this.sizes.set(id, height);
return 'new';
}
/** Total height of rows [from, to). */
sum(ids: readonly { id: string }[], from: number, to: number): number {
const estimate = this.estimateValue;
let total = 0;
for (let index = from; index < to; index += 1) {
const id = ids[index]?.id;
total += (id === undefined ? undefined : this.sizes.get(id)) ?? estimate;
}
return total;
}
}
type WindowInput = {
/** Height of row `index`. */
sizeAt: (index: number) => number;
/** First loaded row. */
windowStart: number;
/** Number of rows (exclusive end of the loaded window). */
count: number;
viewportHeight: number;
overscan: number;
maxCount?: number;
};
/** Rows that fill the viewport from the bottom of the loaded window. */
export function tailRange({
sizeAt,
windowStart,
count,
viewportHeight,
overscan,
maxCount = maxRenderedMessageCount,
}: WindowInput): MessageWindowRange {
let start = count;
let covered = 0;
while (start > windowStart && covered < viewportHeight + overscan) {
start -= 1;
covered += sizeAt(start);
}
return { start: Math.max(start, count - maxCount, windowStart), end: count };
}
/**
* Rows that cover the viewport around an anchor row whose top is
* `anchorOffset` pixels below the viewport top (negative when it starts above).
*/
export function rangeAround({
sizeAt,
windowStart,
count,
viewportHeight,
overscan,
anchorIndex,
anchorOffset,
maxCount = maxRenderedMessageCount,
}: WindowInput & { anchorIndex: number; anchorOffset: number }): MessageWindowRange {
const anchor = Math.min(Math.max(anchorIndex, windowStart), Math.max(windowStart, count - 1));
let start = anchor;
let top = anchorOffset;
while (start > windowStart && top > -overscan) {
start -= 1;
top -= sizeAt(start);
}
let end = anchor;
let bottom = anchorOffset;
while (end < count && (end === anchor || bottom < viewportHeight + overscan)) {
bottom += sizeAt(end);
end += 1;
}
while (end - start > maxCount) {
if (anchor - start > end - anchor - 1) start += 1;
else if (end > anchor + 1) end -= 1;
else break;
}
return { start, end };
}
/** Row at content offset `y` from the top of the loaded window. */
export function rowAtOffset(
sizeAt: (index: number) => number,
windowStart: number,
count: number,
y: number,
): { index: number; top: number } {
let top = 0;
for (let index = windowStart; index < count; index += 1) {
const size = sizeAt(index);
if (top + size > y || index === count - 1) return { index, top };
top += size;
}
return { index: windowStart, top: 0 };
}
export function rangeCovers(
outer: MessageWindowRange,
inner: MessageWindowRange,
): boolean {
return outer.start <= inner.start && outer.end >= inner.end;
}
export type MessageWindowSegment =
| { kind: 'spacer'; key: string; height: number; top: boolean; start: number }
| { kind: 'row'; index: number };
/**
* Row heights per conversation, kept across pane remounts (keep-alive
* eviction) so a reopened conversation lays out with real heights. Bounded LRU.
*/
const rowHeightCache = new Map<string, MessageRowHeights>();
const rowHeightCacheLimit = 24;
export function rowHeightsFor(conversationId: string): MessageRowHeights {
let heights = rowHeightCache.get(conversationId);
if (heights) {
rowHeightCache.delete(conversationId);
} else {
heights = new MessageRowHeights();
}
rowHeightCache.set(conversationId, heights);
while (rowHeightCache.size > rowHeightCacheLimit) {
const oldest = rowHeightCache.keys().next().value;
if (oldest === undefined) break;
rowHeightCache.delete(oldest);
}
return heights;
}
export function clearRowHeightCache(): void {
rowHeightCache.clear();
}
/**
* Lays out the loaded window [windowStart, count) as rendered rows and
* spacers. `rendered` must be sorted and unique.
*/
export function buildSegments(
heights: MessageRowHeights,
ids: readonly { id: string }[],
windowStart: number,
count: number,
rendered: readonly number[],
): MessageWindowSegment[] {
// The top spacer is always present (possibly 0 px) so the element keeps
// its identity while the window moves.
const segments: MessageWindowSegment[] = [
{ kind: 'spacer', key: 'top', height: 0, top: true, start: windowStart },
];
let previous = windowStart;
for (const index of rendered) {
if (index < previous || index >= count) continue;
if (index > previous) {
const height = heights.sum(ids, previous, index);
if (previous === windowStart) {
segments[0] = { kind: 'spacer', key: 'top', height, top: true, start: windowStart };
} else {
segments.push({
kind: 'spacer',
key: `gap:${ids[previous]?.id ?? previous}`,
height,
top: false,
start: previous,
});
}
}
segments.push({ kind: 'row', index });
previous = index + 1;
}
if (previous < count) {
segments.push({
kind: 'spacer',
key: 'bottom',
height: heights.sum(ids, previous, count),
top: false,
start: previous,
});
}
return segments;
}
+137
View File
@@ -0,0 +1,137 @@
import { useCallback } from "react";
import type { AssistantTask } from "../../shared/assistant-contracts";
import type { Message } from "./ChatTimeline";
import { getConversationDisplayTitle, type Conversation } from "./chat-conversation";
import {
deriveConversationActivity,
sameActivitySummary,
type ConversationActivitySummary,
} from "./conversation-activity";
import { useConversationStoreSelector, type ConversationStore } from "./conversation-store";
import type { PendingSidebarApproval } from "./RightAssistantSidebar";
import { sameArrayItems, sameMapEntries } from "./stable-derived-value";
// Selectors over the conversation list. Each keeps its previous value while
// the selected content is unchanged, so consumers skip re-rendering when an
// unrelated conversation (or an unrelated field) changes.
const selectCount = (conversations: Conversation[]): number => conversations.length;
export function useConversationCount(store: ConversationStore): number {
return useConversationStoreSelector(store, selectCount);
}
/** Display titles by conversation ID. */
export function useConversationTitles(
store: ConversationStore,
defaultTitle: string,
): ReadonlyMap<string, string> {
const selector = useCallback(
(conversations: Conversation[]) =>
new Map(conversations.map((conversation) => [
conversation.id,
getConversationDisplayTitle(conversation, defaultTitle),
])),
[defaultTitle],
);
return useConversationStoreSelector(store, selector, sameMapEntries);
}
// Message arrays are replaced, never mutated: unchanged conversations reuse
// their scan while another conversation streams.
const approvalMessagesByArray = new WeakMap<readonly Message[], Message[]>();
function messagesWithApprovals(messages: readonly Message[]): Message[] {
let found = approvalMessagesByArray.get(messages);
if (!found) {
found = messages.filter((message) => message.approval);
approvalMessagesByArray.set(messages, found);
}
return found;
}
export function selectPendingSidebarApprovals(
conversations: readonly Conversation[],
): PendingSidebarApproval[] {
return conversations.flatMap((conversation) =>
messagesWithApprovals(conversation.messages).map((message) => ({
conversationId: conversation.id,
projectId: conversation.projectId,
messageId: message.id,
taskId: message.approval!.taskId ?? message.task?.id,
approvalId: message.approval!.id,
title: message.approval!.title,
description: message.approval!.description,
toolName: message.approval!.toolName,
})),
);
}
export function samePendingSidebarApprovals(
left: readonly PendingSidebarApproval[],
right: readonly PendingSidebarApproval[],
): boolean {
return sameArrayItems(left, right, (a, b) =>
a.conversationId === b.conversationId && a.projectId === b.projectId &&
a.messageId === b.messageId && a.taskId === b.taskId &&
a.approvalId === b.approvalId && a.title === b.title &&
a.description === b.description && a.toolName === b.toolName);
}
export function usePendingSidebarApprovals(store: ConversationStore): PendingSidebarApproval[] {
return useConversationStoreSelector(
store,
selectPendingSidebarApprovals,
samePendingSidebarApprovals,
);
}
/** Running/attention/completed summary across conversations and tasks. */
export function useConversationActivitySummary(
store: ConversationStore,
{
activeConversationIds,
completedConversationIds,
defaultTitle,
fallbackProjectName,
projects,
tasks,
}: {
activeConversationIds: ReadonlySet<string>;
completedConversationIds: ReadonlySet<string>;
defaultTitle: string;
fallbackProjectName: string;
projects: readonly { id: string; name: string }[];
tasks: readonly AssistantTask[];
},
): ConversationActivitySummary {
const selector = useCallback(
(conversations: Conversation[]) => deriveConversationActivity(
conversations.map((conversation) => ({
...conversation,
title: getConversationDisplayTitle(conversation, defaultTitle),
})),
tasks,
activeConversationIds,
projects,
fallbackProjectName,
completedConversationIds,
),
[activeConversationIds, completedConversationIds, defaultTitle, fallbackProjectName, projects, tasks],
);
return useConversationStoreSelector(store, selector, sameActivitySummary);
}
/** The conversations with these IDs, in this order; stable while unchanged. */
export function useConversationsById(
store: ConversationStore,
conversationIds: readonly string[],
): Conversation[] {
const selector = useCallback((conversations: Conversation[]) => {
const byId = new Map(conversations.map((conversation) => [conversation.id, conversation]));
return conversationIds.flatMap((conversationId) => {
const conversation = byId.get(conversationId);
return conversation ? [conversation] : [];
});
}, [conversationIds]);
return useConversationStoreSelector(store, selector, sameArrayItems);
}
@@ -0,0 +1,194 @@
import { act, cleanup, render } from "@testing-library/react";
import { StrictMode, useCallback, useLayoutEffect } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { Message } from "./ChatTimeline";
import type { Conversation } from "./chat-conversation";
import {
ConversationStoreEffects,
createConversationStore,
createConversationStores,
useConversation,
useConversationStoreSelector,
type ConversationStore,
} from "./conversation-store";
import {
selectPendingSidebarApprovals,
usePendingSidebarApprovals,
useConversationsById,
} from "./conversation-selectors";
import { createLiveMessageStore } from "./live-message-store";
function message(overrides: Partial<Message> = {}): Message {
return { id: "m1", role: "assistant", content: "", createdAt: 1, state: "streaming", blocks: [], ...overrides } as Message;
}
function conversation(id: string, messages: Message[] = [], overrides: Partial<Conversation> = {}): Conversation {
return { id, title: id, updatedAt: 1, messages, ...overrides } as Conversation;
}
afterEach(() => {
cleanup();
vi.useRealTimers();
});
describe("conversation store", () => {
it("applies updates synchronously, in dispatch order", () => {
const store = createConversationStore([conversation("a")], createLiveMessageStore());
store.set((current) => [...current, conversation("b")]);
expect(store.getState().map((item) => item.id)).toEqual(["a", "b"]);
store.set((current) => current.map((item) => item.id === "a" ? { ...item, title: "A" } : item));
expect(store.getConversation("a")?.title).toBe("A");
store.set([conversation("c")]);
expect(store.getState().map((item) => item.id)).toEqual(["c"]);
});
it("notifies list listeners on change and per-conversation listeners only for changed IDs", () => {
const store = createConversationStore([conversation("a"), conversation("b")], createLiveMessageStore());
const list = vi.fn();
const a = vi.fn();
const b = vi.fn();
store.subscribe(list);
store.subscribeConversation("a", a);
const unsubscribeB = store.subscribeConversation("b", b);
store.set((current) => current);
expect(list).not.toHaveBeenCalled();
store.set((current) => current.map((item) => item.id === "a" ? { ...item, title: "A" } : item));
expect(list).toHaveBeenCalledTimes(1);
expect(a).toHaveBeenCalledTimes(1);
expect(b).not.toHaveBeenCalled();
store.set((current) => current.filter((item) => item.id !== "b"));
expect(b).toHaveBeenCalledTimes(1);
unsubscribeB();
store.set((current) => [...current, conversation("b")]);
expect(b).toHaveBeenCalledTimes(1);
});
it("absorbs live deltas captured at dispatch in order with other blocks", () => {
let id = 0;
const live = createLiveMessageStore({ createId: () => `b${++id}` });
const store = createConversationStore([conversation("c1", [message()])], live);
const committed = (): Message => store.getState()[0]!.messages[0]!;
live.append("c1", "m1", "text", "Before. ", committed());
store.set((current) => current.map((item) => ({
...item,
messages: item.messages.map((entry) => ({
...entry,
blocks: [...(entry.blocks ?? []), { id: "tool", type: "tool", tool: { name: "x" } }],
} as Message)),
})));
live.prune(store.getState());
live.append("c1", "m1", "text", "After.", committed());
expect(live.resolve(committed()).content).toBe("Before. After.");
store.set((current) => current);
expect(committed().content).toBe("Before. After.");
expect(committed().blocks?.map((block) => block.type)).toEqual(["text", "tool", "text"]);
live.prune(store.getState());
expect(live.hasEntries()).toBe(false);
});
it("flushes pending deltas on the slow cadence and cancels the flush", () => {
vi.useFakeTimers();
const stores = createConversationStores([conversation("c1", [message()])], { flushIntervalMs: 250 });
const committed = (): Message => stores.conversations.getState()[0]!.messages[0]!;
stores.liveMessages.append("c1", "m1", "text", "Hi", committed());
vi.advanceTimersByTime(249);
expect(committed().content).toBe("");
vi.advanceTimersByTime(1);
expect(committed().content).toBe("Hi");
stores.liveMessages.prune(stores.conversations.getState());
stores.liveMessages.append("c1", "m1", "text", "!", committed());
stores.cancelLiveMessageFlush();
vi.advanceTimersByTime(1000);
expect(committed().content).toBe("Hi");
});
});
describe("conversation store hooks", () => {
function setup(initial: Conversation[]) {
return createConversationStore(initial, createLiveMessageStore());
}
it("re-renders a selector only when its selected value changes", () => {
const store = setup([conversation("a"), conversation("b")]);
const renders = vi.fn();
function Count({ store }: { store: ConversationStore }) {
const count = useConversationStoreSelector(store, useCallback((items: Conversation[]) => items.length, []));
renders(count);
return <span>{count}</span>;
}
const view = render(<Count store={store} />);
expect(renders).toHaveBeenCalledTimes(1);
act(() => store.set((current) => current.map((item) => ({ ...item, title: "x" }))));
expect(renders).toHaveBeenCalledTimes(1);
act(() => store.set((current) => [...current, conversation("c")]));
expect(renders).toHaveBeenCalledTimes(2);
expect(view.container.textContent).toBe("3");
});
it("keeps an equal selected value's identity", () => {
const store = setup([conversation("a", [message({ approval: { id: "p1", title: "Run" } } as Partial<Message>)])]);
const seen: unknown[] = [];
function Approvals({ store }: { store: ConversationStore }) {
seen.push(usePendingSidebarApprovals(store));
return null;
}
const view = render(<Approvals store={store} />);
act(() => store.set((current) => [...current, conversation("b")]));
view.rerender(<Approvals store={store} />);
expect(seen.length).toBe(2);
expect(seen[1]).toBe(seen[0]);
expect(selectPendingSidebarApprovals(store.getState())).toEqual(seen[0]);
});
it("re-renders useConversation only for its own conversation and follows ID changes", () => {
const store = setup([conversation("a"), conversation("b")]);
const renders = vi.fn();
function Title({ id }: { id: string }) {
const item = useConversation(store, id);
renders(id);
return <span>{item?.title ?? "none"}</span>;
}
const view = render(<Title id="a" />);
act(() => store.set((current) => current.map((item) => item.id === "b" ? { ...item, title: "B" } : item)));
expect(renders).toHaveBeenCalledTimes(1);
act(() => store.set((current) => current.map((item) => item.id === "a" ? { ...item, title: "A" } : item)));
expect(view.container.textContent).toBe("A");
view.rerender(<Title id="b" />);
expect(view.container.textContent).toBe("B");
act(() => store.set((current) => current.filter((item) => item.id !== "b")));
expect(view.container.textContent).toBe("none");
});
it("catches updates made between render and subscription, also in Strict Mode", () => {
const store = setup([conversation("a")]);
function Ids() {
const items = useConversationsById(store, ids);
return <span>{items.map((item) => item.title).join(",")}</span>;
}
const ids = ["a", "b"];
function UpdateOnMount() {
// Earlier sibling: its layout effect runs before the selector subscribes.
useLayoutEffect(() => {
if (!store.getConversation("b")) store.set((current) => [...current, conversation("b")]);
}, []);
return null;
}
const view = render(<StrictMode><UpdateOnMount /><Ids /></StrictMode>);
expect(view.container.textContent).toBe("a,b");
act(() => store.set((current) => current.map((item) => ({ ...item, title: item.id.toUpperCase() }))));
expect(view.container.textContent).toBe("A,B");
});
it("prunes absorbed deltas and runs the commit callback after list commits", () => {
const live = createLiveMessageStore({ createId: () => "b" });
const store = createConversationStore([conversation("c1", [message()])], live);
const onCommit = vi.fn();
render(<ConversationStoreEffects liveMessages={live} onCommit={onCommit} store={store} />);
expect(onCommit).toHaveBeenCalledTimes(1);
live.append("c1", "m1", "text", "abc", store.getState()[0]!.messages[0]);
act(() => store.set((current) => current));
expect(store.getState()[0]!.messages[0]!.content).toBe("abc");
expect(live.hasEntries()).toBe(false);
expect(onCommit).toHaveBeenCalledTimes(2);
});
});
+266
View File
@@ -0,0 +1,266 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useLayoutEffect,
useMemo,
useReducer,
useState,
type ReactNode,
} from "react";
import type { Conversation } from "./chat-conversation";
import {
createLiveMessageStore,
type ConversationsUpdate,
type LiveMessageStore,
} from "./live-message-store";
/**
* The conversation list, kept outside App state.
*
* `getState()` is always the latest list, synchronously after `set()`; it
* replaces the former `conversationsRef`, which only caught up after commit.
* Updates still go through the live-message store, so streaming deltas
* captured at dispatch are absorbed in their original order.
*
* Components read the list through `useConversationStoreSelector`, which
* re-renders only when the selected value changes. App selects the active and
* cached conversations, so updates to background conversations (history
* release, remote refreshes, other runs) no longer re-render the whole window.
*/
type Listener = () => void;
export type ConversationStore = ReturnType<typeof createConversationStore>;
export function createConversationStore(
initial: Conversation[],
liveMessages: LiveMessageStore,
) {
let state = initial;
const listeners = new Set<Listener>();
const conversationListeners = new Map<string, Set<Listener>>();
const notify = (previous: readonly Conversation[]): void => {
if (conversationListeners.size) {
const before = new Map(previous.map((item) => [item.id, item]));
const changed = new Set<string>();
for (const item of state) {
if (before.get(item.id) !== item) changed.add(item.id);
before.delete(item.id);
}
for (const id of before.keys()) changed.add(id);
for (const id of changed) {
for (const listener of [...(conversationListeners.get(id) ?? [])]) listener();
}
}
for (const listener of [...listeners]) listener();
};
return {
getState(): Conversation[] {
return state;
},
getConversation(conversationId: string): Conversation | undefined {
return state.find((item) => item.id === conversationId);
},
/** Applies `update` now, after absorbing the deltas pending at dispatch. */
set(update: ConversationsUpdate): void {
const captured = liveMessages.capture();
const next = liveMessages.applyUpdate(state, update, captured);
if (next === state) return;
const previous = state;
state = next;
notify(previous);
},
subscribe(listener: Listener): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
/** Notified only when this conversation object is replaced or removed. */
subscribeConversation(conversationId: string, listener: Listener): () => void {
let set = conversationListeners.get(conversationId);
if (!set) {
set = new Set();
conversationListeners.set(conversationId, set);
}
set.add(listener);
return () => {
set.delete(listener);
if (!set.size && conversationListeners.get(conversationId) === set) {
conversationListeners.delete(conversationId);
}
};
},
};
}
/**
* The conversation store plus the live-message store feeding it, with the
* slow flush that absorbs streaming deltas into the list.
*/
export function createConversationStores(
initial: Conversation[],
options: { flushIntervalMs: number; createId?: () => string },
) {
let flushTimer: ReturnType<typeof setTimeout> | undefined;
const liveMessages = createLiveMessageStore({
createId: options.createId,
onPending: () => {
if (flushTimer !== undefined) return;
// A plain update: a transition would keep being interrupted by the
// row's synchronous store updates and might never absorb anything.
flushTimer = setTimeout(() => {
flushTimer = undefined;
if (!liveMessages.hasEntries()) return;
conversations.set((current) => current);
}, options.flushIntervalMs);
},
});
const conversations = createConversationStore(initial, liveMessages);
return {
conversations,
liveMessages,
cancelLiveMessageFlush: (): void => {
clearTimeout(flushTimer);
flushTimer = undefined;
},
};
}
type Selection<T> = {
state: readonly Conversation[];
selector: (conversations: Conversation[]) => T;
value: T;
};
type LatestSelector<T> = {
selector: (conversations: Conversation[]) => T;
isEqual: (left: T, right: T) => boolean;
};
// Keyed by a per-hook slot, as in stable-derived-value.ts.
const selections = new WeakMap<object, Selection<unknown>>();
const latestSelectors = new WeakMap<object, LatestSelector<unknown>>();
/**
* Selects a value from the conversation list and re-renders only when it is
* no longer `isEqual` to the previous one; equal values keep their identity.
*
* Unlike useSyncExternalStore, the re-render is a plain state update made
* from `set()`, so it has the priority of the code that changed the list and
* batches with that code's other state updates (for example `setActiveId`
* after an await), exactly like the former App state. Keep `selector` stable
* (useCallback) when it is expensive; a new selector recomputes on render.
*/
export function useConversationStoreSelector<T>(
store: ConversationStore,
selector: (conversations: Conversation[]) => T,
isEqual: (left: T, right: T) => boolean = Object.is,
subscribe: (listener: Listener) => () => void = store.subscribe,
): T {
const [slot] = useState(() => ({}));
const [, rerender] = useReducer((count: number) => count + 1, 0);
const state = store.getState();
const cached = selections.get(slot) as Selection<T> | undefined;
let value: T;
if (cached && cached.state === state && cached.selector === selector) {
value = cached.value;
} else {
const next = selector(state);
value = cached && isEqual(cached.value, next) ? cached.value : next;
selections.set(slot, { state, selector, value });
}
useLayoutEffect(() => {
latestSelectors.set(slot, { selector, isEqual } as LatestSelector<unknown>);
});
useLayoutEffect(() => {
const check = (): void => {
const current = selections.get(slot) as Selection<T> | undefined;
const latest = latestSelectors.get(slot) as LatestSelector<T> | undefined;
const next = store.getState();
if (!latest || current?.state === next) return;
const selected = latest.selector(next);
if (current && latest.isEqual(current.value, selected)) {
selections.set(slot, { state: next, selector: latest.selector, value: current.value });
return;
}
selections.set(slot, { state: next, selector: latest.selector, value: selected });
rerender();
};
// Catch updates made between render and subscription.
check();
return subscribe(check);
}, [slot, store, subscribe]);
return value;
}
const selectAll = (conversations: Conversation[]): Conversation[] => conversations;
/** The whole list; re-renders on every change. */
export function useConversationList(store: ConversationStore): Conversation[] {
return useConversationStoreSelector(store, selectAll);
}
/** One conversation; re-renders only when that conversation changes. */
export function useConversation(
store: ConversationStore,
conversationId: string,
): Conversation | undefined {
const selector = useMemo(
() => (conversations: Conversation[]) =>
conversations.find((item) => item.id === conversationId),
[conversationId],
);
const subscribe = useCallback(
(listener: Listener) => store.subscribeConversation(conversationId, listener),
[conversationId, store],
);
return useConversationStoreSelector(store, selector, Object.is, subscribe);
}
/** Renders `children` with the whole list; for rarely mounted consumers. */
export function ConversationListView({
children,
store,
}: {
children: (conversations: Conversation[]) => ReactNode;
store: ConversationStore;
}): ReactNode {
return children(useConversationList(store));
}
/**
* Commits that change the list: prunes deltas the committed list contains
* (after the panes showing them have committed, so text never flickers) and
* runs `onCommit` after each change, like an App effect on `conversations`.
*/
export function ConversationStoreEffects({
liveMessages,
onCommit,
store,
}: {
liveMessages: LiveMessageStore;
onCommit: () => void;
store: ConversationStore;
}): null {
const conversations = useConversationList(store);
useLayoutEffect(() => {
liveMessages.prune(conversations);
}, [conversations, liveMessages]);
useEffect(() => {
onCommit();
// Only list changes run the callback; its identity is irrelevant.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [conversations]);
return null;
}
export const ConversationStoreContext = createContext<ConversationStore | null>(null);
export function useConversationStore(): ConversationStore {
const store = useContext(ConversationStoreContext);
if (!store) throw new Error("ConversationStoreContext is missing");
return store;
}
+32
View File
@@ -6424,6 +6424,38 @@ button > svg {
gap: 4px;
}
/*
* Windowed timeline (PERF-14): only rows near the viewport are mounted;
* spacers stand in for the rest. Each row wrapper carries the list gap as
* padding so its measured height is its full share of the list.
*/
.message-list--windowed {
gap: 0;
}
.message-list--windowed > .load-earlier-messages {
margin-bottom: 4px;
}
.message-window-row {
display: flex;
min-width: 0;
flex-direction: column;
padding-bottom: 4px;
gap: 4px;
}
.message-window-spacer {
flex: 0 0 auto;
pointer-events: none;
}
/* The pane keeps the reader's row in place itself; native scroll anchoring
could pick a spacer and fight the correction. */
.chat-history-pane > .chat {
overflow-anchor: none;
}
.load-earlier-messages {
align-self: center;
min-height: var(--control-height);
@@ -1,4 +1,4 @@
import { StrictMode } from 'react'
import { memo, StrictMode } from 'react'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { Conversation } from './chat-conversation'
@@ -146,6 +146,17 @@ describe('useConversationListOrder', () => {
expect(ids()).toEqual([])
})
it('sorts the latest input on a tick inside a memo component', () => {
const MemoList = memo(List)
const a = conversation('a', 20)
const { rerender } = render(<MemoList items={[conversation('x', 5)]} />)
rerender(<MemoList items={[a, conversation('b', 10)]} scope="project" />)
expect(ids()).toEqual(['a', 'b'])
rerender(<MemoList items={[a, conversation('b', 30)]} scope="project" />)
advance(5000)
expect(ids()).toEqual(['b', 'a'])
})
it('cleans up the interval on unmount and does not duplicate it in Strict Mode', () => {
const { unmount } = render(<StrictMode><List items={[]} /></StrictMode>)
expect(vi.getTimerCount()).toBe(1)
@@ -1,4 +1,4 @@
import { useEffect, useEffectEvent, useMemo, useRef, useState } from 'react'
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { sortConversationsForDisplay, type Conversation } from './chat-conversation'
export function useConversationListOrder(
@@ -16,8 +16,10 @@ export function useConversationListOrder(
}))
const byId = useMemo(() => new Map(conversations.map(item => [item.id, item])), [conversations])
const reconciled = useMemo(() => {
const listed = new Set(order.ids)
// New, repinned, or (after a reorder from older input) unlisted rows.
const changed = new Set(conversations.filter(item =>
!order.pins.has(item.id) || order.pins.get(item.id) !== Boolean(item.pinned),
!order.pins.has(item.id) || order.pins.get(item.id) !== Boolean(item.pinned) || !listed.has(item.id),
).map(item => item.id))
const scopeChanged = order.scope !== scope
if (!scopeChanged && !changed.size && order.ids.length === byId.size) return order
@@ -37,15 +39,21 @@ export function useConversationListOrder(
}, [byId, conversations, order, scope])
if (reconciled !== order) setOrder(reconciled)
const reorder = useEffectEvent(() => {
if (menuOpen || mouseWithin.current || Date.now() < scrollIdleAt.current ||
listRef.current?.contains(document.activeElement)) return
const ids = sortConversationsForDisplay(conversations).map(item => item.id)
setOrder(current => ids.every((id, index) => current.ids[index] === id) && ids.length === current.ids.length
? current : { ...current, ids })
// Latest inputs for the interval. Not useEffectEvent: React 19.2 never
// refreshes effect events of simple memo components (such as the sidebar),
// so the tick would sort a stale list.
const latest = useRef({ conversations, menuOpen })
useLayoutEffect(() => {
latest.current = { conversations, menuOpen }
})
useEffect(() => {
const timer = window.setInterval(() => reorder(), 5000)
const timer = window.setInterval(() => {
if (latest.current.menuOpen || mouseWithin.current || Date.now() < scrollIdleAt.current ||
listRef.current?.contains(document.activeElement)) return
const ids = sortConversationsForDisplay(latest.current.conversations).map(item => item.id)
setOrder(current => ids.every((id, index) => current.ids[index] === id) && ids.length === current.ids.length
? current : { ...current, ids })
}, 5000)
return () => window.clearInterval(timer)
}, [])
+2 -2
View File
@@ -575,7 +575,7 @@ try {
return { latency: { count: 1, p50: openMs, p95: openMs, max: openMs }, fields: { openMs, renderedMessages } }
})
await measure('scroll-long-conversation', 'Wheel-scroll up through the long conversation for 3 s', async () => {
await measure('scroll-long-conversation', 'Wheel-scroll up through the long conversation for 3 s', () => withRendererProfile('scroll-long-conversation', async () => {
const point = await js(`(() => { const e = document.querySelector('.chat-history-pane[data-active=true] #chat-message-list') || document.querySelector('.chat-history-pane[data-active=true]'); const r = e.getBoundingClientRect(); return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; })()`)
const scroller = `(() => { const p = document.querySelector('.chat-history-pane[data-active=true]'); const c = p && [p, ...p.querySelectorAll('*')].find(e => e.scrollHeight > e.clientHeight + 4 && /(auto|scroll)/.test(getComputedStyle(e).overflowY)); return c ? c.scrollTop : null; })()`;
const before = await js(scroller)
@@ -587,7 +587,7 @@ try {
await sleep(300)
const after = await js(scroller)
return { fields: { scrollTopBefore: before, scrollTopAfter: after, scrolledPx: before === null || after === null ? null : before - after } }
})
}))
await screenshot('long-conversation')
+22 -5
View File
@@ -120,11 +120,22 @@ app.whenReady().then(async () => {
await waitFor("!document.querySelector('.runtime-checklist__content')")
await evaluate(`document.querySelector('.chat-history-pane[data-active="true"] .chat').scrollTop = 400`)
await waitFor("document.querySelector('.chat-scroll-to-bottom') !== null")
const toggleAnchor = await evaluate(`document.querySelector('.chat-history-pane[data-active="true"] article').getBoundingClientRect().top`)
// The timeline is windowed (PERF-14): the first mounted row can change while
// the reader's place stays put, so track one visible message by id.
const anchorRow = await evaluate(`(() => {
const pane = document.querySelector('.chat-history-pane[data-active="true"]');
const top = pane.querySelector('.chat').getBoundingClientRect().top;
const row = [...pane.querySelectorAll('article')].find(a => a.getBoundingClientRect().bottom > top + 1);
const id = row.closest('[data-message-id]')?.dataset.messageId ?? row.id;
return { id, top: row.getBoundingClientRect().top };
})()`)
const anchorTop = `(() => { const pane = document.querySelector('.chat-history-pane[data-active="true"]');
const wrapper = pane.querySelector('[data-message-id=${JSON.stringify(anchorRow.id)}]');
return (wrapper?.querySelector('article') ?? document.getElementById(${JSON.stringify(anchorRow.id)})).getBoundingClientRect().top; })()`
for (const enabled of [true, false]) {
await evaluate(`for (const e of [document.documentElement, document.querySelector('.app-shell')]) e.dataset.frostedGlass = '${enabled}'`)
await evaluate('new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))')
assert.equal(await evaluate(`document.querySelector('.chat-history-pane[data-active="true"] article').getBoundingClientRect().top`), toggleAnchor, 'toggle preserves reader position')
assert.equal(await evaluate(anchorTop), anchorRow.top, 'toggle preserves reader position')
}
for (const theme of ['light', 'dark']) {
await evaluate(`document.documentElement.dataset.theme = '${theme}'`)
@@ -169,10 +180,16 @@ app.whenReady().then(async () => {
await waitFor(`document.querySelector('.chat-history-pane[data-active="true"]').dataset.conversationId === 'a'`)
assert.equal(await evaluate(`document.querySelector('.chat-history-pane[data-active="true"] .chat').scrollTop`), 400, 'keep-alive scroll position')
await evaluate(`document.querySelector('.chat-history-pane[data-active="true"] .chat').scrollTop = 0`)
const anchor = await evaluate(`document.querySelector('.chat-history-pane[data-active="true"] article').getBoundingClientRect().top`)
const firstRowId = `document.querySelector('.chat-history-pane[data-active="true"] [data-message-id]').dataset.messageId`
const prependId = await evaluate(firstRowId)
const rowTop = id => `document.querySelector('.chat-history-pane[data-active="true"] [data-message-id=${JSON.stringify(id)}] article').getBoundingClientRect().top`
const anchor = await evaluate(rowTop(prependId))
await click('.load-earlier-messages')
await waitFor(`document.querySelectorAll('.chat-history-pane[data-active="true"] article').length === 100`)
const restoredAnchor = await evaluate(`document.querySelectorAll('.chat-history-pane[data-active="true"] article')[20].getBoundingClientRect().top`)
// Earlier rows load above; the windowed list may mount some of them, but the
// row the reader was looking at must not move.
await waitFor(`${firstRowId} !== ${JSON.stringify(prependId)}`)
await evaluate('new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))')
const restoredAnchor = await evaluate(rowTop(prependId))
assert.ok(Math.abs(restoredAnchor - anchor) < 1, 'prepend preserves message anchor')
await click('.runtime-checklist__toggle')
await waitFor(`!!document.querySelector('.runtime-checklist__content')`)