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 { formatAttachmentSize, resizeComposerTextarea } from "./composer-textarea";
|
||||||
import { Composer } from "./Composer";
|
import { Composer } from "./Composer";
|
||||||
import { useComposerActions } from "./use-composer-actions";
|
import { useComposerActions } from "./use-composer-actions";
|
||||||
|
import { useKnowledgeWorkspaceActions } from "./use-knowledge-workspace-actions";
|
||||||
import { LiveMessageStoreContext } from "./live-message-store";
|
import { LiveMessageStoreContext } from "./live-message-store";
|
||||||
import {
|
import {
|
||||||
createConversationPersistence,
|
createConversationPersistence,
|
||||||
@@ -206,6 +207,7 @@ import { useExecutionStats } from "./use-execution-stats";
|
|||||||
import {
|
import {
|
||||||
RightAssistantSidebar,
|
RightAssistantSidebar,
|
||||||
type AssistantSidebarTab,
|
type AssistantSidebarTab,
|
||||||
|
type RightAssistantSidebarProps,
|
||||||
type SidebarArtifact,
|
type SidebarArtifact,
|
||||||
} from "./RightAssistantSidebar";
|
} from "./RightAssistantSidebar";
|
||||||
import {
|
import {
|
||||||
@@ -250,10 +252,8 @@ import {
|
|||||||
touchAndPruneKeepAliveEntries,
|
touchAndPruneKeepAliveEntries,
|
||||||
type KeepAliveCacheEntry,
|
type KeepAliveCacheEntry,
|
||||||
} from "./keep-alive-cache";
|
} from "./keep-alive-cache";
|
||||||
import {
|
import type { ReportWorkspaceUnsavedChanges } from "./workspace-unsaved-changes";
|
||||||
WorkspaceUnsavedChangesContext,
|
import { KeepAliveRoute } from "./KeepAliveRoute";
|
||||||
type ReportWorkspaceUnsavedChanges,
|
|
||||||
} from "./workspace-unsaved-changes";
|
|
||||||
import { activateModalFocus, trapTabFocus } from "./dialog-focus";
|
import { activateModalFocus, trapTabFocus } from "./dialog-focus";
|
||||||
import { FloatingPortal } from "./FloatingPortal";
|
import { FloatingPortal } from "./FloatingPortal";
|
||||||
import {
|
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<
|
class RouteErrorBoundary extends Component<
|
||||||
{ children: ReactNode; fallback: ReactNode },
|
{ children: ReactNode; fallback: ReactNode },
|
||||||
@@ -598,6 +573,11 @@ const emptyTokenUsage: TokenUsageSummary = {
|
|||||||
const storageKey = "goodbuddy.conversations.v1";
|
const storageKey = "goodbuddy.conversations.v1";
|
||||||
const emptyConversationTasks: AssistantTask[] = [];
|
const emptyConversationTasks: AssistantTask[] = [];
|
||||||
const emptyAttachments: ContextAttachment[] = [];
|
const emptyAttachments: ContextAttachment[] = [];
|
||||||
|
type RightSidebarHandlers = Required<Pick<RightAssistantSidebarProps,
|
||||||
|
| "onBeforeCloseNotes" | "onOpenSupervisionGraph" | "onContinueSupervision" | "onCreateCustomTask"
|
||||||
|
| "onBackBrowser" | "onNavigateBrowser" | "onReloadBrowser" | "onStopLoadingBrowser"
|
||||||
|
| "onImportArtifacts" | "onLoadArtifact" | "onOpenTask" | "onRespondApproval"
|
||||||
|
>>;
|
||||||
const emptyImageReferences: AssistantArtifact[] = [];
|
const emptyImageReferences: AssistantArtifact[] = [];
|
||||||
|
|
||||||
const activeProjectStorageKey = "goodbuddy.active-project.v1";
|
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 => {
|
const openQuickNotes = useCallback((): void => {
|
||||||
if (!magicNotesEnabled) return;
|
if (!magicNotesEnabled) return;
|
||||||
@@ -6785,6 +6780,179 @@ function App(): React.JSX.Element {
|
|||||||
await command(browserApi);
|
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 (
|
return (
|
||||||
<div className="app-shell" data-frosted-glass={applicationSettings?.transparentFrostedEffectEnabled ? 'true' : undefined}>
|
<div className="app-shell" data-frosted-glass={applicationSettings?.transparentFrostedEffectEnabled ? 'true' : undefined}>
|
||||||
<LiveMessageStoreContext value={liveMessages}>
|
<LiveMessageStoreContext value={liveMessages}>
|
||||||
@@ -6855,14 +7023,8 @@ function App(): React.JSX.Element {
|
|||||||
activityByProjectId={projectActivity.byProjectId}
|
activityByProjectId={projectActivity.byProjectId}
|
||||||
recoveryByProjectId={projectRecoveryByProjectId}
|
recoveryByProjectId={projectRecoveryByProjectId}
|
||||||
runtimeSettings={runtimeSettings}
|
runtimeSettings={runtimeSettings}
|
||||||
onArchive={archiveProject}
|
{...projectSwitcherActions}
|
||||||
onCreate={createProject}
|
|
||||||
onDelete={deleteProject}
|
|
||||||
onRemoteCommitted={loadCommittedRemoteProject}
|
|
||||||
onRetryRecovery={retryProjectRecovery}
|
onRetryRecovery={retryProjectRecovery}
|
||||||
onSelect={selectProject}
|
|
||||||
onSelectRoot={() => window.goodbuddy.settings.selectWorkspace()}
|
|
||||||
onUpdate={updateProject}
|
|
||||||
projects={projects}
|
projects={projects}
|
||||||
remoteProjectsEnabled={remoteProjectsEnabled}
|
remoteProjectsEnabled={remoteProjectsEnabled}
|
||||||
/>
|
/>
|
||||||
@@ -7412,7 +7574,7 @@ function App(): React.JSX.Element {
|
|||||||
<RouteLoadingStatus label={t("route.loading")} />
|
<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>
|
</Suspense>
|
||||||
</RouteErrorBoundary>
|
</RouteErrorBoundary>
|
||||||
</PageShell>
|
</PageShell>
|
||||||
@@ -7442,12 +7604,8 @@ function App(): React.JSX.Element {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<KnowledgeWorkspace
|
<KnowledgeWorkspace
|
||||||
|
{...knowledgeWorkspaceActions}
|
||||||
externalInstances={externalInstances}
|
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}
|
documents={knowledgeSnapshot.documents}
|
||||||
evidence={knowledgeSnapshot.evidence}
|
evidence={knowledgeSnapshot.evidence}
|
||||||
graphNodes={knowledgeSnapshot.graphNodes}
|
graphNodes={knowledgeSnapshot.graphNodes}
|
||||||
@@ -7455,308 +7613,6 @@ function App(): React.JSX.Element {
|
|||||||
libraries={knowledgeSnapshot.libraries}
|
libraries={knowledgeSnapshot.libraries}
|
||||||
loadError={knowledgeLoadError}
|
loadError={knowledgeLoadError}
|
||||||
loading={knowledgeLoading}
|
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}
|
selectedLibraryId={knowledgeSnapshot.selectedLibraryId}
|
||||||
sources={knowledgeSnapshot.sources}
|
sources={knowledgeSnapshot.sources}
|
||||||
tasks={knowledgeSnapshot.tasks}
|
tasks={knowledgeSnapshot.tasks}
|
||||||
@@ -7794,7 +7650,7 @@ function App(): React.JSX.Element {
|
|||||||
applicationSettingsLocked={applicationSettingsUnconfirmed}
|
applicationSettingsLocked={applicationSettingsUnconfirmed}
|
||||||
applicationSettingsError={applicationSettingsError}
|
applicationSettingsError={applicationSettingsError}
|
||||||
onUpdateApplicationSettings={updateApplicationSettings}
|
onUpdateApplicationSettings={updateApplicationSettings}
|
||||||
onRetryApplicationSettings={() => void reloadApplicationSettings()}
|
onRetryApplicationSettings={heartbeatActions.onRetryApplicationSettings}
|
||||||
active={view === 'heartbeat'}
|
active={view === 'heartbeat'}
|
||||||
graphNavigation={supervisionGraphNavigation}
|
graphNavigation={supervisionGraphNavigation}
|
||||||
onOpenConversation={openActivityConversation}
|
onOpenConversation={openActivityConversation}
|
||||||
@@ -7808,11 +7664,11 @@ function App(): React.JSX.Element {
|
|||||||
onRetryLoad={retryHeartbeatLoad}
|
onRetryLoad={retryHeartbeatLoad}
|
||||||
onRemove={removeHeartbeat}
|
onRemove={removeHeartbeat}
|
||||||
onRunNow={runHeartbeat}
|
onRunNow={runHeartbeat}
|
||||||
onSetMemoryStatus={setMemoryStatus}
|
onSetMemoryStatus={heartbeatActions.onSetMemoryStatus}
|
||||||
onSetPaused={setHeartbeatPaused}
|
onSetPaused={setHeartbeatPaused}
|
||||||
onSetTaskStatus={setHeartbeatTaskStatus}
|
onSetTaskStatus={heartbeatActions.onSetTaskStatus}
|
||||||
onUpdate={updateHeartbeat}
|
onUpdate={updateHeartbeat}
|
||||||
onUseFollowUpTask={useHeartbeatTask}
|
onUseFollowUpTask={heartbeatActions.onUseFollowUpTask}
|
||||||
projects={projects}
|
projects={projects}
|
||||||
runs={heartbeatRuns}
|
runs={heartbeatRuns}
|
||||||
tasks={assistantTasks}
|
tasks={assistantTasks}
|
||||||
@@ -7878,13 +7734,12 @@ function App(): React.JSX.Element {
|
|||||||
>
|
>
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<SettingsPanel
|
<SettingsPanel
|
||||||
|
{...settingsPanelActions}
|
||||||
appearanceTheme={appearanceTheme}
|
appearanceTheme={appearanceTheme}
|
||||||
transparentFrostedEffectEnabled={applicationSettings?.transparentFrostedEffectEnabled ?? true}
|
transparentFrostedEffectEnabled={applicationSettings?.transparentFrostedEffectEnabled ?? true}
|
||||||
applicationSettingsPending={applicationSettingsPending}
|
applicationSettingsPending={applicationSettingsPending}
|
||||||
applicationSettingsLocked={applicationSettingsUnconfirmed || !applicationSettings}
|
applicationSettingsLocked={applicationSettingsUnconfirmed || !applicationSettings}
|
||||||
applicationSettingsError={applicationSettingsError}
|
applicationSettingsError={applicationSettingsError}
|
||||||
onTransparentFrostedEffectEnabledChange={(enabled) => updateApplicationSettings({ transparentFrostedEffectEnabled: enabled })}
|
|
||||||
onRetryApplicationSettings={() => void reloadApplicationSettings()}
|
|
||||||
brandingFallbackLogo={
|
brandingFallbackLogo={
|
||||||
resolvedAppearanceTheme === "dark"
|
resolvedAppearanceTheme === "dark"
|
||||||
? goodbuddyDarkIcon
|
? goodbuddyDarkIcon
|
||||||
@@ -7899,46 +7754,9 @@ function App(): React.JSX.Element {
|
|||||||
setConversationHtmlRenderingEnabled
|
setConversationHtmlRenderingEnabled
|
||||||
}
|
}
|
||||||
onAppearanceThemeChange={setAppearanceTheme}
|
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}
|
onNotify={notify}
|
||||||
onLeaveRequestReady={registerSettingsLeaveRequester}
|
onLeaveRequestReady={registerSettingsLeaveRequester}
|
||||||
onProjectsDeleted={removeProjectsFromUi}
|
|
||||||
onSaved={(settings) => {
|
|
||||||
setRuntimeSettings(settings);
|
|
||||||
}}
|
|
||||||
onShortcutSettingsChanged={handleShortcutSettingsChanged}
|
onShortcutSettingsChanged={handleShortcutSettingsChanged}
|
||||||
onUpdateProject={updateProject}
|
|
||||||
open={settingsOpen}
|
open={settingsOpen}
|
||||||
projects={projects}
|
projects={projects}
|
||||||
/>
|
/>
|
||||||
@@ -8101,21 +7919,14 @@ function App(): React.JSX.Element {
|
|||||||
)}</ConversationListView>
|
)}</ConversationListView>
|
||||||
)}
|
)}
|
||||||
<RightAssistantSidebar
|
<RightAssistantSidebar
|
||||||
|
{...rightSidebarActions}
|
||||||
notesEnabled={magicNotesEnabled}
|
notesEnabled={magicNotesEnabled}
|
||||||
notesSettingsReady={Boolean(applicationSettings)}
|
notesSettingsReady={Boolean(applicationSettings)}
|
||||||
notesOpenRequest={notesOpenRequest}
|
notesOpenRequest={notesOpenRequest}
|
||||||
onBeforeCloseNotes={async () => { if (!await guardNoteDraft()) return false; setNoteDraft(undefined); return true; }}
|
notesPanel={notesPanel}
|
||||||
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'); })} />}
|
|
||||||
nativeTerminals={nativeTerminals}
|
nativeTerminals={nativeTerminals}
|
||||||
activeConversationId={activeId}
|
activeConversationId={activeId}
|
||||||
conversationStats={statsConversation && executionStats.conversation ? {
|
conversationStats={conversationStats}
|
||||||
conversationId: statsConversation.id,
|
|
||||||
messageCount: statsMessageCount,
|
|
||||||
replyDurationMs: executionStats.conversation.durationMs,
|
|
||||||
incomplete: executionStats.conversation.incompleteRequestCount > 0,
|
|
||||||
} : undefined}
|
|
||||||
taskDurations={taskDurations}
|
taskDurations={taskDurations}
|
||||||
approvals={pendingSidebarApprovals}
|
approvals={pendingSidebarApprovals}
|
||||||
artifacts={sidebarArtifacts}
|
artifacts={sidebarArtifacts}
|
||||||
@@ -8124,73 +7935,12 @@ function App(): React.JSX.Element {
|
|||||||
currentProject={activeProject}
|
currentProject={activeProject}
|
||||||
supervisionEnabled={isApplicationEnabled(applicationSettings, 'heartbeat')}
|
supervisionEnabled={isApplicationEnabled(applicationSettings, 'heartbeat')}
|
||||||
supervisionLibraries={knowledgeSnapshot.libraries}
|
supervisionLibraries={knowledgeSnapshot.libraries}
|
||||||
onOpenSupervisionGraph={(resultId) => {
|
|
||||||
requestWorkspaceLeave('heartbeat', () => {
|
|
||||||
setSupervisionGraphNavigation({ resultId });
|
|
||||||
commitView('heartbeat');
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
onOpenSupervisionConversation={openActivityConversation}
|
onOpenSupervisionConversation={openActivityConversation}
|
||||||
onContinueSupervision={async (prompt, conversationId) => {
|
|
||||||
await window.goodbuddy.supervision.continue({
|
|
||||||
conversationId,
|
|
||||||
prompt,
|
|
||||||
})
|
|
||||||
openActivityConversation(conversationId)
|
|
||||||
}}
|
|
||||||
onCreateCustomTask={() => openCustomTaskDialog("current")}
|
|
||||||
schedules={assistantSchedules}
|
schedules={assistantSchedules}
|
||||||
selectedTaskId={selectedAssistantTaskId}
|
selectedTaskId={selectedAssistantTaskId}
|
||||||
tasks={productAssistantTasks}
|
tasks={productAssistantTasks}
|
||||||
projectNames={projectNames}
|
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}
|
onRemoveSchedule={removeAssistantSchedule}
|
||||||
onRespondApproval={(approval, decision) => {
|
|
||||||
void respondToApproval(
|
|
||||||
approval.conversationId,
|
|
||||||
approval.messageId,
|
|
||||||
approval.approvalId,
|
|
||||||
decision,
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
onRunSchedule={runAssistantSchedule}
|
onRunSchedule={runAssistantSchedule}
|
||||||
onSetScheduleEnabled={setAssistantScheduleEnabled}
|
onSetScheduleEnabled={setAssistantScheduleEnabled}
|
||||||
onListWorkspaceDirectory={listWorkspaceDirectory}
|
onListWorkspaceDirectory={listWorkspaceDirectory}
|
||||||
@@ -8202,11 +7952,7 @@ function App(): React.JSX.Element {
|
|||||||
open={assistantSidebarOpen}
|
open={assistantSidebarOpen}
|
||||||
restoreFocusRef={assistantSidebarToggleRef}
|
restoreFocusRef={assistantSidebarToggleRef}
|
||||||
tab={assistantSidebarTab}
|
tab={assistantSidebarTab}
|
||||||
workspaceChanges={
|
workspaceChanges={activeWorkspaceChanges}
|
||||||
workspaceChanges?.projectId === activeProjectId
|
|
||||||
? workspaceChanges.changes
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
workspaceProjectId={activeProjectId || undefined}
|
workspaceProjectId={activeProjectId || undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
XCircle
|
XCircle
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { SupervisionSuggestionsPanel } from './SupervisionSuggestionsPanel'
|
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 { useTranslation } from 'react-i18next'
|
||||||
import type {
|
import type {
|
||||||
AssistantHeartbeatConfig,
|
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} />
|
return <UnifiedSupervisorCenter {...props} />
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -616,3 +616,6 @@ function HeartbeatSections({
|
|||||||
</section>
|
</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 { stripKnowledgeHighlightTags } from '../../shared/knowledge-text'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { resolvedLocale, formatNumber } from './knowledge-workspace/formatting'
|
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 { ExternalKnowledgeProvider } from '../../shared/external-knowledge-contracts'
|
||||||
import { type KnowledgeTaskContext, KnowledgeTasksView } from './knowledge-workspace/KnowledgeTasksView'
|
import { type KnowledgeTaskContext, KnowledgeTasksView } from './knowledge-workspace/KnowledgeTasksView'
|
||||||
import { toErrorMessage, strategyLabelKeys } from './knowledge-workspace/helpers'
|
import { toErrorMessage, strategyLabelKeys } from './knowledge-workspace/helpers'
|
||||||
@@ -139,7 +139,7 @@ function toWorkbenchResponse(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function KnowledgeWorkspace({
|
function KnowledgeWorkspaceView({
|
||||||
externalInstances = [],
|
externalInstances = [],
|
||||||
onExternalChanged = () => {},
|
onExternalChanged = () => {},
|
||||||
notify = () => {},
|
notify = () => {},
|
||||||
@@ -970,3 +970,6 @@ export function KnowledgeWorkspace({
|
|||||||
</div>
|
</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,
|
useLayoutEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
useState
|
useState,
|
||||||
|
memo
|
||||||
} from 'react'
|
} from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { InlineHelp } from './InlineHelp'
|
import { InlineHelp } from './InlineHelp'
|
||||||
@@ -376,7 +377,7 @@ function TodoListItem({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MagicNotesWorkspace({
|
function MagicNotesWorkspaceView({
|
||||||
navigation,
|
navigation,
|
||||||
onOpenSource,
|
onOpenSource,
|
||||||
onNotify,
|
onNotify,
|
||||||
@@ -3399,3 +3400,6 @@ export function MagicNotesWorkspace({
|
|||||||
</div>
|
</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
|
X
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import type { LucideIcon } 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 { createPortal } from 'react-dom'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import type {
|
import type {
|
||||||
@@ -212,7 +212,7 @@ function isRemoteAbsolutePath(value: string): boolean {
|
|||||||
return remoteProjectRootPathSchema.safeParse(value).success
|
return remoteProjectRootPathSchema.safeParse(value).success
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProjectSwitcher({
|
function ProjectSwitcherView({
|
||||||
projects,
|
projects,
|
||||||
activeProjectId,
|
activeProjectId,
|
||||||
activityByProjectId = {},
|
activityByProjectId = {},
|
||||||
@@ -2141,3 +2141,6 @@ export function ProjectSwitcher({
|
|||||||
</div>
|
</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,
|
useLayoutEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
useState
|
useState,
|
||||||
|
memo
|
||||||
} from 'react'
|
} from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import type {
|
import type {
|
||||||
@@ -807,7 +808,7 @@ function BrowserViewport({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RightAssistantSidebar({
|
function RightAssistantSidebarView({
|
||||||
notesEnabled = false,
|
notesEnabled = false,
|
||||||
notesSettingsReady = true,
|
notesSettingsReady = true,
|
||||||
notesOpenRequest = 0,
|
notesOpenRequest = 0,
|
||||||
@@ -2752,3 +2753,6 @@ export function RightAssistantSidebar({
|
|||||||
</aside>
|
</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,
|
useEffect,
|
||||||
useLayoutEffect,
|
useLayoutEffect,
|
||||||
useRef,
|
useRef,
|
||||||
useState
|
useState,
|
||||||
|
memo
|
||||||
} from 'react'
|
} from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { InlineHelp } from './InlineHelp'
|
import { InlineHelp } from './InlineHelp'
|
||||||
@@ -642,7 +643,7 @@ function RuntimeOverviewCard({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SettingsPanel({
|
function SettingsPanelView({
|
||||||
open,
|
open,
|
||||||
presentation = 'modal',
|
presentation = 'modal',
|
||||||
initialCategory,
|
initialCategory,
|
||||||
@@ -4309,3 +4310,6 @@ export function SettingsPanel({
|
|||||||
)
|
)
|
||||||
return presentation === 'modal' ? createPortal(panel, document.body) : panel
|
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 { createPortal } from 'react-dom'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { X } from 'lucide-react'
|
import { X } from 'lucide-react'
|
||||||
@@ -32,14 +32,18 @@ export function useMagicNoteDraft() {
|
|||||||
if (!pendingGuard.current) pendingGuard.current = new Promise<boolean>(resolve => setConfirmation({ resolve }))
|
if (!pendingGuard.current) pendingGuard.current = new Promise<boolean>(resolve => setConfirmation({ resolve }))
|
||||||
return pendingGuard.current
|
return pendingGuard.current
|
||||||
}, [])
|
}, [])
|
||||||
const finishGuard = (discard: boolean): void => {
|
// The result keeps its identity while nothing in it changes, so views that
|
||||||
if (discard) { current.current = { ...current.current, draft: undefined }; setDraft(undefined) }
|
// receive it (the notes panel) can skip re-rendering with their parent.
|
||||||
confirmation?.resolve(discard)
|
return useMemo(() => {
|
||||||
setConfirmation(undefined)
|
const finishGuard = (discard: boolean): void => {
|
||||||
pendingGuard.current = undefined
|
if (discard) { current.current = { ...current.current, draft: undefined }; setDraft(undefined) }
|
||||||
}
|
confirmation?.resolve(discard)
|
||||||
return { draft, setDraft, saving, setSaving, selectedNoteId, setSelectedNoteId, guard,
|
setConfirmation(undefined)
|
||||||
confirmation: confirmation ? <MagicNoteDraftConfirmation onResolve={finishGuard} /> : null }
|
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 {
|
function MagicNoteDraftConfirmation({ onResolve }: { onResolve: (discard: boolean) => void }): React.JSX.Element {
|
||||||
|
|||||||
Reference in New Issue
Block a user