From 2e3d2e77a9ea014a290fb69d003ac15a2a571382 Mon Sep 17 00:00:00 2001 From: mesalogo Date: Sat, 3 Oct 2026 17:59:20 +0800 Subject: [PATCH] perf(renderer): memoize keep-alive routes, workspaces and sidebars behind stable callbacks --- src/renderer/src/App.tsx | 672 ++++++------------ src/renderer/src/HeartbeatCenter.tsx | 7 +- src/renderer/src/KeepAliveRoute.test.tsx | 61 ++ src/renderer/src/KeepAliveRoute.tsx | 44 ++ src/renderer/src/KnowledgeWorkspace.tsx | 7 +- src/renderer/src/MagicNotesWorkspace.tsx | 8 +- src/renderer/src/ProjectSwitcher.tsx | 7 +- src/renderer/src/RightAssistantSidebar.tsx | 8 +- src/renderer/src/SettingsPanel.tsx | 8 +- .../src/use-knowledge-workspace-actions.ts | 180 +++++ src/renderer/src/use-magic-note-draft.tsx | 22 +- 11 files changed, 540 insertions(+), 484 deletions(-) create mode 100644 src/renderer/src/KeepAliveRoute.test.tsx create mode 100644 src/renderer/src/KeepAliveRoute.tsx create mode 100644 src/renderer/src/use-knowledge-workspace-actions.ts diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 354f8d28..227f4fc1 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -165,6 +165,7 @@ import type { ComposerMenuOption, RuntimeActionChoice } from "./ComposerMenuSele import { formatAttachmentSize, resizeComposerTextarea } from "./composer-textarea"; import { Composer } from "./Composer"; import { useComposerActions } from "./use-composer-actions"; +import { useKnowledgeWorkspaceActions } from "./use-knowledge-workspace-actions"; import { LiveMessageStoreContext } from "./live-message-store"; import { createConversationPersistence, @@ -206,6 +207,7 @@ import { useExecutionStats } from "./use-execution-stats"; import { RightAssistantSidebar, type AssistantSidebarTab, + type RightAssistantSidebarProps, type SidebarArtifact, } from "./RightAssistantSidebar"; import { @@ -250,10 +252,8 @@ import { touchAndPruneKeepAliveEntries, type KeepAliveCacheEntry, } from "./keep-alive-cache"; -import { - WorkspaceUnsavedChangesContext, - type ReportWorkspaceUnsavedChanges, -} from "./workspace-unsaved-changes"; +import type { ReportWorkspaceUnsavedChanges } from "./workspace-unsaved-changes"; +import { KeepAliveRoute } from "./KeepAliveRoute"; import { activateModalFocus, trapTabFocus } from "./dialog-focus"; import { FloatingPortal } from "./FloatingPortal"; import { @@ -387,31 +387,6 @@ function RouteLoadingStatus({ label }: { label: string }): React.JSX.Element { ); } -function KeepAliveRoute({ - active, - children, - onUnsavedChanges, - route, -}: { - active: boolean; - children: ReactNode; - onUnsavedChanges?: ReportWorkspaceUnsavedChanges; - route: string; -}): React.JSX.Element { - return ( - - ); -} class RouteErrorBoundary extends Component< { children: ReactNode; fallback: ReactNode }, @@ -598,6 +573,11 @@ const emptyTokenUsage: TokenUsageSummary = { const storageKey = "goodbuddy.conversations.v1"; const emptyConversationTasks: AssistantTask[] = []; const emptyAttachments: ContextAttachment[] = []; +type RightSidebarHandlers = Required>; const emptyImageReferences: AssistantArtifact[] = []; const activeProjectStorageKey = "goodbuddy.active-project.v1"; @@ -6214,6 +6194,21 @@ function App(): React.JSX.Element { }); } }; + const knowledgeWorkspaceActions = useKnowledgeWorkspaceActions({ + notify, + t, + selectedLibraryId: knowledgeSnapshot.selectedLibraryId, + refreshKnowledge, + refreshSelectedKnowledge, + retryKnowledgeLoad, + runKnowledgeSourceAction, + createKnowledgeLibrary, + deleteKnowledgeLibrary, + setEnabledKnowledgeLibraryIds, + setKnowledgeSnapshot, + openModelSettings: openImageModelSettings, + showChatAndFocusComposer, + }); const openQuickNotes = useCallback((): void => { if (!magicNotesEnabled) return; @@ -6785,6 +6780,179 @@ function App(): React.JSX.Element { await command(browserApi); }; + // Stable callbacks for the memoized routes and sidebars: App re-renders on + // chat updates, and fresh inline closures would re-render all of them. + const projectSwitcherActions = useStableHandlers({ + onArchive: archiveProject, + onCreate: createProject, + onDelete: deleteProject, + onRemoteCommitted: loadCommittedRemoteProject, + onSelect: selectProject, + onSelectRoot: () => window.goodbuddy.settings.selectWorkspace(), + onUpdate: updateProject, + }); + const magicNotesActions = useStableHandlers({ + onOpenSource: openNoteSource, + onCancelNoteCapture: () => requestAnimationFrame(() => { + const trigger = noteCaptureTrigger.current; + (trigger?.isConnected && !trigger.closest('[hidden], [inert]') ? trigger : noteTitleMenuRef.current)?.focus(); + }), + onOpenNoteWorkspace: (noteId: string, entryId?: string) => + requestWorkspaceLeave('magic-notes', () => { + setNotesNavigation({ noteId, entryId, requestId: Date.now() }); + commitView('magic-notes'); + }), + }); + const heartbeatActions = useStableHandlers({ + onRetryApplicationSettings: () => void reloadApplicationSettings(), + onSetMemoryStatus: setMemoryStatus, + onSetTaskStatus: setHeartbeatTaskStatus, + onUseFollowUpTask: useHeartbeatTask, + }); + const settingsPanelActions = useStableHandlers({ + onTransparentFrostedEffectEnabledChange: (enabled: boolean) => + updateApplicationSettings({ transparentFrostedEffectEnabled: enabled }), + onRetryApplicationSettings: () => void reloadApplicationSettings(), + onBrandingPreferencesChange: (preferences: Parameters[0]) => { + if (!saveBrandingPreferences(preferences)) { + return false; + } + setBrandingPreferences(preferences); + notify({ + tone: "success", + message: t("notices.brandingSaved"), + dedupeKey: "branding-saved", + }); + return true; + }, + onClearLocalData: clearLocalData, + onClose: () => { + commitView(viewRef.current); + }, + onExpertsChanged: (experts: AssistantExpert[]) => { + setAssistantExperts(experts); + if ( + (selectedExpertId === "team" && experts.length < 2) || + (selectedExpertId && + selectedExpertId !== "team" && + !experts.some((expert) => expert.id === selectedExpertId)) + ) { + setSelectedExpertId(""); + } + }, + onRemoteProjectsEnabledChange: handleRemoteProjectsEnabledChange, + onProjectsDeleted: removeProjectsFromUi, + onSaved: (settings: RuntimeSettings) => { + setRuntimeSettings(settings); + }, + onUpdateProject: updateProject, + }); + const rightSidebarActions = useStableHandlers({ + onBeforeCloseNotes: async () => { + if (!await guardNoteDraft()) return false; + setNoteDraft(undefined); + return true; + }, + onOpenSupervisionGraph: (resultId) => { + requestWorkspaceLeave('heartbeat', () => { + setSupervisionGraphNavigation({ resultId }); + commitView('heartbeat'); + }); + }, + onContinueSupervision: async (prompt, conversationId) => { + await window.goodbuddy.supervision.continue({ + conversationId, + prompt, + }); + openActivityConversation(conversationId); + }, + onCreateCustomTask: () => openCustomTaskDialog("current"), + onBackBrowser: (conversationId, tabId) => + runBrowserCommand((browserApi) => + browserApi.back({ conversationId, tabId }), + ), + onNavigateBrowser: (conversationId, tabId, url) => + runBrowserCommand((browserApi) => + browserApi.navigate({ conversationId, tabId, url }), + ), + onReloadBrowser: (conversationId, tabId) => + runBrowserCommand((browserApi) => + browserApi.reload({ conversationId, tabId }), + ), + onStopLoadingBrowser: (conversationId, tabId) => + runBrowserCommand((browserApi) => + browserApi.stopLoading({ conversationId, tabId }), + ), + onImportArtifacts: async () => { + const imported = await window.goodbuddy.artifacts.importFiles( + activeProjectId || undefined, + ); + if (imported.length > 0) { + setAssistantArtifacts((current) => [...imported, ...current]); + setAssistantSidebarTab("results"); + } + }, + onLoadArtifact: async (artifactId) => { + if (assistantArtifactById.get(artifactId)?.content) { + return; + } + const artifact = await window.goodbuddy.artifacts.get(artifactId); + setAssistantArtifacts((current) => + mergeArtifacts(current, [artifact]), + ); + }, + onOpenTask: openAssistantTask, + onRespondApproval: (approval, decision) => { + void respondToApproval( + approval.conversationId, + approval.messageId, + approval.approvalId, + decision, + ); + }, + }); + const notesPanelActive = assistantSidebarOpen && magicNotesEnabled; + const notesPanel = useMemo( + () => ( + + ), + [ + applicationSettings?.magicNoteCommentFormat, + applicationSettings?.magicNoteCommentMode, + magicNotesActions, + noteDraft, + notesPanelActive, + ], + ); + const statsConversationId = statsConversation?.id; + const statsDurationMs = executionStats.conversation?.durationMs; + const statsIncomplete = (executionStats.conversation?.incompleteRequestCount ?? 0) > 0; + const conversationStats = useMemo( + () => + statsConversationId && statsDurationMs !== undefined + ? { + conversationId: statsConversationId, + messageCount: statsMessageCount, + replyDurationMs: statsDurationMs, + incomplete: statsIncomplete, + } + : undefined, + [statsConversationId, statsDurationMs, statsIncomplete, statsMessageCount], + ); + const activeWorkspaceChanges = + workspaceChanges?.projectId === activeProjectId + ? workspaceChanges.changes + : undefined; + return (
@@ -6855,14 +7023,8 @@ function App(): React.JSX.Element { activityByProjectId={projectActivity.byProjectId} recoveryByProjectId={projectRecoveryByProjectId} runtimeSettings={runtimeSettings} - onArchive={archiveProject} - onCreate={createProject} - onDelete={deleteProject} - onRemoteCommitted={loadCommittedRemoteProject} + {...projectSwitcherActions} onRetryRecovery={retryProjectRecovery} - onSelect={selectProject} - onSelectRoot={() => window.goodbuddy.settings.selectWorkspace()} - onUpdate={updateProject} projects={projects} remoteProjectsEnabled={remoteProjectsEnabled} /> @@ -7412,7 +7574,7 @@ function App(): React.JSX.Element { } > - + @@ -7442,12 +7604,8 @@ function App(): React.JSX.Element { } > { - await refreshKnowledge(createdId ?? snapshot?.selectedLibraryId ?? knowledgeSnapshot.selectedLibraryId); - if (createdId) setEnabledKnowledgeLibraryIds(current => [...new Set([...current, createdId])]); - }} documents={knowledgeSnapshot.documents} evidence={knowledgeSnapshot.evidence} graphNodes={knowledgeSnapshot.graphNodes} @@ -7455,308 +7613,6 @@ function App(): React.JSX.Element { libraries={knowledgeSnapshot.libraries} loadError={knowledgeLoadError} loading={knowledgeLoading} - onCreateLibrary={createKnowledgeLibrary} - onCreateEntity={async (input) => { - const libraryId = knowledgeSnapshot.selectedLibraryId; - if (!libraryId) { - throw new Error(t("notices.selectKnowledgeBase")); - } - await runKnowledgeSourceAction(() => - window.goodbuddy.knowledge.createEntity( - libraryId, - input, - ), - ); - }} - onCreateRelation={async (input) => { - const libraryId = knowledgeSnapshot.selectedLibraryId; - if (!libraryId) { - throw new Error(t("notices.selectKnowledgeBase")); - } - await runKnowledgeSourceAction(() => - window.goodbuddy.knowledge.createRelation( - libraryId, - input, - ), - ); - }} - onDeleteEntity={(entityId) => - runKnowledgeSourceAction(() => - window.goodbuddy.knowledge.deleteEntity(entityId), - ) - } - onDeleteLibrary={deleteKnowledgeLibrary} - onReextractGraph={async (libraryId) => { - await runKnowledgeSourceAction(() => - window.goodbuddy.knowledge.reextractGraph( - libraryId, - ), - ); - notify({ - tone: "success", - message: t("notices.knowledgeGraphRebuilt"), - dedupeKey: `knowledge-graph:${libraryId}`, - }); - }} - onUpdateLibrary={async (libraryId, update) => { - await runKnowledgeSourceAction(async () => { - await window.goodbuddy.knowledge.updateLibrary( - libraryId, - update, - ); - }); - notify({ - tone: "success", - message: t("notices.knowledgeSettingsUpdated"), - dedupeKey: `knowledge-library:${libraryId}`, - }); - }} - onDeleteRelation={(relationId) => - runKnowledgeSourceAction(() => - window.goodbuddy.knowledge.deleteRelation( - relationId, - ), - ) - } - onImportDirectory={( - libraryId, - files, - graphStrategy, - ) => { - void files; - return runKnowledgeSourceAction(() => - window.goodbuddy.knowledge.selectDirectory( - libraryId, - graphStrategy, - ), - ); - }} - onImportFiles={(libraryId, files, graphStrategy) => - runKnowledgeSourceAction(() => - window.goodbuddy.knowledge.importDroppedFiles( - libraryId, - files, - graphStrategy, - ), - ) - } - onImportUrl={(libraryId, url, graphStrategy) => - runKnowledgeSourceAction(() => - window.goodbuddy.knowledge.importUrl( - libraryId, - url, - graphStrategy, - ), - ) - } - onOpenDocumentSource={(libraryId, documentId) => - window.goodbuddy.knowledge.openDocumentSource({ - knowledgeBaseId: libraryId, - documentId, - }) - } - onOpenModelSettings={() => { - setSettingsInitialCategory("model"); - setView("settings"); - }} - onMergeEntities={(sourceId, targetId) => - runKnowledgeSourceAction(() => - window.goodbuddy.knowledge.mergeEntities( - sourceId, - targetId, - ), - ) - } - onMoveNode={(nodeId, position) => { - setKnowledgeSnapshot((current) => ({ - ...current, - graphNodes: current.graphNodes.map((node) => - node.id === nodeId - ? { ...node, ...position } - : node, - ), - })); - void window.goodbuddy.knowledge - .moveEntity(nodeId, position) - .catch(() => void refreshSelectedKnowledge()); - }} - onOpenEvidence={(evidence) => - notify({ - tone: "info", - message: t("notices.evidenceExcerpt", { - source: `${evidence.documentName}${ - evidence.location - ? ` · ${evidence.location}` - : "" - }`, - excerpt: evidence.excerpt, - }).slice(0, 500), - }) - } - onUseInChat={(libraryId) => { - setEnabledKnowledgeLibraryIds((current) => - current.includes(libraryId) - ? current - : [...current, libraryId], - ); - showChatAndFocusComposer(); - }} - onPauseSource={(sourceId) => - runKnowledgeSourceAction(() => - window.goodbuddy.knowledge.pauseSource(sourceId), - ) - } - onRemoveSource={(sourceId) => - runKnowledgeSourceAction(() => - window.goodbuddy.knowledge.removeSource(sourceId), - ) - } - onRetrieve={(libraryId, query, settings) => - window.goodbuddy.knowledge.retrieve({ - knowledgeBaseId: libraryId, - query, - settings, - }) - } - onUpdateKnowledgeSettings={async ( - libraryId, - settings, - ) => { - await runKnowledgeSourceAction(() => - window.goodbuddy.knowledge.updateSettings({ - knowledgeBaseId: libraryId, - ...settings, - }), - ); - notify({ - tone: "success", - message: t("notices.knowledgeSettingsUpdated"), - dedupeKey: `knowledge-retrieval-settings:${libraryId}`, - }); - }} - onListChunks={({ - libraryId, - documentId, - page, - pageSize, - search, - }) => - window.goodbuddy.knowledge.listChunks({ - knowledgeBaseId: libraryId, - documentId, - page, - pageSize, - search, - }) - } - onUpdateChunk={(input) => - window.goodbuddy.knowledge.updateChunk(input) - } - onDeleteChunk={(input) => - window.goodbuddy.knowledge.deleteChunk(input) - } - onRebuildDocument={(libraryId, documentId) => - runKnowledgeSourceAction(async () => { - await window.goodbuddy.knowledge.rebuildDocument({ - knowledgeBaseId: libraryId, - documentId, - }); - }) - } - onRebuildLibrary={(libraryId) => - runKnowledgeSourceAction(async () => { - const result = - await window.goodbuddy.knowledge.rebuildLibrary({ - knowledgeBaseId: libraryId, - }); - if (result.failed > 0) { - throw new Error( - t("notices.knowledgeRebuildPartial", { - rebuilt: result.rebuilt, - failed: result.failed, - }), - ); - } - notify({ - tone: "success", - message: t("notices.knowledgeRebuildCompleted", { - count: result.rebuilt, - }), - dedupeKey: `knowledge-rebuild:${libraryId}`, - }); - }) - } - onCancelRebuild={async (libraryId) => { - const cancelled = - await window.goodbuddy.knowledge.cancelRebuild( - libraryId, - ); - if (!cancelled) { - throw new Error( - t("notices.knowledgeRebuildNotRunning"), - ); - } - }} - onGetEmbeddingIndex={(libraryId) => - window.goodbuddy.knowledge.getEmbeddingIndex( - libraryId, - ) - } - onRebuildEmbeddingIndex={(libraryId) => - window.goodbuddy.knowledge.rebuildEmbeddingIndex( - libraryId, - ) - } - onCancelTask={async (taskId) => { - const cancelled = - await window.goodbuddy.knowledge.cancelTask(taskId); - if (!cancelled) { - throw new Error( - t("notices.knowledgeTaskNotRunning"), - ); - } - await refreshSelectedKnowledge(); - }} - onOpenReferenceSource={(input) => - window.goodbuddy.knowledge.openReferenceSource(input) - } - onRetrySource={(sourceId) => - runKnowledgeSourceAction(() => - window.goodbuddy.knowledge.retrySource(sourceId), - ) - } - onRetryTask={(taskId) => - runKnowledgeSourceAction(() => - window.goodbuddy.knowledge.retryTask(taskId), - ) - } - onRetryLoad={retryKnowledgeLoad} - onSelectLibrary={(libraryId) => { - void refreshKnowledge(libraryId).catch(() => { - // KnowledgeWorkspace renders the recoverable load error. - }); - }} - onSyncSource={(sourceId) => - runKnowledgeSourceAction(() => - window.goodbuddy.knowledge.syncSource(sourceId), - ) - } - onUpdateEntity={(entityId, update) => - runKnowledgeSourceAction(() => - window.goodbuddy.knowledge.updateEntity( - entityId, - update, - ), - ) - } - onUpdateRelation={(relationId, input) => - runKnowledgeSourceAction(() => - window.goodbuddy.knowledge.updateRelation( - relationId, - input, - ), - ) - } selectedLibraryId={knowledgeSnapshot.selectedLibraryId} sources={knowledgeSnapshot.sources} tasks={knowledgeSnapshot.tasks} @@ -7794,7 +7650,7 @@ function App(): React.JSX.Element { applicationSettingsLocked={applicationSettingsUnconfirmed} applicationSettingsError={applicationSettingsError} onUpdateApplicationSettings={updateApplicationSettings} - onRetryApplicationSettings={() => void reloadApplicationSettings()} + onRetryApplicationSettings={heartbeatActions.onRetryApplicationSettings} active={view === 'heartbeat'} graphNavigation={supervisionGraphNavigation} onOpenConversation={openActivityConversation} @@ -7808,11 +7664,11 @@ function App(): React.JSX.Element { onRetryLoad={retryHeartbeatLoad} onRemove={removeHeartbeat} onRunNow={runHeartbeat} - onSetMemoryStatus={setMemoryStatus} + onSetMemoryStatus={heartbeatActions.onSetMemoryStatus} onSetPaused={setHeartbeatPaused} - onSetTaskStatus={setHeartbeatTaskStatus} + onSetTaskStatus={heartbeatActions.onSetTaskStatus} onUpdate={updateHeartbeat} - onUseFollowUpTask={useHeartbeatTask} + onUseFollowUpTask={heartbeatActions.onUseFollowUpTask} projects={projects} runs={heartbeatRuns} tasks={assistantTasks} @@ -7878,13 +7734,12 @@ function App(): React.JSX.Element { > updateApplicationSettings({ transparentFrostedEffectEnabled: enabled })} - onRetryApplicationSettings={() => void reloadApplicationSettings()} brandingFallbackLogo={ resolvedAppearanceTheme === "dark" ? goodbuddyDarkIcon @@ -7899,46 +7754,9 @@ function App(): React.JSX.Element { setConversationHtmlRenderingEnabled } onAppearanceThemeChange={setAppearanceTheme} - onBrandingPreferencesChange={(preferences) => { - if (!saveBrandingPreferences(preferences)) { - return false; - } - setBrandingPreferences(preferences); - notify({ - tone: "success", - message: t("notices.brandingSaved"), - dedupeKey: "branding-saved", - }); - return true; - }} - onClearLocalData={clearLocalData} - onClose={() => { - commitView(viewRef.current); - }} - onExpertsChanged={(experts) => { - setAssistantExperts(experts); - if ( - (selectedExpertId === "team" && experts.length < 2) || - (selectedExpertId && - selectedExpertId !== "team" && - !experts.some( - (expert) => expert.id === selectedExpertId, - )) - ) { - setSelectedExpertId(""); - } - }} - onRemoteProjectsEnabledChange={ - handleRemoteProjectsEnabledChange - } onNotify={notify} onLeaveRequestReady={registerSettingsLeaveRequester} - onProjectsDeleted={removeProjectsFromUi} - onSaved={(settings) => { - setRuntimeSettings(settings); - }} onShortcutSettingsChanged={handleShortcutSettingsChanged} - onUpdateProject={updateProject} open={settingsOpen} projects={projects} /> @@ -8101,21 +7919,14 @@ function App(): React.JSX.Element { )} )} { if (!await guardNoteDraft()) return false; setNoteDraft(undefined); return true; }} - notesPanel={ requestAnimationFrame(() => { const trigger = noteCaptureTrigger.current; (trigger?.isConnected && !trigger.closest('[hidden], [inert]') ? trigger : noteTitleMenuRef.current)?.focus(); })} - onOpenWorkspace={(noteId, entryId) => requestWorkspaceLeave('magic-notes', () => { setNotesNavigation({ noteId, entryId, requestId: Date.now() }); commitView('magic-notes'); })} />} + notesPanel={notesPanel} nativeTerminals={nativeTerminals} activeConversationId={activeId} - conversationStats={statsConversation && executionStats.conversation ? { - conversationId: statsConversation.id, - messageCount: statsMessageCount, - replyDurationMs: executionStats.conversation.durationMs, - incomplete: executionStats.conversation.incompleteRequestCount > 0, - } : undefined} + conversationStats={conversationStats} taskDurations={taskDurations} approvals={pendingSidebarApprovals} artifacts={sidebarArtifacts} @@ -8124,73 +7935,12 @@ function App(): React.JSX.Element { currentProject={activeProject} supervisionEnabled={isApplicationEnabled(applicationSettings, 'heartbeat')} supervisionLibraries={knowledgeSnapshot.libraries} - onOpenSupervisionGraph={(resultId) => { - requestWorkspaceLeave('heartbeat', () => { - setSupervisionGraphNavigation({ resultId }); - commitView('heartbeat'); - }); - }} onOpenSupervisionConversation={openActivityConversation} - onContinueSupervision={async (prompt, conversationId) => { - await window.goodbuddy.supervision.continue({ - conversationId, - prompt, - }) - openActivityConversation(conversationId) - }} - onCreateCustomTask={() => openCustomTaskDialog("current")} schedules={assistantSchedules} selectedTaskId={selectedAssistantTaskId} tasks={productAssistantTasks} projectNames={projectNames} - onBackBrowser={(conversationId, tabId) => - runBrowserCommand((browserApi) => - browserApi.back({ conversationId, tabId }), - ) - } - onNavigateBrowser={(conversationId, tabId, url) => - runBrowserCommand((browserApi) => - browserApi.navigate({ conversationId, tabId, url }), - ) - } - onReloadBrowser={(conversationId, tabId) => - runBrowserCommand((browserApi) => - browserApi.reload({ conversationId, tabId }), - ) - } - onStopLoadingBrowser={(conversationId, tabId) => - runBrowserCommand((browserApi) => - browserApi.stopLoading({ conversationId, tabId }), - ) - } - onImportArtifacts={async () => { - const imported = await window.goodbuddy.artifacts.importFiles( - activeProjectId || undefined, - ); - if (imported.length > 0) { - setAssistantArtifacts((current) => [...imported, ...current]); - setAssistantSidebarTab("results"); - } - }} - onLoadArtifact={async (artifactId) => { - if (assistantArtifactById.get(artifactId)?.content) { - return; - } - const artifact = await window.goodbuddy.artifacts.get(artifactId); - setAssistantArtifacts((current) => - mergeArtifacts(current, [artifact]), - ); - }} - onOpenTask={openAssistantTask} onRemoveSchedule={removeAssistantSchedule} - onRespondApproval={(approval, decision) => { - void respondToApproval( - approval.conversationId, - approval.messageId, - approval.approvalId, - decision, - ); - }} onRunSchedule={runAssistantSchedule} onSetScheduleEnabled={setAssistantScheduleEnabled} onListWorkspaceDirectory={listWorkspaceDirectory} @@ -8202,11 +7952,7 @@ function App(): React.JSX.Element { open={assistantSidebarOpen} restoreFocusRef={assistantSidebarToggleRef} tab={assistantSidebarTab} - workspaceChanges={ - workspaceChanges?.projectId === activeProjectId - ? workspaceChanges.changes - : undefined - } + workspaceChanges={activeWorkspaceChanges} workspaceProjectId={activeProjectId || undefined} />
diff --git a/src/renderer/src/HeartbeatCenter.tsx b/src/renderer/src/HeartbeatCenter.tsx index e5a14f5f..d84f449c 100644 --- a/src/renderer/src/HeartbeatCenter.tsx +++ b/src/renderer/src/HeartbeatCenter.tsx @@ -5,7 +5,7 @@ import { XCircle } from 'lucide-react' import { SupervisionSuggestionsPanel } from './SupervisionSuggestionsPanel' -import { useEffect, useMemo, useRef, useState } from 'react' +import { useEffect, useMemo, useRef, useState, memo } from 'react' import { useTranslation } from 'react-i18next' import type { AssistantHeartbeatConfig, @@ -79,7 +79,7 @@ function byNewest(left: T, right: T): number { ) } -export function HeartbeatCenter(props: HeartbeatCenterProps): React.JSX.Element { +function HeartbeatCenterView(props: HeartbeatCenterProps): React.JSX.Element { return } @@ -616,3 +616,6 @@ function HeartbeatSections({ ) } + +/** Memoized: App re-renders on chat updates; this view re-renders only when its props change. */ +export const HeartbeatCenter = memo(HeartbeatCenterView) diff --git a/src/renderer/src/KeepAliveRoute.test.tsx b/src/renderer/src/KeepAliveRoute.test.tsx new file mode 100644 index 00000000..9350fab9 --- /dev/null +++ b/src/renderer/src/KeepAliveRoute.test.tsx @@ -0,0 +1,61 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { KeepAliveRoute } from "./KeepAliveRoute"; + +afterEach(cleanup); + +function Child({ label, onRender }: { label: string; onRender: () => void }): React.JSX.Element { + onRender(); + return {label}; +} + +describe("KeepAliveRoute render boundary", () => { + it("does not re-render a hidden route when the parent re-renders with new children", () => { + const renders = vi.fn(); + const view = (label: string, active: boolean) => ( + + + + ); + const { container, rerender } = render(view("first", false)); + expect(renders).toHaveBeenCalledTimes(1); + // A chat delta re-renders App, which creates new children for every route. + rerender(view("second", false)); + rerender(view("third", false)); + expect(renders).toHaveBeenCalledTimes(1); + expect(container.querySelector("[data-route='knowledge']")?.hasAttribute("hidden")).toBe(true); + expect(screen.getByText("first", { selector: "span" })).toBeTruthy(); + }); + + it("catches up with the latest children when shown again", () => { + const renders = vi.fn(); + const view = (label: string, active: boolean) => ( + + + + ); + const { container, rerender } = render(view("first", false)); + rerender(view("second", false)); + rerender(view("latest", true)); + expect(renders).toHaveBeenCalledTimes(2); + expect(container.textContent).toBe("latest"); + expect(container.querySelector("[data-route='knowledge']")?.hasAttribute("hidden")).toBe(false); + }); + + it("re-renders the visible route and renders once more when it is hidden", () => { + const renders = vi.fn(); + const view = (label: string, active: boolean) => ( + + + + ); + const { rerender } = render(view("a", true)); + rerender(view("b", true)); + expect(renders).toHaveBeenCalledTimes(2); + // Hiding renders once so children see that they are inactive. + rerender(view("c", false)); + expect(renders).toHaveBeenCalledTimes(3); + rerender(view("d", false)); + expect(renders).toHaveBeenCalledTimes(3); + }); +}); diff --git a/src/renderer/src/KeepAliveRoute.tsx b/src/renderer/src/KeepAliveRoute.tsx new file mode 100644 index 00000000..9135a75d --- /dev/null +++ b/src/renderer/src/KeepAliveRoute.tsx @@ -0,0 +1,44 @@ +import { memo, type ReactNode } from "react"; +import { + WorkspaceUnsavedChangesContext, + type ReportWorkspaceUnsavedChanges, +} from "./workspace-unsaved-changes"; + +type KeepAliveRouteProps = { + active: boolean; + children: ReactNode; + onUnsavedChanges?: ReportWorkspaceUnsavedChanges; + route: string; +}; + +// A hidden route keeps its last rendering: App re-renders on every chat +// update, and a route nobody sees must not re-render with it. It catches up +// with the latest children in the render that shows it again. +const keepHiddenRoute = ( + previous: KeepAliveRouteProps, + next: KeepAliveRouteProps, +): boolean => + !previous.active && !next.active && + previous.route === next.route && + previous.onUnsavedChanges === next.onUnsavedChanges; + +export const KeepAliveRoute = memo(function KeepAliveRoute({ + active, + children, + onUnsavedChanges, + route, +}: KeepAliveRouteProps): React.JSX.Element { + return ( + + ); +}, keepHiddenRoute); diff --git a/src/renderer/src/KnowledgeWorkspace.tsx b/src/renderer/src/KnowledgeWorkspace.tsx index 4204a96c..bf47b02e 100644 --- a/src/renderer/src/KnowledgeWorkspace.tsx +++ b/src/renderer/src/KnowledgeWorkspace.tsx @@ -17,7 +17,7 @@ import { import { stripKnowledgeHighlightTags } from '../../shared/knowledge-text' import { useTranslation } from 'react-i18next' import { resolvedLocale, formatNumber } from './knowledge-workspace/formatting' -import { useState, useRef, useEffect } from 'react' +import { useState, useRef, useEffect, memo } from 'react' import type { ExternalKnowledgeProvider } from '../../shared/external-knowledge-contracts' import { type KnowledgeTaskContext, KnowledgeTasksView } from './knowledge-workspace/KnowledgeTasksView' import { toErrorMessage, strategyLabelKeys } from './knowledge-workspace/helpers' @@ -139,7 +139,7 @@ function toWorkbenchResponse( } } -export function KnowledgeWorkspace({ +function KnowledgeWorkspaceView({ externalInstances = [], onExternalChanged = () => {}, notify = () => {}, @@ -970,3 +970,6 @@ export function KnowledgeWorkspace({ ) } + +/** Memoized: App re-renders on chat updates; this view re-renders only when its props change. */ +export const KnowledgeWorkspace = memo(KnowledgeWorkspaceView) diff --git a/src/renderer/src/MagicNotesWorkspace.tsx b/src/renderer/src/MagicNotesWorkspace.tsx index d831c7b5..31003316 100644 --- a/src/renderer/src/MagicNotesWorkspace.tsx +++ b/src/renderer/src/MagicNotesWorkspace.tsx @@ -29,7 +29,8 @@ import { useLayoutEffect, useMemo, useRef, - useState + useState, + memo } from 'react' import { useTranslation } from 'react-i18next' import { InlineHelp } from './InlineHelp' @@ -376,7 +377,7 @@ function TodoListItem({ ) } -export function MagicNotesWorkspace({ +function MagicNotesWorkspaceView({ navigation, onOpenSource, onNotify, @@ -3399,3 +3400,6 @@ export function MagicNotesWorkspace({ ) } + +/** Memoized: App re-renders on chat updates; this view re-renders only when its props change. */ +export const MagicNotesWorkspace = memo(MagicNotesWorkspaceView) diff --git a/src/renderer/src/ProjectSwitcher.tsx b/src/renderer/src/ProjectSwitcher.tsx index 04d09234..29da9a72 100644 --- a/src/renderer/src/ProjectSwitcher.tsx +++ b/src/renderer/src/ProjectSwitcher.tsx @@ -16,7 +16,7 @@ import { X } from 'lucide-react' import type { LucideIcon } from 'lucide-react' -import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useLayoutEffect, useRef, useState, memo } from 'react' import { createPortal } from 'react-dom' import { useTranslation } from 'react-i18next' import type { @@ -212,7 +212,7 @@ function isRemoteAbsolutePath(value: string): boolean { return remoteProjectRootPathSchema.safeParse(value).success } -export function ProjectSwitcher({ +function ProjectSwitcherView({ projects, activeProjectId, activityByProjectId = {}, @@ -2141,3 +2141,6 @@ export function ProjectSwitcher({ ) } + +/** Memoized: App re-renders on chat updates; this view re-renders only when its props change. */ +export const ProjectSwitcher = memo(ProjectSwitcherView) diff --git a/src/renderer/src/RightAssistantSidebar.tsx b/src/renderer/src/RightAssistantSidebar.tsx index 646249ef..1160e32f 100644 --- a/src/renderer/src/RightAssistantSidebar.tsx +++ b/src/renderer/src/RightAssistantSidebar.tsx @@ -24,7 +24,8 @@ import { useLayoutEffect, useMemo, useRef, - useState + useState, + memo } from 'react' import { useTranslation } from 'react-i18next' import type { @@ -807,7 +808,7 @@ function BrowserViewport({ ) } -export function RightAssistantSidebar({ +function RightAssistantSidebarView({ notesEnabled = false, notesSettingsReady = true, notesOpenRequest = 0, @@ -2752,3 +2753,6 @@ export function RightAssistantSidebar({ ) } + +/** Memoized: App re-renders on chat updates; this view re-renders only when its props change. */ +export const RightAssistantSidebar = memo(RightAssistantSidebarView) diff --git a/src/renderer/src/SettingsPanel.tsx b/src/renderer/src/SettingsPanel.tsx index 6aa5a483..a2dd0273 100644 --- a/src/renderer/src/SettingsPanel.tsx +++ b/src/renderer/src/SettingsPanel.tsx @@ -15,7 +15,8 @@ import { useEffect, useLayoutEffect, useRef, - useState + useState, + memo } from 'react' import { useTranslation } from 'react-i18next' import { InlineHelp } from './InlineHelp' @@ -642,7 +643,7 @@ function RuntimeOverviewCard({ ) } -export function SettingsPanel({ +function SettingsPanelView({ open, presentation = 'modal', initialCategory, @@ -4309,3 +4310,6 @@ export function SettingsPanel({ ) return presentation === 'modal' ? createPortal(panel, document.body) : panel } + +/** Memoized: App re-renders on chat updates; this view re-renders only when its props change. */ +export const SettingsPanel = memo(SettingsPanelView) diff --git a/src/renderer/src/use-knowledge-workspace-actions.ts b/src/renderer/src/use-knowledge-workspace-actions.ts new file mode 100644 index 00000000..522def7b --- /dev/null +++ b/src/renderer/src/use-knowledge-workspace-actions.ts @@ -0,0 +1,180 @@ +import type { Dispatch, SetStateAction } from "react"; +import type { TFunction } from "i18next"; +import type { KnowledgeSnapshot } from "../../shared/contracts"; +import type { AppNotificationInput } from "./notifications"; +import type { KnowledgeWorkspaceProps } from "./knowledge-workspace/types"; +import { useStableHandlers } from "./stable-derived-value"; + +/** The knowledge route's callbacks; moved out of App's JSX so they keep their identity. */ +export type KnowledgeWorkspaceActions = Required>; + +export type KnowledgeWorkspaceActionDeps = { + notify: (input: AppNotificationInput) => void; + t: TFunction<"app">; + selectedLibraryId: string | undefined; + refreshKnowledge: (libraryId?: string) => Promise; + refreshSelectedKnowledge: () => Promise; + retryKnowledgeLoad: () => Promise; + runKnowledgeSourceAction: (action: () => Promise) => Promise; + createKnowledgeLibrary: KnowledgeWorkspaceProps["onCreateLibrary"]; + deleteKnowledgeLibrary: KnowledgeWorkspaceProps["onDeleteLibrary"]; + setEnabledKnowledgeLibraryIds: (update: SetStateAction) => void; + setKnowledgeSnapshot: Dispatch>; + openModelSettings: () => void; + showChatAndFocusComposer: () => void; +}; + +export function useKnowledgeWorkspaceActions( + deps: KnowledgeWorkspaceActionDeps, +): KnowledgeWorkspaceActions { + const { + notify, t, selectedLibraryId, refreshKnowledge, refreshSelectedKnowledge, + runKnowledgeSourceAction: run, setEnabledKnowledgeLibraryIds, + } = deps; + // Looked up per call, like the former inline callbacks. + const knowledge = (): typeof window.goodbuddy.knowledge => window.goodbuddy.knowledge; + const requireLibrary = (): string => { + if (!selectedLibraryId) throw new Error(t("notices.selectKnowledgeBase")); + return selectedLibraryId; + }; + return useStableHandlers({ + notify, + onExternalChanged: async (snapshot, createdId) => { + await refreshKnowledge(createdId ?? snapshot?.selectedLibraryId ?? selectedLibraryId); + if (createdId) setEnabledKnowledgeLibraryIds((current) => [...new Set([...current, createdId])]); + }, + onRetryLoad: deps.retryKnowledgeLoad, + onSelectLibrary: (libraryId) => { + void refreshKnowledge(libraryId).catch(() => { + // KnowledgeWorkspace renders the recoverable load error. + }); + }, + onCreateLibrary: deps.createKnowledgeLibrary, + onDeleteLibrary: deps.deleteKnowledgeLibrary, + onUpdateLibrary: async (libraryId, update) => { + await run(async () => { + await knowledge().updateLibrary(libraryId, update); + }); + notify({ + tone: "success", + message: t("notices.knowledgeSettingsUpdated"), + dedupeKey: `knowledge-library:${libraryId}`, + }); + }, + onReextractGraph: async (libraryId) => { + await run(() => knowledge().reextractGraph(libraryId)); + notify({ + tone: "success", + message: t("notices.knowledgeGraphRebuilt"), + dedupeKey: `knowledge-graph:${libraryId}`, + }); + }, + onImportFiles: (libraryId, files, graphStrategy) => + run(() => knowledge().importDroppedFiles(libraryId, files, graphStrategy)), + onImportDirectory: (libraryId, _files, graphStrategy) => + run(() => knowledge().selectDirectory(libraryId, graphStrategy)), + onImportUrl: (libraryId, url, graphStrategy) => + run(() => knowledge().importUrl(libraryId, url, graphStrategy)), + onOpenDocumentSource: (libraryId, documentId) => + knowledge().openDocumentSource({ knowledgeBaseId: libraryId, documentId }), + onOpenModelSettings: deps.openModelSettings, + onSyncSource: (sourceId) => run(() => knowledge().syncSource(sourceId)), + onPauseSource: (sourceId) => run(() => knowledge().pauseSource(sourceId)), + onRetrySource: (sourceId) => run(() => knowledge().retrySource(sourceId)), + onRemoveSource: (sourceId) => run(() => knowledge().removeSource(sourceId)), + onRetrieve: (libraryId, query, settings) => + knowledge().retrieve({ knowledgeBaseId: libraryId, query, settings }), + onUpdateKnowledgeSettings: async (libraryId, settings) => { + await run(() => knowledge().updateSettings({ knowledgeBaseId: libraryId, ...settings })); + notify({ + tone: "success", + message: t("notices.knowledgeSettingsUpdated"), + dedupeKey: `knowledge-retrieval-settings:${libraryId}`, + }); + }, + onListChunks: ({ libraryId, documentId, page, pageSize, search }) => + knowledge().listChunks({ knowledgeBaseId: libraryId, documentId, page, pageSize, search }), + onUpdateChunk: (input) => knowledge().updateChunk(input), + onDeleteChunk: (input) => knowledge().deleteChunk(input), + onRebuildDocument: (libraryId, documentId) => + run(async () => { + await knowledge().rebuildDocument({ knowledgeBaseId: libraryId, documentId }); + }), + onRebuildLibrary: (libraryId) => + run(async () => { + const result = await knowledge().rebuildLibrary({ knowledgeBaseId: libraryId }); + if (result.failed > 0) { + throw new Error(t("notices.knowledgeRebuildPartial", { + rebuilt: result.rebuilt, + failed: result.failed, + })); + } + notify({ + tone: "success", + message: t("notices.knowledgeRebuildCompleted", { count: result.rebuilt }), + dedupeKey: `knowledge-rebuild:${libraryId}`, + }); + }), + onCancelRebuild: async (libraryId) => { + if (!await knowledge().cancelRebuild(libraryId)) { + throw new Error(t("notices.knowledgeRebuildNotRunning")); + } + }, + onGetEmbeddingIndex: (libraryId) => knowledge().getEmbeddingIndex(libraryId), + onRebuildEmbeddingIndex: (libraryId) => knowledge().rebuildEmbeddingIndex(libraryId), + onCancelTask: async (taskId) => { + if (!await knowledge().cancelTask(taskId)) { + throw new Error(t("notices.knowledgeTaskNotRunning")); + } + await refreshSelectedKnowledge(); + }, + onRetryTask: (taskId) => run(() => knowledge().retryTask(taskId)), + onOpenReferenceSource: (input) => knowledge().openReferenceSource(input), + onUseInChat: (libraryId) => { + setEnabledKnowledgeLibraryIds((current) => + current.includes(libraryId) ? current : [...current, libraryId]); + deps.showChatAndFocusComposer(); + }, + onMoveNode: (nodeId, position) => { + deps.setKnowledgeSnapshot((current) => ({ + ...current, + graphNodes: current.graphNodes.map((node) => + node.id === nodeId ? { ...node, ...position } : node), + })); + void knowledge().moveEntity(nodeId, position).catch(() => void refreshSelectedKnowledge()); + }, + onCreateEntity: async (input) => { + const libraryId = requireLibrary(); + await run(() => knowledge().createEntity(libraryId, input)); + }, + onUpdateEntity: (entityId, update) => run(() => knowledge().updateEntity(entityId, update)), + onDeleteEntity: (entityId) => run(() => knowledge().deleteEntity(entityId)), + onMergeEntities: (sourceId, targetId) => run(() => knowledge().mergeEntities(sourceId, targetId)), + onCreateRelation: async (input) => { + const libraryId = requireLibrary(); + await run(() => knowledge().createRelation(libraryId, input)); + }, + onUpdateRelation: (relationId, input) => run(() => knowledge().updateRelation(relationId, input)), + onDeleteRelation: (relationId) => run(() => knowledge().deleteRelation(relationId)), + onOpenEvidence: (evidence) => + notify({ + tone: "info", + message: t("notices.evidenceExcerpt", { + source: `${evidence.documentName}${evidence.location ? ` · ${evidence.location}` : ""}`, + excerpt: evidence.excerpt, + }).slice(0, 500), + }), + }); +} diff --git a/src/renderer/src/use-magic-note-draft.tsx b/src/renderer/src/use-magic-note-draft.tsx index 1e6a1240..bcab6bce 100644 --- a/src/renderer/src/use-magic-note-draft.tsx +++ b/src/renderer/src/use-magic-note-draft.tsx @@ -1,4 +1,4 @@ -import { useCallback, useLayoutEffect, useRef, useState } from 'react' +import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react' import { createPortal } from 'react-dom' import { useTranslation } from 'react-i18next' import { X } from 'lucide-react' @@ -32,14 +32,18 @@ export function useMagicNoteDraft() { if (!pendingGuard.current) pendingGuard.current = new Promise(resolve => setConfirmation({ resolve })) return pendingGuard.current }, []) - const finishGuard = (discard: boolean): void => { - if (discard) { current.current = { ...current.current, draft: undefined }; setDraft(undefined) } - confirmation?.resolve(discard) - setConfirmation(undefined) - pendingGuard.current = undefined - } - return { draft, setDraft, saving, setSaving, selectedNoteId, setSelectedNoteId, guard, - confirmation: confirmation ? : null } + // The result keeps its identity while nothing in it changes, so views that + // receive it (the notes panel) can skip re-rendering with their parent. + return useMemo(() => { + const finishGuard = (discard: boolean): void => { + if (discard) { current.current = { ...current.current, draft: undefined }; setDraft(undefined) } + confirmation?.resolve(discard) + setConfirmation(undefined) + pendingGuard.current = undefined + } + return { draft, setDraft, saving, setSaving, selectedNoteId, setSelectedNoteId, guard, + confirmation: confirmation ? : null } + }, [confirmation, draft, guard, saving, selectedNoteId]) } function MagicNoteDraftConfirmation({ onResolve }: { onResolve: (discard: boolean) => void }): React.JSX.Element {