({
+ 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 (
+
+
+ {children}
+
+
+ );
+}, 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 {