perf: move conversations into an external store and window the message timeline
This commit is contained in:
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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('重新编辑')
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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 }
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)
|
||||
}, [])
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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')`)
|
||||
|
||||
Reference in New Issue
Block a user