perf(renderer): memoize keep-alive routes, workspaces and sidebars behind stable callbacks

This commit is contained in:
mesalogo
2026-10-03 17:59:20 +08:00
parent 41be9f8076
commit 2e3d2e77a9
11 changed files with 540 additions and 484 deletions
+209 -463
View File
@@ -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 (
<div
aria-hidden={active ? undefined : "true"}
className="workspace-route-cache"
data-route={route}
hidden={!active}
inert={!active}
>
<WorkspaceUnsavedChangesContext.Provider value={onUnsavedChanges}>
{children}
</WorkspaceUnsavedChangesContext.Provider>
</div>
);
}
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<Pick<RightAssistantSidebarProps,
| "onBeforeCloseNotes" | "onOpenSupervisionGraph" | "onContinueSupervision" | "onCreateCustomTask"
| "onBackBrowser" | "onNavigateBrowser" | "onReloadBrowser" | "onStopLoadingBrowser"
| "onImportArtifacts" | "onLoadArtifact" | "onOpenTask" | "onRespondApproval"
>>;
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<typeof saveBrandingPreferences>[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<RightSidebarHandlers>({
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(
() => (
<MagicNotesPanel
state={noteDraft}
active={notesPanelActive}
commentMode={applicationSettings?.magicNoteCommentMode}
commentFormat={applicationSettings?.magicNoteCommentFormat}
onNotify={notify}
onOpenSource={magicNotesActions.onOpenSource}
onCancelCapture={magicNotesActions.onCancelNoteCapture}
onOpenWorkspace={magicNotesActions.onOpenNoteWorkspace}
/>
),
[
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 (
<div className="app-shell" data-frosted-glass={applicationSettings?.transparentFrostedEffectEnabled ? 'true' : undefined}>
<LiveMessageStoreContext value={liveMessages}>
@@ -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 {
<RouteLoadingStatus label={t("route.loading")} />
}
>
<MagicNotesWorkspace onNotify={notify} applicationSettings={applicationSettings} onBeforeLeave={registerNotesLeaveRequester} navigation={notesNavigation} onOpenSource={openNoteSource} />
<MagicNotesWorkspace onNotify={notify} applicationSettings={applicationSettings} onBeforeLeave={registerNotesLeaveRequester} navigation={notesNavigation} onOpenSource={magicNotesActions.onOpenSource} />
</Suspense>
</RouteErrorBoundary>
</PageShell>
@@ -7442,12 +7604,8 @@ function App(): React.JSX.Element {
}
>
<KnowledgeWorkspace
{...knowledgeWorkspaceActions}
externalInstances={externalInstances}
notify={notify}
onExternalChanged={async (snapshot, createdId) => {
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 {
>
<Suspense fallback={null}>
<SettingsPanel
{...settingsPanelActions}
appearanceTheme={appearanceTheme}
transparentFrostedEffectEnabled={applicationSettings?.transparentFrostedEffectEnabled ?? true}
applicationSettingsPending={applicationSettingsPending}
applicationSettingsLocked={applicationSettingsUnconfirmed || !applicationSettings}
applicationSettingsError={applicationSettingsError}
onTransparentFrostedEffectEnabledChange={(enabled) => 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 {
)}</ConversationListView>
)}
<RightAssistantSidebar
{...rightSidebarActions}
notesEnabled={magicNotesEnabled}
notesSettingsReady={Boolean(applicationSettings)}
notesOpenRequest={notesOpenRequest}
onBeforeCloseNotes={async () => { if (!await guardNoteDraft()) return false; setNoteDraft(undefined); return true; }}
notesPanel={<MagicNotesPanel state={noteDraft} active={assistantSidebarOpen && magicNotesEnabled} commentMode={applicationSettings?.magicNoteCommentMode} commentFormat={applicationSettings?.magicNoteCommentFormat} onNotify={notify} onOpenSource={openNoteSource}
onCancelCapture={() => 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}
/>
</div>
+5 -2
View File
@@ -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<T extends { createdAt: string }>(left: T, right: T): number {
)
}
export function HeartbeatCenter(props: HeartbeatCenterProps): React.JSX.Element {
function HeartbeatCenterView(props: HeartbeatCenterProps): React.JSX.Element {
return <UnifiedSupervisorCenter {...props} />
}
@@ -616,3 +616,6 @@ function HeartbeatSections({
</section>
)
}
/** Memoized: App re-renders on chat updates; this view re-renders only when its props change. */
export const HeartbeatCenter = memo(HeartbeatCenterView)
+61
View File
@@ -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 <span>{label}</span>;
}
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) => (
<KeepAliveRoute active={active} route="knowledge">
<Child label={label} onRender={renders} />
</KeepAliveRoute>
);
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) => (
<KeepAliveRoute active={active} route="knowledge">
<Child label={label} onRender={renders} />
</KeepAliveRoute>
);
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) => (
<KeepAliveRoute active={active} route="chat">
<Child label={label} onRender={renders} />
</KeepAliveRoute>
);
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);
});
});
+44
View File
@@ -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 (
<div
aria-hidden={active ? undefined : "true"}
className="workspace-route-cache"
data-route={route}
hidden={!active}
inert={!active}
>
<WorkspaceUnsavedChangesContext.Provider value={onUnsavedChanges}>
{children}
</WorkspaceUnsavedChangesContext.Provider>
</div>
);
}, keepHiddenRoute);
+5 -2
View File
@@ -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({
</div>
)
}
/** Memoized: App re-renders on chat updates; this view re-renders only when its props change. */
export const KnowledgeWorkspace = memo(KnowledgeWorkspaceView)
+6 -2
View File
@@ -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({
</div>
)
}
/** Memoized: App re-renders on chat updates; this view re-renders only when its props change. */
export const MagicNotesWorkspace = memo(MagicNotesWorkspaceView)
+5 -2
View File
@@ -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({
</div>
)
}
/** Memoized: App re-renders on chat updates; this view re-renders only when its props change. */
export const ProjectSwitcher = memo(ProjectSwitcherView)
+6 -2
View File
@@ -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({
</aside>
)
}
/** Memoized: App re-renders on chat updates; this view re-renders only when its props change. */
export const RightAssistantSidebar = memo(RightAssistantSidebarView)
+6 -2
View File
@@ -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)
@@ -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<Pick<
KnowledgeWorkspaceProps,
| "notify" | "onExternalChanged" | "onRetryLoad" | "onSelectLibrary" | "onCreateLibrary"
| "onDeleteLibrary" | "onUpdateLibrary" | "onReextractGraph" | "onImportFiles"
| "onImportDirectory" | "onImportUrl" | "onOpenDocumentSource" | "onOpenModelSettings"
| "onSyncSource" | "onPauseSource" | "onRetrySource" | "onRemoveSource" | "onRetrieve"
| "onUpdateKnowledgeSettings" | "onListChunks" | "onUpdateChunk" | "onDeleteChunk"
| "onRebuildDocument" | "onRebuildLibrary" | "onCancelRebuild" | "onGetEmbeddingIndex"
| "onRebuildEmbeddingIndex" | "onCancelTask" | "onRetryTask" | "onOpenReferenceSource"
| "onUseInChat" | "onMoveNode" | "onCreateEntity" | "onUpdateEntity" | "onDeleteEntity"
| "onMergeEntities" | "onCreateRelation" | "onUpdateRelation" | "onDeleteRelation"
| "onOpenEvidence"
>>;
export type KnowledgeWorkspaceActionDeps = {
notify: (input: AppNotificationInput) => void;
t: TFunction<"app">;
selectedLibraryId: string | undefined;
refreshKnowledge: (libraryId?: string) => Promise<unknown>;
refreshSelectedKnowledge: () => Promise<void>;
retryKnowledgeLoad: () => Promise<void>;
runKnowledgeSourceAction: <T>(action: () => Promise<T>) => Promise<T>;
createKnowledgeLibrary: KnowledgeWorkspaceProps["onCreateLibrary"];
deleteKnowledgeLibrary: KnowledgeWorkspaceProps["onDeleteLibrary"];
setEnabledKnowledgeLibraryIds: (update: SetStateAction<string[]>) => void;
setKnowledgeSnapshot: Dispatch<SetStateAction<KnowledgeSnapshot>>;
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<KnowledgeWorkspaceActions>({
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),
}),
});
}
+13 -9
View File
@@ -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<boolean>(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 ? <MagicNoteDraftConfirmation onResolve={finishGuard} /> : 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 ? <MagicNoteDraftConfirmation onResolve={finishGuard} /> : null }
}, [confirmation, draft, guard, saving, selectedNoteId])
}
function MagicNoteDraftConfirmation({ onResolve }: { onResolve: (discard: boolean) => void }): React.JSX.Element {