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();
|
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([
|
vi.mocked(api.conversations.list).mockResolvedValueOnce([
|
||||||
{
|
{
|
||||||
id: "00000000-0000-4000-8000-000000000421",
|
id: "00000000-0000-4000-8000-000000000421",
|
||||||
@@ -4474,25 +4474,28 @@ describe("App", () => {
|
|||||||
const { container } = render(<App />);
|
const { container } = render(<App />);
|
||||||
|
|
||||||
expect(await screen.findByText("历史消息 160")).toBeInTheDocument();
|
expect(await screen.findByText("历史消息 160")).toBeInTheDocument();
|
||||||
// The first paint shows only the trailing messages; the rest of the batch
|
// Only the rows near the bottom are mounted (PERF-14); a spacer stands in
|
||||||
// follows in steps.
|
// for the rest of the 80-message batch.
|
||||||
expect(container.querySelectorAll(".message").length).toBeLessThanOrEqual(20);
|
const mounted = container.querySelectorAll(".message").length;
|
||||||
|
expect(mounted).toBeGreaterThan(0);
|
||||||
|
expect(mounted).toBeLessThan(80);
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("button", { name: "加载更早的消息(还剩 81 条)" }),
|
screen.getByRole("button", { name: "加载更早的消息(还剩 81 条)" }),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
await waitFor(() =>
|
|
||||||
expect(container.querySelectorAll(".message")).toHaveLength(80),
|
|
||||||
);
|
|
||||||
expect(screen.queryByText("历史消息 080")).not.toBeInTheDocument();
|
expect(screen.queryByText("历史消息 080")).not.toBeInTheDocument();
|
||||||
const chat = container.querySelector<HTMLElement>(".chat");
|
const chat = container.querySelector<HTMLElement>(".chat");
|
||||||
if (!chat) {
|
if (!chat) {
|
||||||
throw new Error("Missing chat scroll container");
|
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, {
|
Object.defineProperties(chat, {
|
||||||
clientHeight: { configurable: true, value: 400 },
|
clientHeight: { configurable: true, value: 400 },
|
||||||
scrollHeight: {
|
scrollHeight: {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
get: () => container.querySelectorAll(".message").length * 10,
|
get: () => container.querySelectorAll(".message").length * 10 + topSpacerHeight(),
|
||||||
},
|
},
|
||||||
scrollTop: {
|
scrollTop: {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
@@ -4507,24 +4510,26 @@ describe("App", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(container.querySelectorAll(".message")).toHaveLength(160);
|
expect(
|
||||||
expect(screen.getByText("历史消息 001")).toBeInTheDocument();
|
screen.getByRole("button", { name: "加载更早的消息(还剩 1 条)" }),
|
||||||
expect(screen.queryByText("历史消息 000")).not.toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
expect(chat.scrollTop).toBe(925);
|
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(
|
fireEvent.click(
|
||||||
screen.getByRole("button", {
|
screen.getByRole("button", {
|
||||||
name: "加载更早的消息(还剩 1 条)",
|
name: "加载更早的消息(还剩 1 条)",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
expect(container.querySelectorAll(".message")).toHaveLength(161);
|
|
||||||
expect(screen.getByText("历史消息 000")).toBeInTheDocument();
|
expect(screen.getByText("历史消息 000")).toBeInTheDocument();
|
||||||
expect(
|
expect(
|
||||||
screen.queryByRole("button", { name: /加载更早的消息/u }),
|
screen.queryByRole("button", { name: /加载更早的消息/u }),
|
||||||
).not.toBeInTheDocument();
|
).not.toBeInTheDocument();
|
||||||
expect(screen.getByText("历史消息 000").closest("article")).toHaveFocus();
|
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([
|
vi.mocked(api.conversations.list).mockResolvedValueOnce([
|
||||||
{
|
{
|
||||||
id: "00000000-0000-4000-8000-000000000422",
|
id: "00000000-0000-4000-8000-000000000422",
|
||||||
@@ -4551,21 +4556,17 @@ describe("App", () => {
|
|||||||
try {
|
try {
|
||||||
expect(await screen.findByText("分步消息 160")).toBeInTheDocument();
|
expect(await screen.findByText("分步消息 160")).toBeInTheDocument();
|
||||||
record();
|
record();
|
||||||
await waitFor(() =>
|
await act(
|
||||||
expect(container.querySelectorAll(".message")).toHaveLength(80),
|
() =>
|
||||||
|
new Promise<void>((resolve) => requestAnimationFrame(() => setTimeout(resolve, 20))),
|
||||||
);
|
);
|
||||||
record();
|
record();
|
||||||
} finally {
|
} finally {
|
||||||
observer.disconnect();
|
observer.disconnect();
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(counts[0]).toBeLessThanOrEqual(20);
|
expect(counts).toHaveLength(1);
|
||||||
expect(counts.at(-1)).toBe(80);
|
expect(counts[0]).toBeLessThan(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(
|
expect(
|
||||||
screen.getByRole("button", { name: "加载更早的消息(还剩 81 条)" }),
|
screen.getByRole("button", { name: "加载更早的消息(还剩 81 条)" }),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
@@ -4820,6 +4821,27 @@ describe("App", () => {
|
|||||||
expect(composer).toHaveValue("");
|
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 () => {
|
it("isolates history and task strips while typing during a running request", async () => {
|
||||||
const conversationId = crypto.randomUUID();
|
const conversationId = crypto.randomUUID();
|
||||||
vi.mocked(api.conversations.list).mockResolvedValueOnce([{
|
vi.mocked(api.conversations.list).mockResolvedValueOnce([{
|
||||||
@@ -4929,7 +4951,13 @@ describe("App", () => {
|
|||||||
const firstPane = container.querySelector<HTMLElement>(
|
const firstPane = container.querySelector<HTMLElement>(
|
||||||
`[data-conversation-id="${firstConversationId}"]`,
|
`[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");
|
const firstChat = firstPane?.querySelector<HTMLElement>(".chat");
|
||||||
if (!firstChat) {
|
if (!firstChat) {
|
||||||
throw new Error("Missing first chat scroll container");
|
throw new Error("Missing first chat scroll container");
|
||||||
@@ -4951,7 +4979,7 @@ describe("App", () => {
|
|||||||
target: { value: "第一段会话草稿" },
|
target: { value: "第一段会话草稿" },
|
||||||
});
|
});
|
||||||
expect(messageRenderProbe).not.toHaveBeenCalled();
|
expect(messageRenderProbe).not.toHaveBeenCalled();
|
||||||
expect(firstPane?.querySelectorAll(".message")).toHaveLength(160);
|
expect(firstPane?.querySelectorAll(".message")).toHaveLength(mountedRows);
|
||||||
fireEvent.click(screen.getByLabelText("添加附件"));
|
fireEvent.click(screen.getByLabelText("添加附件"));
|
||||||
expect(await screen.findByText(draftAttachment.name)).toBeInTheDocument();
|
expect(await screen.findByText(draftAttachment.name)).toBeInTheDocument();
|
||||||
|
|
||||||
@@ -4964,7 +4992,7 @@ describe("App", () => {
|
|||||||
target: { value: "第二段会话草稿" },
|
target: { value: "第二段会话草稿" },
|
||||||
});
|
});
|
||||||
expect(messageRenderProbe).not.toHaveBeenCalled();
|
expect(messageRenderProbe).not.toHaveBeenCalled();
|
||||||
expect(firstPane?.querySelectorAll(".message")).toHaveLength(160);
|
expect(firstPane?.querySelectorAll(".message")).toHaveLength(mountedRows);
|
||||||
const paneOrder = (): string[] =>
|
const paneOrder = (): string[] =>
|
||||||
[...container.querySelectorAll<HTMLElement>(".chat-history-pane")].map(
|
[...container.querySelectorAll<HTMLElement>(".chat-history-pane")].map(
|
||||||
(pane) => pane.dataset.conversationId ?? "",
|
(pane) => pane.dataset.conversationId ?? "",
|
||||||
@@ -4972,14 +5000,14 @@ describe("App", () => {
|
|||||||
const warmPaneOrder = paneOrder();
|
const warmPaneOrder = paneOrder();
|
||||||
fireEvent.click(screen.getByText("第一段长会话").closest("button")!);
|
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.
|
// Chromium resets scrollTop when React moves a kept pane; jsdom does not.
|
||||||
expect(paneOrder()).toEqual(warmPaneOrder);
|
expect(paneOrder()).toEqual(warmPaneOrder);
|
||||||
const restoredFirstPane = container.querySelector<HTMLElement>(
|
const restoredFirstPane = container.querySelector<HTMLElement>(
|
||||||
`[data-conversation-id="${firstConversationId}"]`,
|
`[data-conversation-id="${firstConversationId}"]`,
|
||||||
);
|
);
|
||||||
expect(restoredFirstPane).toBe(firstPane);
|
expect(restoredFirstPane).toBe(firstPane);
|
||||||
expect(restoredFirstPane?.querySelectorAll(".message")).toHaveLength(160);
|
expect(restoredFirstPane?.querySelectorAll(".message")).toHaveLength(mountedRows);
|
||||||
expect(restoredFirstPane?.querySelector(".chat")).toBe(firstChat);
|
expect(restoredFirstPane?.querySelector(".chat")).toBe(firstChat);
|
||||||
expect(firstChat.scrollTop).toBe(225);
|
expect(firstChat.scrollTop).toBe(225);
|
||||||
expect(reasoningDetails).toHaveAttribute("open");
|
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,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
useLayoutEffect,
|
useLayoutEffect,
|
||||||
useMemo,
|
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
@@ -19,21 +18,26 @@ import type {
|
|||||||
import type { ImageOperation } from "../../shared/image-generation-contracts";
|
import type { ImageOperation } from "../../shared/image-generation-contracts";
|
||||||
import { ChatTimeline, type ImageViewerItem, type Message } from "./ChatTimeline";
|
import { ChatTimeline, type ImageViewerItem, type Message } from "./ChatTimeline";
|
||||||
import { isUnusedConversation, type Conversation } from "./chat-conversation";
|
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";
|
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;
|
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;
|
const chatBottomProximity = 96;
|
||||||
|
|
||||||
type ChatQuickAction = {
|
type ChatQuickAction = {
|
||||||
@@ -45,8 +49,88 @@ type ChatQuickAction = {
|
|||||||
export type ChatScrollSnapshot = {
|
export type ChatScrollSnapshot = {
|
||||||
pinnedToBottom: boolean;
|
pinnedToBottom: boolean;
|
||||||
scrollTop: number;
|
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({
|
export const ChatHistoryPane = memo(function ChatHistoryPane({
|
||||||
onAddToNote,
|
onAddToNote,
|
||||||
noteMessageNavigation,
|
noteMessageNavigation,
|
||||||
@@ -115,101 +199,172 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
|
|||||||
}): React.JSX.Element {
|
}): React.JSX.Element {
|
||||||
const { t } = useTranslation("app");
|
const { t } = useTranslation("app");
|
||||||
const headingId = `chat-heading-${conversation.id}`;
|
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 scrollRef = useRef<HTMLElement>(null);
|
||||||
const contextRef = useRef<HTMLDivElement>(null);
|
const contextRef = useRef<HTMLDivElement>(null);
|
||||||
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
const pinnedToBottomRef = useRef(scrollSnapshot?.pinnedToBottom ?? true);
|
const pinnedToBottomRef = useRef(scrollSnapshot?.pinnedToBottom ?? true);
|
||||||
const latestScrollSnapshotRef = useRef(scrollSnapshot);
|
const latestScrollSnapshotRef = useRef(scrollSnapshot);
|
||||||
const restorePendingRef = useRef(true);
|
const restorePendingRef = useRef(true);
|
||||||
const wasActiveRef = useRef(false);
|
const wasActiveRef = useRef(false);
|
||||||
const followFrameRef = useRef<number | undefined>(undefined);
|
const followFrameRef = useRef<number | undefined>(undefined);
|
||||||
|
const anchorRef = useRef<ScrollAnchor | undefined>(undefined);
|
||||||
const prependScrollPositionRef = useRef<
|
const prependScrollPositionRef = useRef<
|
||||||
| {
|
{ scrollHeight: number; scrollTop: number; windowStart: number } | undefined
|
||||||
scrollHeight: number;
|
|
||||||
scrollTop: number;
|
|
||||||
}
|
|
||||||
| undefined
|
|
||||||
>(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 finalRevealedMessageIdRef = useRef<string | undefined>(undefined);
|
||||||
|
const navigationScrolledRef = useRef<number | undefined>(undefined);
|
||||||
const messageArticleRefs = useRef(new Map<string, HTMLElement>());
|
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(
|
const [showScrollToBottom, setShowScrollToBottom] = useState(
|
||||||
scrollSnapshot ? !scrollSnapshot.pinnedToBottom : false,
|
scrollSnapshot ? !scrollSnapshot.pinnedToBottom : false,
|
||||||
);
|
);
|
||||||
// Only a pane that opens at the bottom renders in steps. A saved scroll
|
// Bumped when new row heights change spacer sizes or the window size.
|
||||||
// position or a note target refers to the full batch, so those render it
|
const [, setMeasureVersion] = useState(0);
|
||||||
// at once. `undefined` means the full batch is rendered.
|
// Rows kept mounted outside the range: the focused row, the first message
|
||||||
const [initialRenderLimit, setInitialRenderLimit] = useState<
|
// after the final "load earlier" step.
|
||||||
number | undefined
|
const [keptMessageIds, setKeptMessageIds] = useState<readonly string[]>([]);
|
||||||
>(() =>
|
|
||||||
(scrollSnapshot === undefined || scrollSnapshot.pinnedToBottom) &&
|
const navigationTarget =
|
||||||
noteMessageNavigation?.conversationId !== conversation.id &&
|
noteMessageNavigation?.conversationId === conversation.id
|
||||||
Math.min(visibleMessageCount, conversation.messages.length) >
|
? noteMessageNavigation
|
||||||
initialMessageRenderCount
|
: undefined;
|
||||||
? initialMessageRenderCount
|
const navigationIndex = navigationTarget
|
||||||
: undefined,
|
? 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);
|
if (
|
||||||
// Stepping only covers the batch the pane opened with. A changed batch
|
navigationTarget &&
|
||||||
// ("load earlier", note navigation) or a note target for this pane ends the
|
navigationIndex >= 0 &&
|
||||||
// stepping and renders the full batch at once. Adjusting state during
|
navigationTarget.requestId !== handledNavigation
|
||||||
// render avoids committing a partial step first.
|
) {
|
||||||
const stopStepping =
|
setHandledNavigation(navigationTarget.requestId);
|
||||||
initialRenderLimit !== undefined &&
|
setView(viewAround(heights, messages, windowStart, navigationIndex, heights.viewport / 3));
|
||||||
(visibleMessageCount !== steppedVisibleMessageCount ||
|
}
|
||||||
noteMessageNavigation?.conversationId === conversation.id);
|
|
||||||
if (stopStepping) setInitialRenderLimit(undefined);
|
// A message the user just sent brings the view back to the bottom.
|
||||||
const steppingLimit = stopStepping ? undefined : initialRenderLimit;
|
const [seenMessages, setSeenMessages] = useState(messages);
|
||||||
const initialRenderScrollRef = useRef<
|
if (seenMessages !== messages) {
|
||||||
{ scrollHeight: number; scrollTop: number } | undefined
|
setSeenMessages(messages);
|
||||||
>(undefined);
|
if (
|
||||||
const initialRenderTarget = Math.min(
|
messages.slice(seenMessages.length).some((message) => message.role === "user") &&
|
||||||
visibleMessageCount,
|
view.mode !== "bottom"
|
||||||
conversation.messages.length,
|
) {
|
||||||
);
|
setView(bottomView);
|
||||||
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
|
const sizeAt = sizeAtFor(heights, messages);
|
||||||
// per second and would keep restarting a transition (see
|
const viewportHeight = heights.viewport;
|
||||||
// live-message-store).
|
let range: MessageWindowRange;
|
||||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
if (view.mode === "bottom") {
|
||||||
const frame = requestAnimationFrame(() => {
|
range = tailRange({
|
||||||
timer = setTimeout(() => {
|
sizeAt,
|
||||||
const scrollContainer = scrollRef.current;
|
windowStart,
|
||||||
initialRenderScrollRef.current = scrollContainer
|
count: total,
|
||||||
? {
|
viewportHeight,
|
||||||
scrollHeight: scrollContainer.scrollHeight,
|
overscan: overscanFor(viewportHeight),
|
||||||
scrollTop: scrollContainer.scrollTop,
|
|
||||||
}
|
|
||||||
: undefined;
|
|
||||||
const nextLimit = steppingLimit + catchUpMessageRenderStep;
|
|
||||||
setInitialRenderLimit(
|
|
||||||
nextLimit >= initialRenderTarget ? undefined : nextLimit,
|
|
||||||
);
|
|
||||||
}, 0);
|
|
||||||
});
|
});
|
||||||
return () => {
|
} else {
|
||||||
cancelAnimationFrame(frame);
|
const start = Math.min(Math.max(view.start, windowStart), total);
|
||||||
clearTimeout(timer);
|
// A reader scrolled up near the end still sees rows appended below, up
|
||||||
};
|
// to a bound; the next scroll recomputes the range.
|
||||||
}, [steppingLimit, initialRenderTarget]);
|
const end = view.toEnd
|
||||||
const renderedMessageCount =
|
? Math.min(total, start + maxRenderedMessageCount * 2)
|
||||||
steppingLimit === undefined
|
: Math.min(Math.max(view.end, start), total);
|
||||||
? visibleMessageCount
|
range = end > start
|
||||||
: Math.min(visibleMessageCount, steppingLimit);
|
? { start, end }
|
||||||
const visibleMessageStartIndex = Math.max(
|
: tailRange({ sizeAt, windowStart, count: total, viewportHeight, overscan: overscanFor(viewportHeight) });
|
||||||
0,
|
}
|
||||||
conversation.messages.length - renderedMessageCount,
|
const rendered: number[] = [];
|
||||||
);
|
for (let index = range.start; index < range.end; index += 1) rendered.push(index);
|
||||||
const visibleMessages = useMemo(
|
for (const id of keptMessageIds) {
|
||||||
() => conversation.messages.slice(visibleMessageStartIndex),
|
const index = messages.findIndex((message) => message.id === id);
|
||||||
[conversation.messages, visibleMessageStartIndex],
|
if (index >= windowStart && (index < range.start || index >= range.end)) {
|
||||||
);
|
rendered.push(index);
|
||||||
// The "load earlier" control reflects the real batch, not the partial step.
|
}
|
||||||
const hiddenMessageCount = Math.max(
|
}
|
||||||
0,
|
rendered.sort((left, right) => left - right);
|
||||||
conversation.messages.length - visibleMessageCount,
|
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(() => {
|
useLayoutEffect(() => {
|
||||||
const context = contextRef.current;
|
const context = contextRef.current;
|
||||||
@@ -228,27 +383,62 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
|
|||||||
};
|
};
|
||||||
measure();
|
measure();
|
||||||
if (typeof ResizeObserver !== 'function') return;
|
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();
|
measure();
|
||||||
if (restorePendingRef.current || !pinnedToBottomRef.current) return;
|
let layoutChanged = false;
|
||||||
scrollContainer.scrollTo({ top: scrollContainer.scrollHeight, behavior: 'auto' });
|
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(context);
|
||||||
observer.observe(content);
|
observer.observe(content);
|
||||||
return () => observer.disconnect();
|
observer.observe(scrollContainer);
|
||||||
}, [active]);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!active || noteMessageNavigation?.conversationId !== conversation.id) return;
|
if (!active || !navigationTarget) return;
|
||||||
const element = messageArticleRefs.current.get(noteMessageNavigation.messageId);
|
if (navigationScrolledRef.current === navigationTarget.requestId) return;
|
||||||
|
const element = messageArticleRefs.current.get(navigationTarget.messageId);
|
||||||
if (!element) return;
|
if (!element) return;
|
||||||
const frame = requestAnimationFrame(() => {
|
const frame = requestAnimationFrame(() => {
|
||||||
|
navigationScrolledRef.current = navigationTarget.requestId;
|
||||||
|
pinnedToBottomRef.current = false;
|
||||||
element.scrollIntoView?.({ block: 'center' });
|
element.scrollIntoView?.({ block: 'center' });
|
||||||
element.tabIndex = -1;
|
element.tabIndex = -1;
|
||||||
element.focus({ preventScroll: true });
|
element.focus({ preventScroll: true });
|
||||||
|
// Keep the target mounted while it has focus, and remember the place.
|
||||||
|
setKeptMessageIds([navigationTarget.messageId]);
|
||||||
|
captureAnchor();
|
||||||
});
|
});
|
||||||
return () => cancelAnimationFrame(frame);
|
return () => cancelAnimationFrame(frame);
|
||||||
}, [active, conversation.id, noteMessageNavigation, visibleMessageCount]);
|
}, [active, captureAnchor, navigationTarget, view, visibleMessageCount]);
|
||||||
|
|
||||||
const handleArticleRef = useCallback(
|
const handleArticleRef = useCallback(
|
||||||
(messageId: string, element: HTMLElement | null): void => {
|
(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(
|
const saveScrollPosition = useCallback(
|
||||||
(scrollContainer: HTMLElement): boolean => {
|
(scrollContainer: HTMLElement): boolean => {
|
||||||
const distanceFromBottom =
|
const distanceFromBottom =
|
||||||
@@ -268,9 +474,11 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
|
|||||||
scrollContainer.scrollTop -
|
scrollContainer.scrollTop -
|
||||||
scrollContainer.clientHeight;
|
scrollContainer.clientHeight;
|
||||||
const pinnedToBottom = distanceFromBottom <= chatBottomProximity;
|
const pinnedToBottom = distanceFromBottom <= chatBottomProximity;
|
||||||
|
const anchor = anchorRef.current;
|
||||||
latestScrollSnapshotRef.current = {
|
latestScrollSnapshotRef.current = {
|
||||||
pinnedToBottom,
|
pinnedToBottom,
|
||||||
scrollTop: scrollContainer.scrollTop,
|
scrollTop: scrollContainer.scrollTop,
|
||||||
|
...(anchor ? { anchorMessageId: anchor.id, anchorOffset: anchor.top } : {}),
|
||||||
};
|
};
|
||||||
return pinnedToBottom;
|
return pinnedToBottom;
|
||||||
},
|
},
|
||||||
@@ -296,26 +504,89 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
|
|||||||
|
|
||||||
const updateScrollPosition = useCallback((): void => {
|
const updateScrollPosition = useCallback((): void => {
|
||||||
const scrollContainer = scrollRef.current;
|
const scrollContainer = scrollRef.current;
|
||||||
if (!scrollContainer) {
|
const model = modelRef.current;
|
||||||
|
if (!scrollContainer || !model) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
captureAnchor();
|
||||||
const atBottom = saveScrollPosition(scrollContainer);
|
const atBottom = saveScrollPosition(scrollContainer);
|
||||||
pinnedToBottomRef.current = atBottom;
|
pinnedToBottomRef.current = atBottom;
|
||||||
setShowScrollToBottom(!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(() => {
|
useLayoutEffect(() => {
|
||||||
const previousMessageCount = previousMessageCountRef.current;
|
const previousMessageCount = previousMessageCountRef.current;
|
||||||
if (
|
if (
|
||||||
conversation.messages
|
messages
|
||||||
.slice(previousMessageCount)
|
.slice(previousMessageCount)
|
||||||
.some((message) => message.role === "user")
|
.some((message) => message.role === "user")
|
||||||
) {
|
) {
|
||||||
pinnedToBottomRef.current = true;
|
pinnedToBottomRef.current = true;
|
||||||
}
|
}
|
||||||
previousMessageCountRef.current = conversation.messages.length;
|
previousMessageCountRef.current = messages.length;
|
||||||
}, [conversation.messages]);
|
}, [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(() => {
|
useLayoutEffect(() => {
|
||||||
if (!active) {
|
if (!active) {
|
||||||
wasActiveRef.current = false;
|
wasActiveRef.current = false;
|
||||||
@@ -327,11 +598,38 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
|
|||||||
}
|
}
|
||||||
const activated = !wasActiveRef.current;
|
const activated = !wasActiveRef.current;
|
||||||
wasActiveRef.current = true;
|
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) {
|
if (restorePendingRef.current) {
|
||||||
restorePendingRef.current = false;
|
restorePendingRef.current = false;
|
||||||
if (scrollSnapshot && !scrollSnapshot.pinnedToBottom) {
|
if (scrollSnapshot && !scrollSnapshot.pinnedToBottom) {
|
||||||
pinnedToBottomRef.current = false;
|
restore(scrollSnapshot);
|
||||||
scrollContainer.scrollTop = scrollSnapshot.scrollTop;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} else if (activated && !pinnedToBottomRef.current) {
|
} 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.
|
// them out; restore the reader's last position instead of the top.
|
||||||
const saved = latestScrollSnapshotRef.current;
|
const saved = latestScrollSnapshotRef.current;
|
||||||
if (saved && !saved.pinnedToBottom) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!pinnedToBottomRef.current) {
|
if (!pinnedToBottomRef.current) {
|
||||||
|
correctAnchor();
|
||||||
|
captureAnchor();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (activated) {
|
if (activated || measuredLayout) {
|
||||||
scrollContainer.scrollTo({
|
scrollToBottomNow(scrollContainer);
|
||||||
top: scrollContainer.scrollHeight,
|
|
||||||
behavior: "auto",
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Streaming updates commit many times per frame. Reading scrollHeight here
|
// Streaming updates commit many times per frame. Reading scrollHeight here
|
||||||
@@ -365,9 +680,9 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
|
|||||||
if (!container || !pinnedToBottomRef.current) {
|
if (!container || !pinnedToBottomRef.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
container.scrollTo({ top: container.scrollHeight, behavior: "auto" });
|
scrollToBottomNow(container);
|
||||||
});
|
});
|
||||||
}, [active, conversation.messages, scrollSnapshot, visibleMessageCount]);
|
});
|
||||||
|
|
||||||
useEffect(
|
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 => {
|
const revealEarlierMessages = useCallback((): void => {
|
||||||
setInitialRenderLimit(undefined);
|
|
||||||
const scrollContainer = scrollRef.current;
|
const scrollContainer = scrollRef.current;
|
||||||
if (scrollContainer) {
|
if (scrollContainer) {
|
||||||
prependScrollPositionRef.current = {
|
prependScrollPositionRef.current = {
|
||||||
scrollHeight: scrollContainer.scrollHeight,
|
scrollHeight: scrollContainer.scrollHeight,
|
||||||
scrollTop: scrollContainer.scrollTop,
|
scrollTop: scrollContainer.scrollTop,
|
||||||
|
windowStart,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (
|
if (visibleMessageCount + messageRenderBatchSize >= messages.length) {
|
||||||
visibleMessageCount + messageRenderBatchSize >=
|
const firstId = messages[0]?.id;
|
||||||
conversation.messages.length
|
finalRevealedMessageIdRef.current = firstId;
|
||||||
) {
|
// The control disappears; focus moves to the first message, which
|
||||||
finalRevealedMessageIdRef.current = conversation.messages[0]?.id;
|
// must be mounted for that.
|
||||||
|
if (firstId) setKeptMessageIds([firstId]);
|
||||||
}
|
}
|
||||||
onVisibleMessageCountChange(
|
onVisibleMessageCountChange(
|
||||||
conversation.id,
|
conversation.id,
|
||||||
@@ -435,7 +716,8 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
|
|||||||
);
|
);
|
||||||
}, [
|
}, [
|
||||||
conversation.id,
|
conversation.id,
|
||||||
conversation.messages,
|
windowStart,
|
||||||
|
messages,
|
||||||
onVisibleMessageCountChange,
|
onVisibleMessageCountChange,
|
||||||
visibleMessageCount,
|
visibleMessageCount,
|
||||||
]);
|
]);
|
||||||
@@ -447,6 +729,7 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
|
|||||||
}
|
}
|
||||||
pinnedToBottomRef.current = true;
|
pinnedToBottomRef.current = true;
|
||||||
setShowScrollToBottom(false);
|
setShowScrollToBottom(false);
|
||||||
|
setView(bottomView);
|
||||||
const reduceMotion =
|
const reduceMotion =
|
||||||
typeof window.matchMedia === "function" &&
|
typeof window.matchMedia === "function" &&
|
||||||
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||||
@@ -476,7 +759,7 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
|
|||||||
onScroll={updateScrollPosition}
|
onScroll={updateScrollPosition}
|
||||||
ref={handleScrollRef}
|
ref={handleScrollRef}
|
||||||
>
|
>
|
||||||
<div className="chat-content">
|
<div className="chat-content" ref={listRef}>
|
||||||
{isUnusedConversation(conversation) && (
|
{isUnusedConversation(conversation) && (
|
||||||
<div className="welcome">
|
<div className="welcome">
|
||||||
<div className="welcome__badge">
|
<div className="welcome__badge">
|
||||||
@@ -509,8 +792,8 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
|
|||||||
hiddenMessageCount={hiddenMessageCount}
|
hiddenMessageCount={hiddenMessageCount}
|
||||||
isUnusedConversation={isUnusedConversation(conversation)}
|
isUnusedConversation={isUnusedConversation(conversation)}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
messages={visibleMessages}
|
messages={messages}
|
||||||
messageStartIndex={visibleMessageStartIndex}
|
messageStartIndex={0}
|
||||||
onArticleRef={handleArticleRef}
|
onArticleRef={handleArticleRef}
|
||||||
onCopyMessage={onCopyMessage}
|
onCopyMessage={onCopyMessage}
|
||||||
onAddToNote={onAddToNote}
|
onAddToNote={onAddToNote}
|
||||||
@@ -522,13 +805,15 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
|
|||||||
onRespondQuestion={onRespondQuestion}
|
onRespondQuestion={onRespondQuestion}
|
||||||
onRetry={onRetry}
|
onRetry={onRetry}
|
||||||
onRevealEarlier={revealEarlierMessages}
|
onRevealEarlier={revealEarlierMessages}
|
||||||
|
onRowRef={handleRowRef}
|
||||||
renderAssistantHtml={conversationHtmlRenderingEnabled}
|
renderAssistantHtml={conversationHtmlRenderingEnabled}
|
||||||
retryContent={
|
retryContent={
|
||||||
conversation.messages.at(-2)?.role === "user"
|
messages.at(-2)?.role === "user"
|
||||||
? conversation.messages.at(-2)?.content
|
? messages.at(-2)?.content
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
totalMessageCount={conversation.messages.length}
|
segments={segments}
|
||||||
|
totalMessageCount={total}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ import { useLiveMessage } from './live-message-store'
|
|||||||
import { formatTime, type TimeFormatLocale } from './time-format'
|
import { formatTime, type TimeFormatLocale } from './time-format'
|
||||||
import { formatCompactTokens } from './token-format'
|
import { formatCompactTokens } from './token-format'
|
||||||
import { isCancelledMessage } from './message-terminal-status'
|
import { isCancelledMessage } from './message-terminal-status'
|
||||||
|
import type { MessageWindowSegment } from './chat-message-window'
|
||||||
|
|
||||||
|
|
||||||
export type ToolActivity = ConversationToolActivity
|
export type ToolActivity = ConversationToolActivity
|
||||||
|
|
||||||
@@ -1343,6 +1345,39 @@ export const ChatMessageRow = memo(function ChatMessageRow(
|
|||||||
return <ChatMessageRowView {...props} message={useLiveMessage(props.message)} />
|
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 = {
|
type ChatTimelineProps = {
|
||||||
onAddToNote?: ChatMessageRowProps['onAddToNote']
|
onAddToNote?: ChatMessageRowProps['onAddToNote']
|
||||||
onOpenImageModelSettings?: () => void
|
onOpenImageModelSettings?: () => void
|
||||||
@@ -1382,6 +1417,14 @@ type ChatTimelineProps = {
|
|||||||
renderAssistantHtml?: boolean
|
renderAssistantHtml?: boolean
|
||||||
retryContent?: string
|
retryContent?: string
|
||||||
totalMessageCount: number
|
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({
|
export const ChatTimeline = memo(function ChatTimeline({
|
||||||
@@ -1408,10 +1451,84 @@ export const ChatTimeline = memo(function ChatTimeline({
|
|||||||
onRevealEarlier,
|
onRevealEarlier,
|
||||||
renderAssistantHtml = false,
|
renderAssistantHtml = false,
|
||||||
retryContent,
|
retryContent,
|
||||||
totalMessageCount
|
totalMessageCount,
|
||||||
|
segments,
|
||||||
|
onRowRef
|
||||||
}: ChatTimelineProps): React.JSX.Element {
|
}: ChatTimelineProps): React.JSX.Element {
|
||||||
const { t } = useTranslation('app')
|
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 (
|
return (
|
||||||
<div className="message-list">
|
<div className="message-list">
|
||||||
{hiddenMessageCount > 0 && (
|
{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"
|
(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;
|
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 {
|
.load-earlier-messages {
|
||||||
align-self: center;
|
align-self: center;
|
||||||
min-height: var(--control-height);
|
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 { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import type { Conversation } from './chat-conversation'
|
import type { Conversation } from './chat-conversation'
|
||||||
@@ -146,6 +146,17 @@ describe('useConversationListOrder', () => {
|
|||||||
expect(ids()).toEqual([])
|
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', () => {
|
it('cleans up the interval on unmount and does not duplicate it in Strict Mode', () => {
|
||||||
const { unmount } = render(<StrictMode><List items={[]} /></StrictMode>)
|
const { unmount } = render(<StrictMode><List items={[]} /></StrictMode>)
|
||||||
expect(vi.getTimerCount()).toBe(1)
|
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'
|
import { sortConversationsForDisplay, type Conversation } from './chat-conversation'
|
||||||
|
|
||||||
export function useConversationListOrder(
|
export function useConversationListOrder(
|
||||||
@@ -16,8 +16,10 @@ export function useConversationListOrder(
|
|||||||
}))
|
}))
|
||||||
const byId = useMemo(() => new Map(conversations.map(item => [item.id, item])), [conversations])
|
const byId = useMemo(() => new Map(conversations.map(item => [item.id, item])), [conversations])
|
||||||
const reconciled = useMemo(() => {
|
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 =>
|
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))
|
).map(item => item.id))
|
||||||
const scopeChanged = order.scope !== scope
|
const scopeChanged = order.scope !== scope
|
||||||
if (!scopeChanged && !changed.size && order.ids.length === byId.size) return order
|
if (!scopeChanged && !changed.size && order.ids.length === byId.size) return order
|
||||||
@@ -37,15 +39,21 @@ export function useConversationListOrder(
|
|||||||
}, [byId, conversations, order, scope])
|
}, [byId, conversations, order, scope])
|
||||||
if (reconciled !== order) setOrder(reconciled)
|
if (reconciled !== order) setOrder(reconciled)
|
||||||
|
|
||||||
const reorder = useEffectEvent(() => {
|
// Latest inputs for the interval. Not useEffectEvent: React 19.2 never
|
||||||
if (menuOpen || mouseWithin.current || Date.now() < scrollIdleAt.current ||
|
// refreshes effect events of simple memo components (such as the sidebar),
|
||||||
listRef.current?.contains(document.activeElement)) return
|
// so the tick would sort a stale list.
|
||||||
const ids = sortConversationsForDisplay(conversations).map(item => item.id)
|
const latest = useRef({ conversations, menuOpen })
|
||||||
setOrder(current => ids.every((id, index) => current.ids[index] === id) && ids.length === current.ids.length
|
useLayoutEffect(() => {
|
||||||
? current : { ...current, ids })
|
latest.current = { conversations, menuOpen }
|
||||||
})
|
})
|
||||||
useEffect(() => {
|
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)
|
return () => window.clearInterval(timer)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
|||||||
@@ -575,7 +575,7 @@ try {
|
|||||||
return { latency: { count: 1, p50: openMs, p95: openMs, max: openMs }, fields: { openMs, renderedMessages } }
|
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 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 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)
|
const before = await js(scroller)
|
||||||
@@ -587,7 +587,7 @@ try {
|
|||||||
await sleep(300)
|
await sleep(300)
|
||||||
const after = await js(scroller)
|
const after = await js(scroller)
|
||||||
return { fields: { scrollTopBefore: before, scrollTopAfter: after, scrolledPx: before === null || after === null ? null : before - after } }
|
return { fields: { scrollTopBefore: before, scrollTopAfter: after, scrolledPx: before === null || after === null ? null : before - after } }
|
||||||
})
|
}))
|
||||||
|
|
||||||
await screenshot('long-conversation')
|
await screenshot('long-conversation')
|
||||||
|
|
||||||
|
|||||||
@@ -120,11 +120,22 @@ app.whenReady().then(async () => {
|
|||||||
await waitFor("!document.querySelector('.runtime-checklist__content')")
|
await waitFor("!document.querySelector('.runtime-checklist__content')")
|
||||||
await evaluate(`document.querySelector('.chat-history-pane[data-active="true"] .chat').scrollTop = 400`)
|
await evaluate(`document.querySelector('.chat-history-pane[data-active="true"] .chat').scrollTop = 400`)
|
||||||
await waitFor("document.querySelector('.chat-scroll-to-bottom') !== null")
|
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]) {
|
for (const enabled of [true, false]) {
|
||||||
await evaluate(`for (const e of [document.documentElement, document.querySelector('.app-shell')]) e.dataset.frostedGlass = '${enabled}'`)
|
await evaluate(`for (const e of [document.documentElement, document.querySelector('.app-shell')]) e.dataset.frostedGlass = '${enabled}'`)
|
||||||
await evaluate('new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))')
|
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']) {
|
for (const theme of ['light', 'dark']) {
|
||||||
await evaluate(`document.documentElement.dataset.theme = '${theme}'`)
|
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'`)
|
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')
|
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`)
|
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 click('.load-earlier-messages')
|
||||||
await waitFor(`document.querySelectorAll('.chat-history-pane[data-active="true"] article').length === 100`)
|
// Earlier rows load above; the windowed list may mount some of them, but the
|
||||||
const restoredAnchor = await evaluate(`document.querySelectorAll('.chat-history-pane[data-active="true"] article')[20].getBoundingClientRect().top`)
|
// 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')
|
assert.ok(Math.abs(restoredAnchor - anchor) < 1, 'prepend preserves message anchor')
|
||||||
await click('.runtime-checklist__toggle')
|
await click('.runtime-checklist__toggle')
|
||||||
await waitFor(`!!document.querySelector('.runtime-checklist__content')`)
|
await waitFor(`!!document.querySelector('.runtime-checklist__content')`)
|
||||||
|
|||||||
Reference in New Issue
Block a user