From fdf7d83b20920fe485ef4abd64397f3f8258389d Mon Sep 17 00:00:00 2001 From: mesalogo Date: Sat, 3 Oct 2026 02:53:51 +0800 Subject: [PATCH] perf: move conversations into an external store and window the message timeline --- src/renderer/src/App.test.tsx | 84 +- src/renderer/src/App.tsx | 1012 +++-------------- src/renderer/src/ChatHistoryPane.test.tsx | 378 ++++++ src/renderer/src/ChatHistoryPane.tsx | 611 +++++++--- src/renderer/src/ChatTimeline.tsx | 119 +- src/renderer/src/ConversationSidebar.tsx | 728 ++++++++++++ src/renderer/src/chat-conversation.ts | 7 + src/renderer/src/chat-message-window.test.ts | 92 ++ src/renderer/src/chat-message-window.ts | 257 +++++ src/renderer/src/conversation-selectors.ts | 137 +++ src/renderer/src/conversation-store.test.tsx | 194 ++++ src/renderer/src/conversation-store.ts | 266 +++++ src/renderer/src/styles.css | 32 + .../src/use-conversation-list-order.test.tsx | 13 +- .../src/use-conversation-list-order.ts | 26 +- tests/support/app-perf-driver.mjs | 4 +- tests/support/frosted-glass-driver.mjs | 27 +- 17 files changed, 2922 insertions(+), 1065 deletions(-) create mode 100644 src/renderer/src/ChatHistoryPane.test.tsx create mode 100644 src/renderer/src/ConversationSidebar.tsx create mode 100644 src/renderer/src/chat-message-window.test.ts create mode 100644 src/renderer/src/chat-message-window.ts create mode 100644 src/renderer/src/conversation-selectors.ts create mode 100644 src/renderer/src/conversation-store.test.tsx create mode 100644 src/renderer/src/conversation-store.ts diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index faac5c75..15feca75 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -4455,7 +4455,7 @@ describe("App", () => { expect(scrollTo).not.toHaveBeenCalled(); }); - it("renders the latest 80 messages and preserves scroll when revealing earlier messages", async () => { + it("windows the latest 80 messages and preserves scroll when revealing earlier messages", async () => { vi.mocked(api.conversations.list).mockResolvedValueOnce([ { id: "00000000-0000-4000-8000-000000000421", @@ -4474,25 +4474,28 @@ describe("App", () => { const { container } = render(); expect(await screen.findByText("历史消息 160")).toBeInTheDocument(); - // The first paint shows only the trailing messages; the rest of the batch - // follows in steps. - expect(container.querySelectorAll(".message").length).toBeLessThanOrEqual(20); + // Only the rows near the bottom are mounted (PERF-14); a spacer stands in + // for the rest of the 80-message batch. + const mounted = container.querySelectorAll(".message").length; + expect(mounted).toBeGreaterThan(0); + expect(mounted).toBeLessThan(80); expect( screen.getByRole("button", { name: "加载更早的消息(还剩 81 条)" }), ).toBeInTheDocument(); - await waitFor(() => - expect(container.querySelectorAll(".message")).toHaveLength(80), - ); expect(screen.queryByText("历史消息 080")).not.toBeInTheDocument(); const chat = container.querySelector(".chat"); if (!chat) { throw new Error("Missing chat scroll container"); } + const topSpacerHeight = (): number => + Number.parseFloat( + container.querySelector('[data-window-spacer="top"]')?.style.height ?? "0", + ) || 0; Object.defineProperties(chat, { clientHeight: { configurable: true, value: 400 }, scrollHeight: { configurable: true, - get: () => container.querySelectorAll(".message").length * 10, + get: () => container.querySelectorAll(".message").length * 10 + topSpacerHeight(), }, scrollTop: { configurable: true, @@ -4507,24 +4510,26 @@ describe("App", () => { }), ); - expect(container.querySelectorAll(".message")).toHaveLength(160); - expect(screen.getByText("历史消息 001")).toBeInTheDocument(); - expect(screen.queryByText("历史消息 000")).not.toBeInTheDocument(); - expect(chat.scrollTop).toBe(925); + expect( + screen.getByRole("button", { name: "加载更早的消息(还剩 1 条)" }), + ).toBeInTheDocument(); + expect(container.querySelectorAll(".message")).toHaveLength(mounted); + // The 80 earlier messages land above the reader, who keeps their place. + expect(chat.scrollTop).toBe(125 + 80 * 160); fireEvent.click( screen.getByRole("button", { name: "加载更早的消息(还剩 1 条)", }), ); - expect(container.querySelectorAll(".message")).toHaveLength(161); expect(screen.getByText("历史消息 000")).toBeInTheDocument(); expect( screen.queryByRole("button", { name: /加载更早的消息/u }), ).not.toBeInTheDocument(); expect(screen.getByText("历史消息 000").closest("article")).toHaveFocus(); + expect(container.querySelectorAll(".message").length).toBeLessThanOrEqual(mounted + 1); }); - it("catches up the first message batch in several small steps", async () => { + it("opens a long conversation in one bounded commit without catch-up steps", async () => { vi.mocked(api.conversations.list).mockResolvedValueOnce([ { id: "00000000-0000-4000-8000-000000000422", @@ -4551,21 +4556,17 @@ describe("App", () => { try { expect(await screen.findByText("分步消息 160")).toBeInTheDocument(); record(); - await waitFor(() => - expect(container.querySelectorAll(".message")).toHaveLength(80), + await act( + () => + new Promise((resolve) => requestAnimationFrame(() => setTimeout(resolve, 20))), ); record(); } finally { observer.disconnect(); } - expect(counts[0]).toBeLessThanOrEqual(20); - expect(counts.at(-1)).toBe(80); - // More than one catch-up commit, each adding at most 20 messages. - expect(counts.filter((count) => count > 20 && count < 80).length).toBeGreaterThan(0); - counts.slice(1).forEach((count, index) => { - expect(count - (counts[index] ?? 0)).toBeLessThanOrEqual(20); - }); + expect(counts).toHaveLength(1); + expect(counts[0]).toBeLessThan(80); expect( screen.getByRole("button", { name: "加载更早的消息(还剩 81 条)" }), ).toBeInTheDocument(); @@ -4820,6 +4821,27 @@ describe("App", () => { expect(composer).toHaveValue(""); }); + it("re-renders only the previous and next sidebar rows when switching conversations", async () => { + const conversations = Array.from({ length: 30 }, (_, c) => ({ + id: `00000000-0000-4000-9${String(c).padStart(3, "0")}-000000000000`, + projectId, title: `Switch row ${c}`, updatedAt: 1_000 + c, + messages: [{ + id: `00000000-0000-4000-9${String(c).padStart(3, "0")}-000000000001`, + role: "assistant" as const, content: `Switch body ${c}`, createdAt: 1_000 + c, state: "complete" as const, + }], + })); + vi.mocked(api.conversations.list).mockResolvedValueOnce(conversations); + render(); + fireEvent.click((await screen.findByText("Switch row 3")).closest("button")!); + await screen.findByText("Switch body 3"); + sidebarRenderProbes.row.mockClear(); + fireEvent.click(screen.getByText("Switch row 7").closest("button")!); + await screen.findByText("Switch body 7"); + expect(document.querySelector(".conversation-item--active")).toHaveTextContent("Switch row 7"); + // Each row renders one title marquee; unrelated rows must be skipped. + expect(sidebarRenderProbes.row.mock.calls.length).toBeLessThanOrEqual(4); + }); + it("isolates history and task strips while typing during a running request", async () => { const conversationId = crypto.randomUUID(); vi.mocked(api.conversations.list).mockResolvedValueOnce([{ @@ -4929,7 +4951,13 @@ describe("App", () => { const firstPane = container.querySelector( `[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(".chat"); if (!firstChat) { throw new Error("Missing first chat scroll container"); @@ -4951,7 +4979,7 @@ describe("App", () => { target: { value: "第一段会话草稿" }, }); expect(messageRenderProbe).not.toHaveBeenCalled(); - expect(firstPane?.querySelectorAll(".message")).toHaveLength(160); + expect(firstPane?.querySelectorAll(".message")).toHaveLength(mountedRows); fireEvent.click(screen.getByLabelText("添加附件")); expect(await screen.findByText(draftAttachment.name)).toBeInTheDocument(); @@ -4964,7 +4992,7 @@ describe("App", () => { target: { value: "第二段会话草稿" }, }); expect(messageRenderProbe).not.toHaveBeenCalled(); - expect(firstPane?.querySelectorAll(".message")).toHaveLength(160); + expect(firstPane?.querySelectorAll(".message")).toHaveLength(mountedRows); const paneOrder = (): string[] => [...container.querySelectorAll(".chat-history-pane")].map( (pane) => pane.dataset.conversationId ?? "", @@ -4972,14 +5000,14 @@ describe("App", () => { const warmPaneOrder = paneOrder(); fireEvent.click(screen.getByText("第一段长会话").closest("button")!); - expect(await screen.findByText("第一段历史 001")).toBeInTheDocument(); + expect(await screen.findByText("第一段历史 160")).toBeInTheDocument(); // Chromium resets scrollTop when React moves a kept pane; jsdom does not. expect(paneOrder()).toEqual(warmPaneOrder); const restoredFirstPane = container.querySelector( `[data-conversation-id="${firstConversationId}"]`, ); expect(restoredFirstPane).toBe(firstPane); - expect(restoredFirstPane?.querySelectorAll(".message")).toHaveLength(160); + expect(restoredFirstPane?.querySelectorAll(".message")).toHaveLength(mountedRows); expect(restoredFirstPane?.querySelector(".chat")).toBe(firstChat); expect(firstChat.scrollTop).toBe(225); expect(reasoningDetails).toHaveAttribute("open"); diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index dcfaa108..137b80fb 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -30,18 +30,13 @@ import type { ImageOperation } from "../../shared/image-generation-contracts"; import { Bot, ChartColumn, - Check, CheckCircle2, ChevronDown, ChevronUp, - ChevronRight, CircleAlert, CircleHelp, - Copy, Download, - Edit3, FileText, - GitFork, Grid2X2, Info, Library, @@ -56,8 +51,6 @@ import { MoreHorizontal, Paperclip, PanelLeft, - Pin, - PinOff, Search, Send, Settings, @@ -70,15 +63,12 @@ import { Square, Sun, TerminalSquare, - Trash2, X, } from "lucide-react"; import { Component, - memo, Suspense, useCallback, - useDeferredValue, useEffect, useLayoutEffect, useMemo, @@ -187,18 +177,33 @@ import { type ChatScrollSnapshot, } from "./ChatHistoryPane"; import { reconcilePaneOrder } from "./pane-order"; -import { isUnusedConversation, sortConversationsForDisplay, type Conversation } from "./chat-conversation"; +import { getConversationDisplayTitle, isUnusedConversation, sortConversationsForDisplay, type Conversation } from "./chat-conversation"; +import { + ConversationListView, + ConversationStoreEffects, + createConversationStores, + useConversation, +} from "./conversation-store"; +import { + useConversationActivitySummary, + useConversationCount, + useConversationsById, + useConversationTitles, + usePendingSidebarApprovals, +} from "./conversation-selectors"; +import { + ConversationBranchBadge, + ConversationSidebar, + type ConversationListRowHandlers, + type ConversationSidebarActions, +} from "./ConversationSidebar"; import { ComposerDraftEffect, ComposerDraftHasText, ComposerDraftText, createComposerDraftStore, } from "./composer-draft-store"; -import { - createLiveMessageStore, - LiveMessageStoreContext, - type ConversationsUpdate, -} from "./live-message-store"; +import { LiveMessageStoreContext } from "./live-message-store"; import { clearLegacyActivityHistory, loadLegacyActivityHistory, @@ -212,7 +217,6 @@ import { type KnowledgeCitationContextView, } from "./KnowledgeCitationDialog"; import { - DestructiveConfirmActions, EmptyState, PageShell, SegmentedControl, @@ -220,19 +224,12 @@ import { } from "./WorkspacePrimitives"; import { ProjectSwitcher } from "./ProjectSwitcher"; import { ProjectActivity } from "./ProjectActivity"; -import { - deriveConversationActivity, - sameActivitySummary, - type ConversationActivity, -} from "./conversation-activity"; -import { sameArrayItems, sameMapEntries, useStableDerivedValue, useStableHandlers } from "./stable-derived-value"; +import { sameArrayItems, useStableHandlers } from "./stable-derived-value"; import { useUnviewedCompletions } from "./use-unviewed-completions"; -import { useConversationListOrder } from "./use-conversation-list-order"; import { useExecutionStats } from "./use-execution-stats"; import { RightAssistantSidebar, type AssistantSidebarTab, - type PendingSidebarApproval, type SidebarArtifact, } from "./RightAssistantSidebar"; import { @@ -244,8 +241,6 @@ import { ConversationTaskStrip } from "./ConversationTaskStrip"; import { RuntimeChecklistStrip } from "./RuntimeChecklistStrip"; import { appendConversationQuestionBlock } from "../../shared/conversation-question-blocks"; import { ConversationInputQueue } from "./ConversationInputQueue"; -import { OverflowMarquee } from "./OverflowMarquee"; -import { findTaskSchedule } from "./TaskScheduleActions"; import type { SettingsCategoryId } from "./settings-categories"; import type { SettingsLeaveRequester } from "./SettingsPanel"; import { formatShortcutForDisplay, type GlobalShortcutSettingsSnapshot } from "../../shared/shortcut"; @@ -276,8 +271,6 @@ import type { RemoteProjectRecoveryState } from "../../shared/remote-project-rec import { ReleaseNotesDialog } from "./ReleaseNotesDialog"; import { scheduleIdleRoutePreload } from "./idle-route-preload"; import { createPreloadableComponent } from "./preloadable-component"; -import { formatConversationListTime, type TimeFormatLocale } from "./time-format"; -import { formatMediumDateTime } from "./locale-formatters"; import { formatCompactTokens } from "./token-format"; import { filterKeepAliveEntries, @@ -327,9 +320,8 @@ const SettingsPanel = settingsPanelRoute.Component; const ActivityPanel = activityPanelRoute.Component; const conversationPersistenceIntervalMs = 500; -// How often streaming deltas are absorbed into App state; rows render them live. +// How often streaming deltas are absorbed into the conversation store; rows render them live. const liveMessageFlushIntervalMs = 250; -const conversationSearchSnapshotDelayMs = 250; const keepAliveExpirationMs = 60 * 60 * 1_000; const keepAliveSweepIntervalMs = 5 * 60 * 1_000; const maximumCachedConversations = 12; @@ -639,32 +631,8 @@ const emptyTokenUsage: TokenUsageSummary = { records: [], }; -// 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; -} - -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); -} - const storageKey = "goodbuddy.conversations.v1"; const emptyConversationTasks: AssistantTask[] = []; -const emptyAssistantSchedules: AssistantSchedule[] = []; const activeProjectStorageKey = "goodbuddy.active-project.v1"; @@ -861,33 +829,6 @@ function createConversationBranchTitle( return `${sourceTitle.slice(0, 200 - trailing.length).trimEnd()}${trailing}`; } -function ConversationBranchBadge({ - sourceTitle, -}: { - sourceTitle: string; -}): React.JSX.Element { - const { t } = useTranslation("app"); - const label = t("conversation.branch.badge", { - title: sourceTitle, - }); - return ( - - - ); -} - -function getConversationDisplayTitle( - conversation: Conversation, - defaultTitle: string, -): string { - return isUnusedConversation(conversation) ? defaultTitle : conversation.title; -} - function ConversationHistoryLoader({ conversationId, active, load }: { conversationId: string; active: boolean; @@ -1751,435 +1692,6 @@ function ComposerMenuSelect({ } -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 && ( -
, document.body - )} - {!conversation.remote && - renaming && ( -
{ - event.preventDefault(); - const input = - event.currentTarget.elements.namedItem("title"); - if (input instanceof HTMLInputElement) { - handlers.saveTitle(conversation.id, input.value); - } - }} - > - { - if (event.key === "Escape") { - handlers.cancelRename(conversation.id); - } - }} - pattern=".*\S.*" - required - /> - - -
- )} - {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 && ( -
  • - -
  • - )} -
- )} -
- ); -}); function App(): React.JSX.Element { const { i18n, t } = useTranslation("app"); @@ -2191,46 +1703,21 @@ function App(): React.JSX.Element { const locale = i18n.resolvedLanguage === "en-US" ? "en-US" : "zh-CN"; const [initialConversationMigrationStoragePresent] = useState(hasConversationMigrationStorage); const conversationMigrationStoragePresent = useRef(initialConversationMigrationStoragePresent); - const [conversations, setConversationsState] = useState(() => - loadConversations( - t("conversation.greeting"), - t("conversation.interrupted"), + // The conversation list lives in an external store (conversation-store.ts): + // App and the sidebar select only what they show. Streaming deltas render + // through the live-message store and are absorbed on a slow cadence. + const [{ conversations: conversationStore, liveMessages, cancelLiveMessageFlush }] = useState(() => + createConversationStores( + loadConversations(t("conversation.greeting"), t("conversation.interrupted")), + { flushIntervalMs: liveMessageFlushIntervalMs }, ), ); - // Streaming deltas render through this store; App state absorbs them on a - // slow cadence or with the next conversations update (see live-message-store). - const [{ liveMessages, setConversations, cancelLiveMessageFlush }] = useState(() => { - let flushTimer: ReturnType | undefined; - const update = (next: ConversationsUpdate): void => { - const captured = store.capture(); - setConversationsState((current) => store.applyUpdate(current, next, captured)); - }; - const store = createLiveMessageStore({ - 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 (!store.hasEntries()) return; - update((current) => current); - }, liveMessageFlushIntervalMs); - }, - }); - return { - liveMessages: store, - setConversations: update, - cancelLiveMessageFlush: () => { - clearTimeout(flushTimer); - flushTimer = undefined; - }, - }; - }); + const setConversations = conversationStore.set; + const conversationCount = useConversationCount(conversationStore); const [activeId, setActiveIdState] = useState( - () => conversations[0]?.id ?? "", + () => conversationStore.getState()[0]?.id ?? "", ); const activeConversationIdRef = useRef(activeId); - const conversationsRef = useRef(conversations); const persistedLocalConversationsRef = useRef( new Map(), ); @@ -2244,7 +1731,7 @@ function App(): React.JSX.Element { Set >(() => new Set()); const [conversationStoreReady, setConversationStoreReady] = useState(false); - const migrationConversations = useRef(conversations); + const migrationConversations = useRef(conversationStore.getState()); const [projects, setProjects] = useState([]); const projectsRef = useRef(projects); const [projectRecoveryByProjectId, setProjectRecoveryByProjectId] = useState< @@ -2836,11 +2323,6 @@ function App(): React.JSX.Element { setSearchOpen(false); requestAnimationFrame(() => searchTriggerRef.current?.focus()); }; - const deferredSearchQuery = useDeferredValue(searchQuery); - const [localSearchMatches, setLocalSearchMatches] = - useState<{ query: string; ids: Set }>({ query: "", ids: new Set() }); - const [persistedSearchMatches, setPersistedSearchMatches] = - useState<{ query: string; ids: Set }>({ query: "", ids: new Set() }); const [conversationLoadError, setConversationLoadError] = useState(); const [conversationLoadRetry, setConversationLoadRetry] = useState(0); const [conversationActionsId, setConversationActionsId] = useState(""); @@ -2876,14 +2358,14 @@ function App(): React.JSX.Element { }), []); useEffect(() => { let active = true; - if (attachmentsRef.current.has(activeId) || conversationsRef.current.find((conversation) => conversation.id === activeId)?.remote) return; + if (attachmentsRef.current.has(activeId) || conversationStore.getState().find((conversation) => conversation.id === activeId)?.remote) return; void window.goodbuddy.context.getDraft(activeId).then((saved) => { if (!active || attachmentsRef.current.has(activeId) || !saved.length) return; attachmentsRef.current.set(activeId, saved); setAttachmentsByConversation((current) => ({ ...current, [activeId]: saved })); }).catch((error: unknown) => notify({ tone: 'error', message: error instanceof Error ? error.message : '附件草稿读取失败' })); return () => { active = false; }; - }, [activeId]); + }, [activeId, conversationStore]); const updateAttachments = useCallback( ( update: @@ -2894,7 +2376,7 @@ function App(): React.JSX.Element { const next = typeof update === "function" ? update(current) : update; attachmentSaveQueue.current = attachmentSaveQueue.current.catch(() => undefined).then(async () => { await conversationPersistenceQueueRef.current; - const owner = conversationsRef.current.find((conversation) => conversation.id === activeId); + const owner = conversationStore.getState().find((conversation) => conversation.id === activeId); if (!owner || owner.remote) throw new Error('附件目标会话不可编辑'); if (next.length && !persistedLocalConversationsRef.current.has(activeId)) { await window.goodbuddy.conversations.saveLocal([{ header: toLocalConversationHeader(owner), messages: [] }]); @@ -2916,7 +2398,7 @@ function App(): React.JSX.Element { return remaining; }); }, - [activeId], + [activeId, conversationStore], ); const [contextError, setContextError] = useState(); const [fileSelectionProgress, setFileSelectionProgress] = @@ -3195,7 +2677,7 @@ function App(): React.JSX.Element { } const conversationIds = [ ...new Set( - conversationsRef.current + conversationStore.getState() .filter( (conversation) => !conversation.remote && @@ -3221,13 +2703,13 @@ function App(): React.JSX.Element { dedupeKey: "conversation-queue-resume", }); }); - }, [conversationStoreReady, projectRecoverySnapshotReady, conversations.length, runtimeSettings]); + }, [conversationStoreReady, projectRecoverySnapshotReady, conversationCount, runtimeSettings, conversationStore]); useEffect(() => { const sweep = (): void => { const now = Date.now(); const conversationIds = new Set( - conversationsRef.current.map((conversation) => conversation.id), + conversationStore.getState().map((conversation) => conversation.id), ); const runningConversationIds = new Set( [...activeRuns.current.values()].map((run) => run.conversationId), @@ -3284,7 +2766,7 @@ function App(): React.JSX.Element { }; const interval = window.setInterval(sweep, keepAliveSweepIntervalMs); return () => window.clearInterval(interval); - }, [activeId, assistantTasks, getPinnedWorkspaceViews, knowledgeOperationCount, view]); + }, [activeId, assistantTasks, getPinnedWorkspaceViews, knowledgeOperationCount, view, conversationStore]); useEffect(() => { livePrimarySidebarWidthRef.current = primarySidebarWidth; @@ -3403,46 +2885,8 @@ function App(): React.JSX.Element { }; }, [composerOptionsOpen]); - useLayoutEffect(() => { - conversationsRef.current = conversations; - liveMessages.prune(conversations); - }, [conversations, liveMessages]); - useEffect(() => cancelLiveMessageFlush, [cancelLiveMessageFlush]); - 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, conversationsRef.current.map(item => item.id)).then(ids => { - if (active) setPersistedSearchMatches({ query, ids: new Set(ids) }); - }).catch(() => { - if (active) notify({ - tone: "error", message: tRef.current("notices.remoteConversationRefreshFailed"), - dedupeKey: "conversation-search", - }); - }); - }, conversationSearchSnapshotDelayMs); - return () => { active = false; window.clearTimeout(timeout); }; - }, [deferredSearchQuery, conversationStoreReady, localSearchMatches]); - useEffect(() => { projectsRef.current = projects; }, [projects]); @@ -3623,10 +3067,7 @@ function App(): React.JSX.Element { }; }, []); - const activeConversation = useMemo( - () => conversations.find((conversation) => conversation.id === activeId), - [activeId, conversations], - ); + const activeConversation = useConversation(conversationStore, activeId); const enabledKnowledgeLibraryIds = activeConversation?.knowledgeLibraryIds ?? []; const setEnabledKnowledgeLibraryIds = useCallback( @@ -3705,15 +3146,8 @@ function App(): React.JSX.Element { if (conversationPaneOrder !== conversationPaneOrderState) { setConversationPaneOrderState(conversationPaneOrder); } - const cachedConversations = useMemo(() => { - const conversationById = new Map( - conversations.map((conversation) => [conversation.id, conversation]), - ); - return conversationPaneOrder.flatMap((conversationId) => { - const conversation = conversationById.get(conversationId); - return conversation ? [conversation] : []; - }); - }, [conversationPaneOrder, conversations]); + // App re-renders for changes to these conversations only. + const cachedConversations = useConversationsById(conversationStore, conversationPaneOrder); const activeRuntimeResolution = useMemo( () => @@ -4066,17 +3500,13 @@ function App(): React.JSX.Element { document.removeEventListener("focusin", dismissOnOutsideFocus); }; }, [activeRuntimeSelectionKey, runtimeMenuOpen]); - const conversationNavigationRef = useRef({ - activeId, - conversations, - }); + // The committed active ID, or a just-created conversation until the next + // commit, so repeated new-conversation requests reuse it. + const conversationNavigationRef = useRef({ activeId }); useEffect(() => { - conversationNavigationRef.current = { - activeId, - conversations, - }; - }, [activeId, conversations]); + conversationNavigationRef.current = { activeId }; + }, [activeId]); useEffect(() => { const selection = activeRuntimeSelectionRef.current; @@ -4152,9 +3582,7 @@ function App(): React.JSX.Element { return false; } const navigation = conversationNavigationRef.current; - const currentConversation = navigation.conversations.find( - (conversation) => conversation.id === navigation.activeId, - ); + const currentConversation = conversationStore.getConversation(navigation.activeId); if ( currentConversation && currentConversation.projectId === projectId && @@ -4169,17 +3597,13 @@ function App(): React.JSX.Element { undefined, tRef.current("conversation.greeting"), ); - const nextConversations = [conversation, ...navigation.conversations]; - conversationNavigationRef.current = { - activeId: conversation.id, - conversations: nextConversations, - }; - setConversations(nextConversations); + conversationNavigationRef.current = { activeId: conversation.id }; + setConversations((current) => [conversation, ...current]); if (preview) preview.ready(conversation); else { setActiveId(conversation.id); showChatAndFocusComposer(); } return true; }, - [notify, projects, setActiveId, setView, showChatAndFocusComposer, setConversations], + [notify, projects, setActiveId, setView, showChatAndFocusComposer, setConversations, conversationStore], ); const activeProjectDisplayName = activeProject ? getProjectDisplayText(activeProject, tWorkspace).name @@ -4188,31 +3612,6 @@ function App(): React.JSX.Element { () => new Set(conversationQueueItems.map((item) => item.conversationId)), [conversationQueueItems], ); - const matchingConversations = useMemo(() => { - const query = deferredSearchQuery.trim().toLocaleLowerCase(); - return conversations.filter( - (conversation) => - (!activeProjectId || conversation.projectId === activeProjectId) && - (activeProject?.kind !== "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))), - ); - }, [ - activeProject, - activeProjectId, - conversations, - deferredSearchQuery, - localSearchMatches, - persistedSearchMatches, - ]); - const { conversations: filteredConversations, listProps: conversationListProps } = useConversationListOrder( - matchingConversations, - JSON.stringify([activeProjectId, activeProject?.kind, deferredSearchQuery.trim().toLocaleLowerCase()]), - Boolean(conversationActionsId), - ); const productAssistantTasks = useMemo( () => assistantTasks.filter( @@ -4256,21 +3655,7 @@ function App(): React.JSX.Element { } return grouped; }, [productAssistantTasks]); - // Streaming replaces the conversation array on every delta; keep derived - // values whose content is unchanged so memoized consumers skip re-rendering. - const conversationTitles = useStableDerivedValue(useMemo( - () => - new Map( - conversations.map((conversation) => [ - conversation.id, - getConversationDisplayTitle( - conversation, - t("conversation.defaultTitle"), - ), - ]), - ), - [conversations, t], - ), sameMapEntries); + const conversationTitles = useConversationTitles(conversationStore, t("conversation.defaultTitle")); const projectNames = useMemo( () => new Map( @@ -4285,24 +3670,21 @@ function App(): React.JSX.Element { useUnviewedCompletions(assistantTasks, view === "chat" && !settingsOpen && !applicationCenterOpen && activeConversation && !activeConversation.messageSummary ? activeId : undefined); - const projectActivity = useStableDerivedValue(useMemo( - () => deriveConversationActivity( - conversations.map((conversation) => ({ - ...conversation, - title: conversationTitles.get(conversation.id) ?? conversation.title, - })), - assistantTasks, - activeConversationIds, - projects.map((project) => ({ - id: project.id, - name: projectNames.get(project.id) ?? project.name, - })), - tWorkspace("projectActivity.unassigned"), - completedConversationIds, - ), - [conversations, conversationTitles, assistantTasks, activeConversationIds, - projects, projectNames, tWorkspace, completedConversationIds], - ), sameActivitySummary); + const activityProjects = useMemo( + () => projects.map((project) => ({ + id: project.id, + name: projectNames.get(project.id) ?? project.name, + })), + [projects, projectNames], + ); + const projectActivity = useConversationActivitySummary(conversationStore, { + activeConversationIds, + completedConversationIds, + defaultTitle: t("conversation.defaultTitle"), + fallbackProjectName: tWorkspace("projectActivity.unassigned"), + projects: activityProjects, + tasks: assistantTasks, + }); const activityByConversationId = useMemo( () => new Map( @@ -4313,22 +3695,7 @@ function App(): React.JSX.Element { ), [projectActivity], ); - const pendingSidebarApprovals = useStableDerivedValue(useMemo( - () => - 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, - })), - ), - [conversations], - ), samePendingSidebarApprovals); + const pendingSidebarApprovals = usePendingSidebarApprovals(conversationStore); const sidebarArtifacts = useMemo( () => assistantArtifacts @@ -4378,7 +3745,7 @@ function App(): React.JSX.Element { record: Omit, scopeOverride?: ActivityRecord["scope"], ): void => { - const conversation = conversationsRef.current.find( + const conversation = conversationStore.getState().find( (candidate) => candidate.id === record.conversationId, ); const project = conversation?.projectId @@ -4409,7 +3776,7 @@ function App(): React.JSX.Element { }), ); }, - [], + [conversationStore], ); const updateRequestActivity = useCallback( @@ -4768,7 +4135,7 @@ function App(): React.JSX.Element { return; } - const liveMessage = conversationsRef.current + const liveMessage = conversationStore.getState() .find((conversation) => conversation.id === run.conversationId) ?.messages.find((message) => message.id === run.messageId); if (event.type === "question-resolved") { @@ -5397,6 +4764,7 @@ function App(): React.JSX.Element { } }, [ + conversationStore, recordActivity, loadWorkspaceChanges, releaseConversationQueueAfterRun, @@ -5462,7 +4830,7 @@ function App(): React.JSX.Element { return; } const { batch, acknowledgements } = createLocalConversationSaveBatch( - conversationsRef.current, + conversationStore.getState(), persistedLocalConversationsRef.current, deletingLocalConversationIdsRef.current, ); @@ -5487,16 +4855,16 @@ function App(): React.JSX.Element { dedupeKey: "conversation-persistence", }); }); - }, [releaseUnretainedConversationHistories]); + }, [releaseUnretainedConversationHistories, conversationStore]); const ensureConversationHistory = useCallback((conversationId: string): Promise => { - const current = conversationsRef.current.find(item => item.id === conversationId); + const current = conversationStore.getState().find(item => item.id === conversationId); if (!current) return Promise.reject(new Error(tRef.current("notices.remoteConversationRefreshFailed"))); if (!current.messageSummary) return Promise.resolve(current); const pending = conversationHistoryRequests.current.get(conversationId); if (pending) return pending; const pinRevision = conversationPinRevisionRef.current; const request = window.goodbuddy.conversations.get(conversationId).then(snapshot => { - const latest = conversationsRef.current.find(item => item.id === conversationId); + const latest = conversationStore.getState().find(item => item.id === conversationId); if (!latest || deletingLocalConversationIdsRef.current.has(conversationId)) { throw new Error(tRef.current("notices.remoteConversationRefreshFailed")); } @@ -5505,16 +4873,15 @@ function App(): React.JSX.Element { snapshot = { ...snapshot, pinned: latest.pinned }; } const next = mergePersistedConversations( - conversationsRef.current, [snapshot], persistedLocalConversationsRef.current, + conversationStore.getState(), [snapshot], persistedLocalConversationsRef.current, retainedConversationDetailIds(), ); - conversationsRef.current = next; setConversations(next); return next.find(item => item.id === conversationId)!; }).finally(() => { conversationHistoryRequests.current.delete(conversationId); }); conversationHistoryRequests.current.set(conversationId, request); return request; - }, [retainedConversationDetailIds, setConversations]); + }, [retainedConversationDetailIds, setConversations, conversationStore]); useEffect(() => { if (!conversationStoreReady) return; @@ -5538,7 +4905,9 @@ function App(): React.JSX.Element { }; }, [conversationStoreReady, persistLocalConversationChanges]); - useEffect(() => { + // Runs after each committed conversation change (ConversationStoreEffects) + // and when the store becomes ready. + const flushConversationPersistenceAfterCommit = useCallback((): void => { if ( !conversationStoreReady || !flushConversationPersistenceAfterRenderRef.current @@ -5547,7 +4916,8 @@ function App(): React.JSX.Element { } flushConversationPersistenceAfterRenderRef.current = false; persistLocalConversationChanges(); - }, [conversationStoreReady, conversations, persistLocalConversationChanges]); + }, [conversationStoreReady, persistLocalConversationChanges]); + useEffect(flushConversationPersistenceAfterCommit, [flushConversationPersistenceAfterCommit]); const persistActivityHistory = useCallback(async (): Promise => { if (!activityHistoryReady) { @@ -5697,14 +5067,14 @@ function App(): React.JSX.Element { return; } if (conversationPinPendingRef.current) { - const currentPins = new Map(conversationsRef.current.map(item => [item.id, item.pinned])); + const currentPins = new Map(conversationStore.getState().map(item => [item.id, item.pinned])); persisted = persisted.map(item => ({ ...item, pinned: currentPins.has(item.id) ? currentPins.get(item.id) : item.pinned })); } const remote = persisted.filter( (conversation) => conversation.remote, ); const previousById = new Map( - conversationsRef.current.map((conversation) => [ + conversationStore.getState().map((conversation) => [ conversation.id, conversation, ]), @@ -5785,6 +5155,7 @@ function App(): React.JSX.Element { document.removeEventListener("visibilitychange", refreshWhenVisible); }; }, [ + conversationStore, conversationStoreReady, releaseConversationQueueAfterRun, retainedConversationDetailIds, @@ -6014,7 +5385,7 @@ function App(): React.JSX.Element { ) { return; } - const conversationIds = conversationsRef.current + const conversationIds = conversationStore.getState() .filter( (conversation) => conversation.projectId === projectId && !conversation.remote, @@ -6035,7 +5406,7 @@ function App(): React.JSX.Element { }); }); }, - [notify], + [notify, conversationStore], ); const applyProjectRecoveryState = useCallback( @@ -6812,7 +6183,7 @@ function App(): React.JSX.Element { const commitProjectSelection = ( selected: AssistantProject, - candidateConversations = conversationsRef.current, + candidateConversations = conversationStore.getState(), ): void => { setActiveProjectId(selected.id); const conversation = candidateConversations.find( @@ -6947,7 +6318,7 @@ function App(): React.JSX.Element { const remainingProjects = projectsRef.current.filter( (project) => !deletedProjectIds.has(project.id), ); - const currentConversations = conversationsRef.current; + const currentConversations = conversationStore.getState(); const deletedConversationIds = new Set( currentConversations .filter((conversation) => @@ -7101,7 +6472,7 @@ function App(): React.JSX.Element { if (!conversation.remote) { await conversationPersistenceQueueRef.current; if (!persistedLocalConversationsRef.current.has(conversation.id)) { - const draft = conversationsRef.current.find(item => item.id === conversation.id); + const draft = conversationStore.getState().find(item => item.id === conversation.id); if (!draft) return; await window.goodbuddy.conversations.saveLocal([{ header: toLocalConversationHeader(draft), @@ -7112,8 +6483,7 @@ function App(): React.JSX.Element { } const pinned = !conversation.pinned; await window.goodbuddy.conversations.setPinned({ conversationId: conversation.id, pinned }); - const next = conversationsRef.current.map(item => item.id === conversation.id ? { ...item, pinned } : item); - conversationsRef.current = next; + const next = conversationStore.getState().map(item => item.id === conversation.id ? { ...item, pinned } : item); setConversations(next); } catch { notify({ tone: "error", message: t("notices.conversationPinFailed") }); @@ -7146,7 +6516,7 @@ function App(): React.JSX.Element { setDeletingConversationId(""); return; } - const deletingConversation = conversations.find( + const deletingConversation = conversationStore.getState().find( (conversation) => conversation.id === conversationId, ); if (deletingConversation && !deletingConversation.remote) { @@ -7210,10 +6580,9 @@ function App(): React.JSX.Element { return next; }); setConversationActivity(conversationId, false); - const remaining = conversations.filter( + const remaining = conversationStore.getState().filter( (conversation) => conversation.id !== conversationId, ); - conversationsRef.current = remaining; deletingLocalConversationIdsRef.current.delete(conversationId); const projectRemaining = remaining.filter( (conversation) => conversation.projectId === activeProjectId, @@ -7257,7 +6626,7 @@ function App(): React.JSX.Element { if ( persistedLocalConversationsRef.current.get(sourceConversation.id) !== sourceConversation || - conversationsRef.current.find( + conversationStore.getState().find( (conversation) => conversation.id === sourceConversation.id, ) !== sourceConversation ) { @@ -7544,7 +6913,7 @@ function App(): React.JSX.Element { let queuedInput: ConversationQueueUserInput | undefined = queuedDispatch && !queuedDispatch.scheduled ? queuedDispatch.input : undefined; if (queuedDispatch?.scheduled) { - const conversation = conversationsRef.current.find( + const conversation = conversationStore.getState().find( (candidate) => candidate.id === queuedDispatch.input.conversationId, ); const project = projectsRef.current.find( @@ -7588,7 +6957,7 @@ function App(): React.JSX.Element { } }; let conversationSnapshot = queuedInput - ? conversationsRef.current.find( + ? conversationStore.getState().find( (conversation) => conversation.id === queuedInput.conversationId, ) : activeConversation; @@ -8101,7 +7470,7 @@ function App(): React.JSX.Element { const stop = async (): Promise => { const requestId = [...activeRuns.current.entries()].find( ([, run]) => run.conversationId === activeId, - )?.[0] ?? conversationsRef.current.find( + )?.[0] ?? conversationStore.getState().find( conversation => conversation.id === activeId, )?.activeRequest?.requestId; if (requestId) { @@ -8120,7 +7489,7 @@ function App(): React.JSX.Element { approvalId: string, decision: ApprovalDecision, ): Promise => { - const pendingMessage = conversationsRef.current + const pendingMessage = conversationStore.getState() .find((conversation) => conversation.id === conversationId) ?.messages.find((message) => message.id === messageId); if (pendingMessage?.approval?.id !== approvalId) return; @@ -8185,7 +7554,7 @@ function App(): React.JSX.Element { })); } }, - [updateMessage], + [updateMessage, conversationStore], ); const respondToQuestion = useCallback( @@ -8195,7 +7564,7 @@ function App(): React.JSX.Element { questionId: string, answers?: AgentQuestionAnswer[], ): Promise => { - const pendingMessage = conversationsRef.current + const pendingMessage = conversationStore.getState() .find((conversation) => conversation.id === conversationId) ?.messages.find((message) => message.id === messageId); const question = pendingMessage?.pendingQuestions?.[0]; @@ -8237,7 +7606,7 @@ function App(): React.JSX.Element { }; }); }, - [updateMessage], + [updateMessage, conversationStore], ); const addContext = async ( @@ -8248,7 +7617,7 @@ function App(): React.JSX.Element { try { await conversationPersistenceQueueRef.current; if (!persistedLocalConversationsRef.current.has(conversationId)) { - const owner = conversationsRef.current.find((conversation) => conversation.id === conversationId); + const owner = conversationStore.getState().find((conversation) => conversation.id === conversationId); if (!owner || owner.remote) throw new Error('附件目标会话不可编辑'); await window.goodbuddy.conversations.saveLocal([{ header: toLocalConversationHeader(owner), messages: owner.messages.map(toConversationMessage) }]); } @@ -8588,7 +7957,7 @@ function App(): React.JSX.Element { }, [magicNotesEnabled]); const captureToNote = useCallback(async (conversationId: string, message?: Message, trigger?: HTMLElement): Promise => { if (!magicNotesEnabled || noteCapturePending.current || noteDraft.saving) return; - const snapshot = conversationsRef.current.find(item => item.id === conversationId); + const snapshot = conversationStore.getState().find(item => item.id === conversationId); if (!snapshot || (message && (message.state === 'streaming' || !message.content.trim()))) return; if (!message && snapshot.messages.some(item => item.state === 'streaming')) { notify({ tone: 'info', message: t('magicNotes:capture.wait') }); @@ -8616,7 +7985,7 @@ function App(): React.JSX.Element { openQuickNotes(); } catch (reason) { notify({ tone: 'error', message: displayErrorMessage(reason, t('magicNotes:errors.operationFailed')) }); } finally { noteCapturePending.current = false; setNoteCaptureLoading(false); } - }, [magicNotesEnabled, noteDraft.saving, ensureConversationHistory, persistLocalConversationChanges, guardNoteDraft, setNoteDraft, projectNames, openQuickNotes, notify, t]); + }, [magicNotesEnabled, noteDraft.saving, ensureConversationHistory, persistLocalConversationChanges, guardNoteDraft, setNoteDraft, projectNames, openQuickNotes, notify, t, conversationStore]); const openNoteSource = async (source: MagicNoteSource, messageId?: string): Promise<'opened' | 'missing'> => { let snapshot; try { snapshot = await window.goodbuddy.conversations.get(source.conversationId); } @@ -8625,8 +7994,7 @@ function App(): React.JSX.Element { throw reason; } requestWorkspaceLeave('chat', () => { - const next = mergePersistedConversations(conversationsRef.current, [snapshot], persistedLocalConversationsRef.current, new Set([source.conversationId, ...retainedConversationDetailIds()])); - conversationsRef.current = next; + const next = mergePersistedConversations(conversationStore.getState(), [snapshot], persistedLocalConversationsRef.current, new Set([source.conversationId, ...retainedConversationDetailIds()])); setConversations(next); setActiveProjectId(snapshot.projectId ?? ''); setActiveId(source.conversationId); @@ -8655,7 +8023,7 @@ function App(): React.JSX.Element { const openActivityConversation = useCallback((conversationId: string): void => { const open = async (): Promise => { - let conversation = conversationsRef.current.find( + let conversation = conversationStore.getState().find( (candidate) => candidate.id === conversationId, ); if (!conversation) { @@ -8689,7 +8057,7 @@ function App(): React.JSX.Element { if (narrowWindow) closeNarrowSidebar(); }; requestWorkspaceLeave("chat", () => { void open(); }); - }, [closeNarrowSidebar, commitView, narrowWindow, requestWorkspaceLeave, setActiveId, t, setConversations]); + }, [closeNarrowSidebar, commitView, narrowWindow, requestWorkspaceLeave, setActiveId, t, setConversations, conversationStore]); const openAssistantTask = (task: AssistantTask): void => { if (!task.conversationId) { @@ -8699,7 +8067,7 @@ function App(): React.JSX.Element { }); return; } - const conversation = conversations.find( + const conversation = conversationStore.getState().find( (candidate) => candidate.id === task.conversationId, ); if (!conversation) { @@ -8937,6 +8305,15 @@ function App(): React.JSX.Element { } }, }); + const conversationSidebarActions = useStableHandlers({ + newConversation: () => void newConversation(), + clearSearch: () => setSearchQuery(""), + retryLoad: () => { + setConversationLoadError(undefined); + setConversationLoadRetry((current) => current + 1); + }, + notify, + }); // Reuse each strip element while its inputs are unchanged: streaming into // one conversation must not hand every cached history pane a new taskStrip. @@ -9011,7 +8388,6 @@ function App(): React.JSX.Element { undefined, t("conversation.greeting"), ); - conversationsRef.current = [conversation]; persistedLocalConversationsRef.current.clear(); setConversations([conversation]); setActiveId(conversation.id); @@ -9279,6 +8655,11 @@ function App(): React.JSX.Element { return (
+ new Promise((resolve, reject) => { @@ -9476,117 +8857,34 @@ function App(): React.JSX.Element { -
-
- {!conversationLoadError && - filteredConversations.map((conversation) => { - const conversationTasks = - tasksByConversation.get(conversation.id) ?? emptyConversationTasks; - const tasksExpanded = expandedTaskConversationIds.has( - conversation.id, - ); - const conversationTitle = getConversationDisplayTitle( - conversation, - t("conversation.defaultTitle"), - ); - const activity = activityByConversationId.get(conversation.id); - const branchSourceTitle = conversation.branch - ? (conversationTitles.get( - conversation.branch.sourceConversationId, - ) ?? conversation.branch.sourceTitle) - : undefined; - 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)} - /> - ); - })} - {conversationLoadError ? ( -
- {t("conversation.loadFailed")} - {conversationLoadError} - -
- ) : filteredConversations.length === 0 ? ( - deferredSearchQuery.trim() ? ( -
- {t("conversation.noMatches")} - {t("conversation.noMatchesDescription")} - -
- ) : activeProject?.kind === "channel" ? ( -
- {t("conversation.noRemote")} - {t("chat.remote.waiting")} -
- ) : ( -
- {t("conversation.empty")} - {t("conversation.emptyDescription")} - -
- ) - ) : null} -
- -
+
+ ) + } + for (const segment of segments) { + if (segment.kind === 'spacer') { + children.push( +