perf(renderer): memoize keep-alive routes, workspaces and sidebars behind stable callbacks
This commit is contained in:
+209
-463
@@ -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,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)
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user