@@ -509,8 +792,8 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
hiddenMessageCount={hiddenMessageCount}
isUnusedConversation={isUnusedConversation(conversation)}
locale={locale}
- messages={visibleMessages}
- messageStartIndex={visibleMessageStartIndex}
+ messages={messages}
+ messageStartIndex={0}
onArticleRef={handleArticleRef}
onCopyMessage={onCopyMessage}
onAddToNote={onAddToNote}
@@ -522,13 +805,15 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
onRespondQuestion={onRespondQuestion}
onRetry={onRetry}
onRevealEarlier={revealEarlierMessages}
+ onRowRef={handleRowRef}
renderAssistantHtml={conversationHtmlRenderingEnabled}
retryContent={
- conversation.messages.at(-2)?.role === "user"
- ? conversation.messages.at(-2)?.content
+ messages.at(-2)?.role === "user"
+ ? messages.at(-2)?.content
: undefined
}
- totalMessageCount={conversation.messages.length}
+ segments={segments}
+ totalMessageCount={total}
/>
diff --git a/src/renderer/src/ChatTimeline.tsx b/src/renderer/src/ChatTimeline.tsx
index 313e4386..541a1d33 100644
--- a/src/renderer/src/ChatTimeline.tsx
+++ b/src/renderer/src/ChatTimeline.tsx
@@ -43,6 +43,8 @@ import { useLiveMessage } from './live-message-store'
import { formatTime, type TimeFormatLocale } from './time-format'
import { formatCompactTokens } from './token-format'
import { isCancelledMessage } from './message-terminal-status'
+import type { MessageWindowSegment } from './chat-message-window'
+
export type ToolActivity = ConversationToolActivity
@@ -1343,6 +1345,39 @@ export const ChatMessageRow = memo(function ChatMessageRow(
return
})
+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 (
+
+
+
+ )
+})
+
type ChatTimelineProps = {
onAddToNote?: ChatMessageRowProps['onAddToNote']
onOpenImageModelSettings?: () => void
@@ -1382,6 +1417,14 @@ type ChatTimelineProps = {
renderAssistantHtml?: boolean
retryContent?: string
totalMessageCount: number
+ /**
+ * Windowed layout (PERF-14). When set, `messages` holds every loaded
+ * message starting at `messageStartIndex`, and only the rows listed here
+ * are mounted; spacers stand in for the rest. Each row is wrapped in an
+ * element reported through `onRowRef` so the pane can measure it.
+ */
+ segments?: readonly MessageWindowSegment[]
+ onRowRef?: (messageId: string, element: HTMLElement | null) => void
}
export const ChatTimeline = memo(function ChatTimeline({
@@ -1408,10 +1451,84 @@ export const ChatTimeline = memo(function ChatTimeline({
onRevealEarlier,
renderAssistantHtml = false,
retryContent,
- totalMessageCount
+ totalMessageCount,
+ segments,
+ onRowRef
}: ChatTimelineProps): React.JSX.Element {
const { t } = useTranslation('app')
+ if (segments) {
+ const rowProps = {
+ onAddToNote,
+ onOpenImageModelSettings,
+ onReselectImageSources,
+ onEditImage,
+ artifactById,
+ conversationId,
+ locale,
+ onArticleRef,
+ onCopyMessage,
+ onDownloadImage,
+ onOpenCitationContext,
+ onOpenCitationSource,
+ onOpenImage,
+ onRespondApproval,
+ onRespondQuestion,
+ onRetry,
+ renderAssistantHtml,
+ retryContent
+ }
+ // One flat keyed list, so the "load earlier" control keeps its DOM node
+ // (and focus) while the rows around it change. The control sits above
+ // the top spacer, at the start of the scrollable history.
+ const children: React.JSX.Element[] = []
+ if (hiddenMessageCount > 0) {
+ children.push(
+
+ )
+ }
+ for (const segment of segments) {
+ if (segment.kind === 'spacer') {
+ children.push(
+
+ )
+ continue
+ }
+ const message = messages[segment.index - messageStartIndex]
+ if (!message) continue
+ children.push(
+
+ )
+ }
+ return (
+
{children}
+ )
+ }
+
return (
{hiddenMessageCount > 0 && (
diff --git a/src/renderer/src/ConversationSidebar.tsx b/src/renderer/src/ConversationSidebar.tsx
new file mode 100644
index 00000000..1968a1fd
--- /dev/null
+++ b/src/renderer/src/ConversationSidebar.tsx
@@ -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
};
+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 (
+
+
+
+ );
+}
+
+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;
+ 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 (
+
+
+ {conversationTasks.length > 0 && (
+
+ )}
+
+ {activity && (
+
+ )}
+
+
+ {menuVisible && createPortal(
+
{
+ 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
('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}`}
+ >
+
+ {!conversation.remote && (
+ <>
+
+ {branchDisabledReason && (
+
+ {branchDisabledReason}
+
+ )}
+ >
+ )}
+ {!conversation.remote && (
+
+ )}
+
+
+ {!conversation.remote && (
+ }
+ 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")}
+ />
+ )}
+ , document.body
+ )}
+ {!conversation.remote &&
+ renaming && (
+
+ )}
+ {tasksExpanded && conversationTasks.length > 0 && (
+
+ {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 (
+ -
+
+
+ );
+ })}
+ {conversationTasks.length > 3 && (
+ -
+
+
+ )}
+
+ )}
+
+ );
+});
+
+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;
+ expandedTaskConversationIds: ReadonlySet;
+ activityByConversationId: ReadonlyMap;
+ activeConversationIds: ReadonlySet;
+ queuedConversationIds: ReadonlySet;
+ unreadConversationIds: ReadonlySet;
+ conversationActionsId: string;
+ confirmingConversationId: string;
+ deletingConversationId: string;
+ renamingConversationId: string;
+ branchingConversationId: string;
+ pinningConversationId: string;
+ assistantSchedules: readonly AssistantSchedule[];
+ selectedAssistantTaskId: string | undefined;
+ actionsRef: React.RefObject;
+ 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(noSearchMatches);
+ const [persistedSearchMatches, setPersistedSearchMatches] = useState(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 (
+
+
+ {!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 (
+
task.id === selectedAssistantTaskId) ? selectedAssistantTaskId : undefined}
+ tasksExpanded={tasksExpanded}
+ unread={unreadConversationIds.has(conversation.id)}
+ />
+ );
+ })}
+ {loadError ? (
+
+ {t("conversation.loadFailed")}
+ {loadError}
+
+
+ ) : filteredConversations.length === 0 ? (
+ deferredSearchQuery.trim() ? (
+
+ {t("conversation.noMatches")}
+ {t("conversation.noMatchesDescription")}
+
+
+ ) : activeProjectKind === "channel" ? (
+
+ {t("conversation.noRemote")}
+ {t("chat.remote.waiting")}
+
+ ) : (
+
+ {t("conversation.empty")}
+ {t("conversation.emptyDescription")}
+
+
+ )
+ ) : null}
+
+
+ );
+});
diff --git a/src/renderer/src/chat-conversation.ts b/src/renderer/src/chat-conversation.ts
index d3fec8a5..88b94dcc 100644
--- a/src/renderer/src/chat-conversation.ts
+++ b/src/renderer/src/chat-conversation.ts
@@ -44,3 +44,10 @@ export function isUnusedConversation(conversation: Conversation): boolean {
(conversation.messageSummary?.firstRole ?? conversation.messages[0]?.role) === "assistant"
);
}
+
+export function getConversationDisplayTitle(
+ conversation: Conversation,
+ defaultTitle: string,
+): string {
+ return isUnusedConversation(conversation) ? defaultTitle : conversation.title;
+}
diff --git a/src/renderer/src/chat-message-window.test.ts b/src/renderer/src/chat-message-window.test.ts
new file mode 100644
index 00000000..fc192c72
--- /dev/null
+++ b/src/renderer/src/chat-message-window.test.ts
@@ -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 }
+ ])
+ })
+})
diff --git a/src/renderer/src/chat-message-window.ts b/src/renderer/src/chat-message-window.ts
new file mode 100644
index 00000000..8a890cfd
--- /dev/null
+++ b/src/renderer/src/chat-message-window.ts
@@ -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();
+ 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();
+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;
+}
diff --git a/src/renderer/src/conversation-selectors.ts b/src/renderer/src/conversation-selectors.ts
new file mode 100644
index 00000000..c40739e9
--- /dev/null
+++ b/src/renderer/src/conversation-selectors.ts
@@ -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 {
+ 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();
+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;
+ completedConversationIds: ReadonlySet;
+ 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);
+}
diff --git a/src/renderer/src/conversation-store.test.tsx b/src/renderer/src/conversation-store.test.tsx
new file mode 100644
index 00000000..a2c10484
--- /dev/null
+++ b/src/renderer/src/conversation-store.test.tsx
@@ -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 {
+ return { id: "m1", role: "assistant", content: "", createdAt: 1, state: "streaming", blocks: [], ...overrides } as Message;
+}
+
+function conversation(id: string, messages: Message[] = [], overrides: Partial = {}): 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 {count};
+ }
+ const view = render();
+ 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)])]);
+ const seen: unknown[] = [];
+ function Approvals({ store }: { store: ConversationStore }) {
+ seen.push(usePendingSidebarApprovals(store));
+ return null;
+ }
+ const view = render();
+ act(() => store.set((current) => [...current, conversation("b")]));
+ view.rerender();
+ 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 {item?.title ?? "none"};
+ }
+ const view = render();
+ 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();
+ 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 {items.map((item) => item.title).join(",")};
+ }
+ 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();
+ 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();
+ 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);
+ });
+});
diff --git a/src/renderer/src/conversation-store.ts b/src/renderer/src/conversation-store.ts
new file mode 100644
index 00000000..fbeac8f6
--- /dev/null
+++ b/src/renderer/src/conversation-store.ts
@@ -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;
+
+export function createConversationStore(
+ initial: Conversation[],
+ liveMessages: LiveMessageStore,
+) {
+ let state = initial;
+ const listeners = new Set();
+ const conversationListeners = new Map>();
+
+ const notify = (previous: readonly Conversation[]): void => {
+ if (conversationListeners.size) {
+ const before = new Map(previous.map((item) => [item.id, item]));
+ const changed = new Set();
+ 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 | 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 = {
+ state: readonly Conversation[];
+ selector: (conversations: Conversation[]) => T;
+ value: T;
+};
+type LatestSelector = {
+ 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