perf(renderer): move assistant tasks and artifacts into a task store with narrow selectors and a sync module
This commit is contained in:
@@ -12656,9 +12656,11 @@ describe("App", () => {
|
||||
const card = within(region).getByRole("group");
|
||||
fireEvent.click(within(card).getByText(child.expertName, { selector: "strong" }));
|
||||
fireEvent(card, new Event("toggle"));
|
||||
// App selects only top-level task statuses from the task store
|
||||
// (PERF-13), so subagent tasks never reach it.
|
||||
const runningTasks = assistantTasksProbe.mock.lastCall![0] as AssistantTask[];
|
||||
expect(runningTasks).toContainEqual(expect.objectContaining({
|
||||
id: child.childTaskId, status: "running",
|
||||
expect(runningTasks).not.toContainEqual(expect.objectContaining({
|
||||
id: child.childTaskId,
|
||||
}));
|
||||
|
||||
const progress = (step: number) => [{
|
||||
@@ -12687,10 +12689,7 @@ describe("App", () => {
|
||||
|
||||
act(() => agentListener!({ ...child, state, progress: progress(3), output: "Latest child result" }));
|
||||
const terminalTasks = assistantTasksProbe.mock.lastCall![0] as AssistantTask[];
|
||||
expect(terminalTasks).not.toBe(runningTasks);
|
||||
expect(terminalTasks).toContainEqual(expect.objectContaining({
|
||||
id: child.childTaskId, status: state, completedAt: expect.any(String),
|
||||
}));
|
||||
expect(terminalTasks).toBe(runningTasks);
|
||||
expect(within(card).getByText(state === "completed" ? "已完成" : "已取消", {
|
||||
selector: ".subagent-status-card__status",
|
||||
})).toBeVisible();
|
||||
|
||||
+61
-129
@@ -166,6 +166,23 @@ import { formatAttachmentSize, resizeComposerTextarea } from "./composer-textare
|
||||
import { Composer } from "./Composer";
|
||||
import { useComposerActions } from "./use-composer-actions";
|
||||
import { useKnowledgeWorkspaceActions } from "./use-knowledge-workspace-actions";
|
||||
import { createTaskStore } from "./task-store";
|
||||
import {
|
||||
useAllTasks,
|
||||
useConversationHasBusyTask,
|
||||
useProductTasks,
|
||||
useSidebarArtifacts,
|
||||
useTaskActivityRows,
|
||||
useTaskStatsRevision,
|
||||
useTaskStatusRows,
|
||||
} from "./task-selectors";
|
||||
import {
|
||||
loadArtifact,
|
||||
refreshArtifacts,
|
||||
refreshTasks,
|
||||
useArtifactHydration,
|
||||
useInitialArtifactSync,
|
||||
} from "./task-sync";
|
||||
import { LiveMessageStoreContext } from "./live-message-store";
|
||||
import {
|
||||
createConversationPersistence,
|
||||
@@ -178,7 +195,6 @@ import {
|
||||
import { startConversationRefresh } from "./conversation-refresh";
|
||||
import {
|
||||
handleAgentEvent as applyAgentEvent,
|
||||
mergeArtifacts,
|
||||
type ActiveRun,
|
||||
type AgentEventDependencies,
|
||||
} from "./agent-event-handler";
|
||||
@@ -208,7 +224,6 @@ import {
|
||||
RightAssistantSidebar,
|
||||
type AssistantSidebarTab,
|
||||
type RightAssistantSidebarProps,
|
||||
type SidebarArtifact,
|
||||
} from "./RightAssistantSidebar";
|
||||
import {
|
||||
CustomTaskDialog,
|
||||
@@ -1099,22 +1114,16 @@ function App(): React.JSX.Element {
|
||||
Record<string, RemoteProjectRecoveryState>
|
||||
>({});
|
||||
const retryingRecoveryProjectIdsRef = useRef(new Set<string>());
|
||||
const [assistantTasks, setAssistantTasks] = useState<AssistantTask[]>([]);
|
||||
const assistantTasksRef = useRef(assistantTasks);
|
||||
// Tasks and artifacts live in a store; App selects only what it shows.
|
||||
const [taskStore] = useState(() => createTaskStore());
|
||||
const setAssistantTasks = taskStore.setTasks;
|
||||
const setAssistantArtifacts = taskStore.setArtifacts;
|
||||
const [tokenUsage, setTokenUsage] =
|
||||
useState<TokenUsageSummary>(emptyTokenUsage);
|
||||
const [workspaceChanges, setWorkspaceChanges] = useState<{
|
||||
projectId: string;
|
||||
changes: WorkspaceChanges;
|
||||
}>();
|
||||
const [assistantArtifacts, setAssistantArtifacts] = useState<
|
||||
AssistantArtifact[]
|
||||
>([]);
|
||||
const assistantArtifactById = useMemo(
|
||||
() =>
|
||||
new Map(assistantArtifacts.map((artifact) => [artifact.id, artifact])),
|
||||
[assistantArtifacts],
|
||||
);
|
||||
const [assistantMemories, setAssistantMemories] = useState<AssistantMemory[]>(
|
||||
[],
|
||||
);
|
||||
@@ -1408,7 +1417,7 @@ function App(): React.JSX.Element {
|
||||
protectedWorkspaceViews.add("activity");
|
||||
}
|
||||
if (
|
||||
assistantTasksRef.current.some(
|
||||
taskStore.getTasks().some(
|
||||
(task) =>
|
||||
task.status === "queued" ||
|
||||
task.status === "running" ||
|
||||
@@ -1447,7 +1456,7 @@ function App(): React.JSX.Element {
|
||||
target?.focus();
|
||||
});
|
||||
}
|
||||
}, [getPinnedWorkspaceViews]);
|
||||
}, [getPinnedWorkspaceViews, taskStore]);
|
||||
const requestWorkspaceLeave = useCallback((next: WorkspaceView, leave: () => void): void => {
|
||||
const leaveNotes = (): void => {
|
||||
if (viewRef.current === "magic-notes" && next !== "magic-notes" && notesLeaveRequesterRef.current) {
|
||||
@@ -2030,7 +2039,7 @@ function App(): React.JSX.Element {
|
||||
protectedWorkspaceViews.add("activity");
|
||||
}
|
||||
if (
|
||||
assistantTasks.some(
|
||||
taskStore.getTasks().some(
|
||||
(task) =>
|
||||
task.status === "queued" ||
|
||||
task.status === "running" ||
|
||||
@@ -2069,7 +2078,7 @@ function App(): React.JSX.Element {
|
||||
};
|
||||
const interval = window.setInterval(sweep, keepAliveSweepIntervalMs);
|
||||
return () => window.clearInterval(interval);
|
||||
}, [activeConversationViewIds, activeId, assistantTasks, getPinnedWorkspaceViews, knowledgeOperationCount, view, conversationStore]);
|
||||
}, [activeConversationViewIds, activeId, taskStore, getPinnedWorkspaceViews, knowledgeOperationCount, view, conversationStore]);
|
||||
|
||||
useEffect(() => {
|
||||
livePrimarySidebarWidthRef.current = primarySidebarWidth;
|
||||
@@ -2137,10 +2146,6 @@ function App(): React.JSX.Element {
|
||||
projectRecoveryByProjectIdRef.current = projectRecoveryByProjectId;
|
||||
}, [projectRecoveryByProjectId]);
|
||||
|
||||
useEffect(() => {
|
||||
assistantTasksRef.current = assistantTasks;
|
||||
}, [assistantTasks]);
|
||||
|
||||
useEffect(() => {
|
||||
saveAppearanceTheme(appearanceTheme);
|
||||
}, [appearanceTheme]);
|
||||
@@ -2808,17 +2813,13 @@ function App(): React.JSX.Element {
|
||||
() => new Set(conversationQueueItems.map((item) => item.conversationId)),
|
||||
[conversationQueueItems],
|
||||
);
|
||||
const productAssistantTasks = useMemo(
|
||||
() =>
|
||||
assistantTasks.filter(
|
||||
(task) => !task.parentTaskId && task.origin === "schedule",
|
||||
),
|
||||
[assistantTasks],
|
||||
);
|
||||
const executionStatsRevision = useMemo(
|
||||
() => assistantTasks.map((task) => `${task.id}:${task.status}:${task.completedAt ?? ""}`).join("|"),
|
||||
[assistantTasks],
|
||||
);
|
||||
const productAssistantTasks = useProductTasks(taskStore);
|
||||
const taskStatusRows = useTaskStatusRows(taskStore);
|
||||
const taskActivityRows = useTaskActivityRows(taskStore);
|
||||
// The supervisor page needs every task; App follows them only while it is shown.
|
||||
const heartbeatTasks = useAllTasks(taskStore, view !== "heartbeat");
|
||||
const executionStatsEnabled = assistantSidebarOpen && assistantSidebarTab === "tasks";
|
||||
const executionStatsRevision = useTaskStatsRevision(taskStore, executionStatsEnabled);
|
||||
const statsConversation = activeConversation?.projectId === activeProjectId
|
||||
? activeConversation : undefined;
|
||||
const statsMessageCount = statsConversation?.messageCount ?? 0;
|
||||
@@ -2826,7 +2827,7 @@ function App(): React.JSX.Element {
|
||||
statsConversation?.id,
|
||||
activeProjectId || undefined,
|
||||
`${executionStatsRevision}:${statsMessageCount}:${activeConversationIds.has(activeId)}`,
|
||||
assistantSidebarOpen && assistantSidebarTab === "tasks",
|
||||
executionStatsEnabled,
|
||||
);
|
||||
const taskDurations = useMemo(() => new Map(
|
||||
executionStats.project?.taskDurations.map((task) => [task.id, {
|
||||
@@ -2863,7 +2864,7 @@ function App(): React.JSX.Element {
|
||||
[projects, tWorkspace],
|
||||
);
|
||||
const { completedConversationIds, markConversationCompleted, clearConversationCompleted } =
|
||||
useUnviewedCompletions(assistantTasks,
|
||||
useUnviewedCompletions(taskStatusRows,
|
||||
view === "chat" && !settingsOpen && !applicationCenterOpen && activeConversation?.historyLoaded
|
||||
? activeId : undefined);
|
||||
const activityProjects = useMemo(
|
||||
@@ -2879,7 +2880,7 @@ function App(): React.JSX.Element {
|
||||
defaultTitle: t("conversation.defaultTitle"),
|
||||
fallbackProjectName: tWorkspace("projectActivity.unassigned"),
|
||||
projects: activityProjects,
|
||||
tasks: assistantTasks,
|
||||
tasks: taskActivityRows,
|
||||
});
|
||||
const activityByConversationId = useMemo(
|
||||
() =>
|
||||
@@ -2895,22 +2896,7 @@ function App(): React.JSX.Element {
|
||||
busyConversationIdsRef.current = new Set([...activityByConversationId.keys(), ...queuedConversationIds]);
|
||||
}, [activityByConversationId, queuedConversationIds]);
|
||||
const pendingSidebarApprovals = usePendingSidebarApprovals(conversationStore);
|
||||
const sidebarArtifacts = useMemo<SidebarArtifact[]>(
|
||||
() =>
|
||||
assistantArtifacts
|
||||
.filter(
|
||||
(artifact) =>
|
||||
!activeProjectId || artifact.projectId === activeProjectId,
|
||||
)
|
||||
.map((artifact) => ({
|
||||
id: artifact.id,
|
||||
title: artifact.title,
|
||||
content: artifact.content ?? "",
|
||||
createdAt: new Date(artifact.createdAt).getTime(),
|
||||
mimeType: artifact.mimeType,
|
||||
})),
|
||||
[activeProjectId, assistantArtifacts],
|
||||
);
|
||||
const sidebarArtifacts = useSidebarArtifacts(taskStore, activeProjectId);
|
||||
// Pending supervisor suggestions; earlier report proposals are migrated into them.
|
||||
const [pendingHeartbeatSuggestionCount, setPendingHeartbeatSuggestionCount] = useState(0);
|
||||
|
||||
@@ -3253,14 +3239,14 @@ function App(): React.JSX.Element {
|
||||
const agentEventDependenciesRef = useRef<AgentEventDependencies | undefined>(undefined);
|
||||
useLayoutEffect(() => {
|
||||
agentEventDependenciesRef.current = {
|
||||
activeRuns, activeConversationIdRef, activeProjectIdRef, assistantTasksRef, hydratingArtifactIds,
|
||||
activeRuns, activeConversationIdRef, activeProjectIdRef, taskStore, hydratingArtifactIds,
|
||||
requestPersistenceFlush: conversationPersistence.requestFlushAfterCommit, tRef,
|
||||
conversationStore, liveMessages, setConversations, setAssistantTasks, setAssistantArtifacts,
|
||||
conversationStore, liveMessages, setConversations,
|
||||
setActivityRecords, setUnreadConversationIds, notify, updateMessage, recordActivity,
|
||||
updateRequestActivity, loadWorkspaceChanges, markConversationCompleted, setConversationActivity,
|
||||
releaseConversationQueueAfterRun,
|
||||
};
|
||||
}, [conversationPersistence, conversationStore, liveMessages, setConversations, updateMessage,
|
||||
}, [conversationPersistence, conversationStore, liveMessages, setConversations, taskStore, updateMessage,
|
||||
recordActivity, updateRequestActivity, loadWorkspaceChanges, markConversationCompleted,
|
||||
setConversationActivity, releaseConversationQueueAfterRun]);
|
||||
const handleAgentEvent = useCallback((event: AgentEvent): void => {
|
||||
@@ -3292,7 +3278,7 @@ function App(): React.JSX.Element {
|
||||
useLayoutEffect(() => {
|
||||
conversationPersistence.connect({
|
||||
retainedConversationIds: retainedConversationDetailIds,
|
||||
hasBusyTask: (conversationId) => assistantTasksRef.current.some(task => task.conversationId === conversationId &&
|
||||
hasBusyTask: (conversationId) => taskStore.getTasks().some(task => task.conversationId === conversationId &&
|
||||
(task.status === "running" || task.status === "waiting_approval")),
|
||||
pinState: () => ({ revision: conversationPinRevisionRef.current, pending: conversationPinPendingRef.current }),
|
||||
historyUnavailableError: () => new Error(tRef.current("notices.remoteConversationRefreshFailed")),
|
||||
@@ -3302,7 +3288,7 @@ function App(): React.JSX.Element {
|
||||
dedupeKey: "conversation-persistence",
|
||||
}),
|
||||
});
|
||||
}, [conversationPersistence, retainedConversationDetailIds]);
|
||||
}, [conversationPersistence, retainedConversationDetailIds, taskStore]);
|
||||
// Saves, history loading and the save queue live in conversation-persistence.ts.
|
||||
const persistLocalConversationChanges = conversationPersistence.persist;
|
||||
const ensureConversationHistory = conversationPersistence.ensureHistory;
|
||||
@@ -3411,6 +3397,7 @@ function App(): React.JSX.Element {
|
||||
conversationStoreReady,
|
||||
releaseConversationQueueAfterRun,
|
||||
retainedConversationDetailIds,
|
||||
setAssistantTasks,
|
||||
setConversationActivity,
|
||||
markConversationCompleted,
|
||||
]);
|
||||
@@ -4061,12 +4048,8 @@ function App(): React.JSX.Element {
|
||||
}, [loadHeartbeats, projects.length]);
|
||||
|
||||
const refreshHeartbeatCenter = useCallback(async (): Promise<void> => {
|
||||
const [artifacts] = await Promise.all([
|
||||
window.goodbuddy.artifacts.list(),
|
||||
refreshHeartbeats(),
|
||||
]);
|
||||
setAssistantArtifacts((current) => mergeArtifacts(current, artifacts));
|
||||
}, [refreshHeartbeats]);
|
||||
await Promise.all([refreshArtifacts(taskStore), refreshHeartbeats()]);
|
||||
}, [refreshHeartbeats, taskStore]);
|
||||
|
||||
const retryHeartbeatLoad = useCallback(async (): Promise<void> => {
|
||||
setHeartbeatLoading(true);
|
||||
@@ -4177,7 +4160,7 @@ function App(): React.JSX.Element {
|
||||
message: tRef.current("notices.taskHistoryReadFailed"),
|
||||
}),
|
||||
);
|
||||
}, []);
|
||||
}, [setAssistantTasks]);
|
||||
|
||||
useEffect(() => {
|
||||
if (view !== "activity") {
|
||||
@@ -4214,52 +4197,14 @@ function App(): React.JSX.Element {
|
||||
}
|
||||
}, [notify, refreshActivityPanelRecords, refreshTokenUsage]);
|
||||
|
||||
useEffect(() => {
|
||||
void window.goodbuddy.artifacts
|
||||
.list()
|
||||
.then((artifacts) =>
|
||||
setAssistantArtifacts((current) => mergeArtifacts(current, artifacts)),
|
||||
)
|
||||
.catch(() =>
|
||||
notify({
|
||||
tone: "error",
|
||||
message: tRef.current("notices.resultHistoryReadFailed"),
|
||||
}),
|
||||
);
|
||||
}, []);
|
||||
useInitialArtifactSync(taskStore, () =>
|
||||
notify({
|
||||
tone: "error",
|
||||
message: tRef.current("notices.resultHistoryReadFailed"),
|
||||
}),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const missingIds = [
|
||||
...(activeConversation?.artifactIds ?? []),
|
||||
]
|
||||
.filter(
|
||||
(artifactId) =>
|
||||
!assistantArtifactById.get(artifactId)?.content &&
|
||||
!hydratingArtifactIds.current.has(artifactId),
|
||||
)
|
||||
.slice(-32);
|
||||
if (missingIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
for (const artifactId of missingIds) {
|
||||
hydratingArtifactIds.current.add(artifactId);
|
||||
}
|
||||
void Promise.allSettled(
|
||||
missingIds.map((artifactId) =>
|
||||
window.goodbuddy.artifacts.get(artifactId),
|
||||
),
|
||||
).then((results) => {
|
||||
const artifacts = results.flatMap((result) =>
|
||||
result.status === "fulfilled" ? [result.value] : [],
|
||||
);
|
||||
if (artifacts.length > 0) {
|
||||
setAssistantArtifacts((current) => mergeArtifacts(current, artifacts));
|
||||
}
|
||||
for (const artifactId of missingIds) {
|
||||
hydratingArtifactIds.current.delete(artifactId);
|
||||
}
|
||||
});
|
||||
}, [activeConversation?.artifactIds, assistantArtifactById]);
|
||||
useArtifactHydration(taskStore, activeConversation?.artifactIds, hydratingArtifactIds);
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => {
|
||||
@@ -5799,7 +5744,7 @@ function App(): React.JSX.Element {
|
||||
}));
|
||||
}
|
||||
},
|
||||
[updateMessage, conversationStore],
|
||||
[updateMessage, conversationStore, setAssistantTasks],
|
||||
);
|
||||
|
||||
const respondToQuestion = useCallback(
|
||||
@@ -5851,7 +5796,7 @@ function App(): React.JSX.Element {
|
||||
};
|
||||
});
|
||||
},
|
||||
[updateMessage, conversationStore],
|
||||
[updateMessage, conversationStore, setAssistantTasks],
|
||||
);
|
||||
|
||||
const addContext = async (
|
||||
@@ -6437,12 +6382,12 @@ function App(): React.JSX.Element {
|
||||
|
||||
const runAssistantSchedule = useCallback(async (scheduleId: string): Promise<void> => {
|
||||
await window.goodbuddy.schedules.runNow(scheduleId);
|
||||
setAssistantTasks(await window.goodbuddy.tasks.list());
|
||||
await refreshTasks(taskStore);
|
||||
notify({
|
||||
tone: "success",
|
||||
message: t("notices.scheduleStarted"),
|
||||
});
|
||||
}, [notify, t]);
|
||||
}, [notify, t, taskStore]);
|
||||
|
||||
const setAssistantScheduleEnabled = useCallback(async (
|
||||
scheduleId: string,
|
||||
@@ -6642,13 +6587,8 @@ function App(): React.JSX.Element {
|
||||
conversationQueueItems.filter((item) => item.conversationId === activeId),
|
||||
[activeId, conversationQueueItems],
|
||||
);
|
||||
const conversationExecutionRunning =
|
||||
isRunning ||
|
||||
assistantTasks.some(
|
||||
(task) =>
|
||||
task.conversationId === activeId &&
|
||||
(task.status === "running" || task.status === "waiting_approval"),
|
||||
);
|
||||
const activeConversationHasBusyTask = useConversationHasBusyTask(taskStore, activeId);
|
||||
const conversationExecutionRunning = isRunning || activeConversationHasBusyTask;
|
||||
const handleConversationQueueError = useCallback(
|
||||
(message: string): void => {
|
||||
notify({
|
||||
@@ -6892,15 +6832,7 @@ function App(): React.JSX.Element {
|
||||
setAssistantSidebarTab("results");
|
||||
}
|
||||
},
|
||||
onLoadArtifact: async (artifactId) => {
|
||||
if (assistantArtifactById.get(artifactId)?.content) {
|
||||
return;
|
||||
}
|
||||
const artifact = await window.goodbuddy.artifacts.get(artifactId);
|
||||
setAssistantArtifacts((current) =>
|
||||
mergeArtifacts(current, [artifact]),
|
||||
);
|
||||
},
|
||||
onLoadArtifact: (artifactId) => loadArtifact(taskStore, artifactId),
|
||||
onOpenTask: openAssistantTask,
|
||||
onRespondApproval: (approval, decision) => {
|
||||
void respondToApproval(
|
||||
@@ -7399,7 +7331,7 @@ function App(): React.JSX.Element {
|
||||
{conversationPaneOrder.map((conversationId) => (
|
||||
<ConversationHistorySlot
|
||||
active={view === "chat" && conversationId === activeId}
|
||||
artifactById={assistantArtifactById}
|
||||
taskStore={taskStore}
|
||||
conversationHtmlRenderingEnabled={
|
||||
conversationHtmlRenderingEnabled
|
||||
}
|
||||
@@ -7671,7 +7603,7 @@ function App(): React.JSX.Element {
|
||||
onUseFollowUpTask={heartbeatActions.onUseFollowUpTask}
|
||||
projects={projects}
|
||||
runs={heartbeatRuns}
|
||||
tasks={assistantTasks}
|
||||
tasks={heartbeatTasks}
|
||||
/> : <p role="status">{t('applications.disabledPage')}</p>}
|
||||
</Suspense>
|
||||
</RouteErrorBoundary>
|
||||
|
||||
@@ -13,6 +13,8 @@ import { useConversation, type ConversationStore } from "./conversation-store";
|
||||
import { ConversationTaskStrip } from "./ConversationTaskStrip";
|
||||
import { displayErrorMessage } from "./error-message";
|
||||
import { RuntimeChecklistStrip } from "./RuntimeChecklistStrip";
|
||||
import { useArtifactById } from "./task-selectors";
|
||||
import type { TaskStore } from "./task-store";
|
||||
|
||||
function ConversationHistoryLoader({ conversationId, active, load }: {
|
||||
conversationId: string;
|
||||
@@ -38,11 +40,13 @@ function ConversationHistoryLoader({ conversationId, active, load }: {
|
||||
</section>;
|
||||
}
|
||||
|
||||
type PaneProps = Omit<ComponentProps<typeof ChatHistoryPane>, "conversation" | "taskStrip">;
|
||||
type PaneProps = Omit<ComponentProps<typeof ChatHistoryPane>, "conversation" | "taskStrip" | "artifactById">;
|
||||
|
||||
export type ConversationHistorySlotProps = PaneProps & {
|
||||
conversationId: string;
|
||||
store: ConversationStore;
|
||||
/** Artifacts are read here, so a new result re-renders the panes, not App. */
|
||||
taskStore: TaskStore;
|
||||
loadHistory: (conversationId: string) => Promise<Conversation>;
|
||||
/** The active conversation's effective mode; others use their own or the project's. */
|
||||
workModeOverride?: InteractiveWorkMode;
|
||||
@@ -65,6 +69,7 @@ export type ConversationHistorySlotProps = PaneProps & {
|
||||
export const ConversationHistorySlot = memo(function ConversationHistorySlot({
|
||||
conversationId,
|
||||
store,
|
||||
taskStore,
|
||||
loadHistory,
|
||||
workModeOverride,
|
||||
projects,
|
||||
@@ -78,6 +83,7 @@ export const ConversationHistorySlot = memo(function ConversationHistorySlot({
|
||||
...paneProps
|
||||
}: ConversationHistorySlotProps): React.JSX.Element | null {
|
||||
const conversation = useConversation(store, conversationId);
|
||||
const artifactById = useArtifactById(taskStore);
|
||||
const remote = Boolean(conversation?.remote);
|
||||
const conversationMode = workModeOverride ?? normalizeInteractiveWorkMode(
|
||||
conversation?.workMode ??
|
||||
@@ -117,5 +123,5 @@ export const ConversationHistorySlot = memo(function ConversationHistorySlot({
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <ChatHistoryPane {...paneProps} conversation={conversation} taskStrip={taskStrip} />;
|
||||
return <ChatHistoryPane {...paneProps} artifactById={artifactById} conversation={conversation} taskStrip={taskStrip} />;
|
||||
});
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { Dispatch, SetStateAction } from "react";
|
||||
import type { TFunction } from "i18next";
|
||||
import type { AgentEvent } from "../../shared/contracts";
|
||||
import type {
|
||||
AssistantArtifact,
|
||||
AssistantTask,
|
||||
ConversationContextCompressionMarker,
|
||||
ConversationMessageBlock,
|
||||
@@ -13,6 +12,7 @@ import type { ActivityRecord } from "./activity-store";
|
||||
import type { Message, SubagentActivity, ToolActivity } from "./ChatTimeline";
|
||||
import type { ConversationStore } from "./conversation-store";
|
||||
import type { LiveMessageStore } from "./live-message-store";
|
||||
import type { TaskStore } from "./task-store";
|
||||
import type { AppNotificationInput } from "./notifications";
|
||||
|
||||
/**
|
||||
@@ -39,7 +39,6 @@ export type AgentEventDependencies = {
|
||||
activeRuns: Ref<Map<string, ActiveRun>>;
|
||||
activeConversationIdRef: Ref<string>;
|
||||
activeProjectIdRef: Ref<string>;
|
||||
assistantTasksRef: Ref<AssistantTask[]>;
|
||||
hydratingArtifactIds: Ref<Set<string>>;
|
||||
/** Set when a run ends: persist with the next list commit. */
|
||||
requestPersistenceFlush: () => void;
|
||||
@@ -47,8 +46,8 @@ export type AgentEventDependencies = {
|
||||
conversationStore: ConversationStore;
|
||||
liveMessages: LiveMessageStore;
|
||||
setConversations: ConversationStore["set"];
|
||||
setAssistantTasks: Dispatch<SetStateAction<AssistantTask[]>>;
|
||||
setAssistantArtifacts: Dispatch<SetStateAction<AssistantArtifact[]>>;
|
||||
/** Tasks and artifacts; updates re-render only the views that select them. */
|
||||
taskStore: TaskStore;
|
||||
setActivityRecords: Dispatch<SetStateAction<ActivityRecord[]>>;
|
||||
setUnreadConversationIds: Dispatch<SetStateAction<Set<string>>>;
|
||||
notify: (notification: AppNotificationInput) => void;
|
||||
@@ -61,23 +60,7 @@ export type AgentEventDependencies = {
|
||||
releaseConversationQueueAfterRun: (run: Pick<ActiveRun, "conversationId" | "projectId">) => void;
|
||||
};
|
||||
|
||||
export function mergeArtifacts(
|
||||
current: AssistantArtifact[],
|
||||
incoming: AssistantArtifact[],
|
||||
): AssistantArtifact[] {
|
||||
const merged = new Map(current.map((artifact) => [artifact.id, artifact]));
|
||||
for (const artifact of incoming) {
|
||||
const existing = merged.get(artifact.id);
|
||||
merged.set(artifact.id, {
|
||||
...existing,
|
||||
...artifact,
|
||||
content: artifact.content ?? existing?.content,
|
||||
});
|
||||
}
|
||||
return [...merged.values()].sort((left, right) =>
|
||||
right.createdAt.localeCompare(left.createdAt),
|
||||
);
|
||||
}
|
||||
export { mergeArtifacts } from "./task-store";
|
||||
|
||||
function upsertMessageToolBlock(
|
||||
blocks: ConversationMessageBlock[] | undefined,
|
||||
@@ -184,12 +167,13 @@ function isErrorRepresentedByFailedTool(
|
||||
|
||||
export function handleAgentEvent(event: AgentEvent, deps: AgentEventDependencies): void {
|
||||
const {
|
||||
activeRuns, activeConversationIdRef, activeProjectIdRef, assistantTasksRef, hydratingArtifactIds,
|
||||
activeRuns, activeConversationIdRef, activeProjectIdRef, taskStore, hydratingArtifactIds,
|
||||
requestPersistenceFlush, tRef, conversationStore, liveMessages, setConversations,
|
||||
setAssistantTasks, setAssistantArtifacts, setActivityRecords, setUnreadConversationIds, notify,
|
||||
setActivityRecords, setUnreadConversationIds, notify,
|
||||
updateMessage, recordActivity, updateRequestActivity, loadWorkspaceChanges, markConversationCompleted,
|
||||
setConversationActivity, releaseConversationQueueAfterRun,
|
||||
} = deps;
|
||||
const setAssistantTasks = taskStore.setTasks;
|
||||
const run = activeRuns.current.get(event.requestId);
|
||||
if (!run) {
|
||||
if (event.type !== "approval") {
|
||||
@@ -255,7 +239,7 @@ export function handleAgentEvent(event: AgentEvent, deps: AgentEventDependencies
|
||||
}),
|
||||
);
|
||||
};
|
||||
const task = assistantTasksRef.current.find(
|
||||
const task = taskStore.getTasks().find(
|
||||
(candidate) => candidate.id === event.requestId,
|
||||
);
|
||||
if (task) {
|
||||
@@ -265,7 +249,6 @@ export function handleAgentEvent(event: AgentEvent, deps: AgentEventDependencies
|
||||
.list()
|
||||
.then((tasks) => {
|
||||
setAssistantTasks(tasks);
|
||||
assistantTasksRef.current = tasks;
|
||||
attachScheduledApproval(
|
||||
tasks.find((candidate) => candidate.id === event.requestId),
|
||||
);
|
||||
@@ -345,11 +328,7 @@ export function handleAgentEvent(event: AgentEvent, deps: AgentEventDependencies
|
||||
}
|
||||
void window.goodbuddy.artifacts
|
||||
.list()
|
||||
.then((artifacts) =>
|
||||
setAssistantArtifacts((current) =>
|
||||
mergeArtifacts(current, artifacts),
|
||||
),
|
||||
)
|
||||
.then((artifacts) => taskStore.mergeArtifacts(artifacts))
|
||||
.catch(() =>
|
||||
notify({
|
||||
tone: "error",
|
||||
@@ -360,11 +339,7 @@ export function handleAgentEvent(event: AgentEvent, deps: AgentEventDependencies
|
||||
hydratingArtifactIds.current.add(event.artifactId);
|
||||
void window.goodbuddy.artifacts
|
||||
.get(event.artifactId)
|
||||
.then((artifact) =>
|
||||
setAssistantArtifacts((current) =>
|
||||
mergeArtifacts(current, [artifact]),
|
||||
),
|
||||
)
|
||||
.then((artifact) => taskStore.mergeArtifacts([artifact]))
|
||||
.catch(() =>
|
||||
notify({
|
||||
tone: "error",
|
||||
|
||||
@@ -101,7 +101,7 @@ export function useConversationActivitySummary(
|
||||
defaultTitle: string;
|
||||
fallbackProjectName: string;
|
||||
projects: readonly { id: string; name: string }[];
|
||||
tasks: readonly AssistantTask[];
|
||||
tasks: readonly (Pick<AssistantTask, "conversationId" | "projectId" | "title"> & { status: string })[];
|
||||
},
|
||||
): ConversationActivitySummary {
|
||||
const selector = useCallback(
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { useCallback, useRef, useSyncExternalStore } from "react";
|
||||
import type { AssistantArtifact, AssistantTask } from "../../shared/assistant-contracts";
|
||||
import type { SidebarArtifact } from "./RightAssistantSidebar";
|
||||
import type { TaskStore } from "./task-store";
|
||||
|
||||
/**
|
||||
* Narrow views of the task store. Each returns a value that keeps its
|
||||
* identity while what it selects is equal, so a subagent progress event or a
|
||||
* status change of an unrelated task does not re-render the component.
|
||||
*/
|
||||
function useSelection<S, T>(
|
||||
subscribe: (listener: () => void) => () => void,
|
||||
getSource: () => S,
|
||||
select: (source: S) => T,
|
||||
isEqual: (left: T, right: T) => boolean,
|
||||
): T {
|
||||
const cache = useRef<{ source: S; select: (source: S) => T; value: T } | undefined>(undefined);
|
||||
const getSnapshot = useCallback((): T => {
|
||||
const source = getSource();
|
||||
const cached = cache.current;
|
||||
if (cached && cached.source === source && cached.select === select) return cached.value;
|
||||
const next = select(source);
|
||||
const value = cached && isEqual(cached.value, next) ? cached.value : next;
|
||||
cache.current = { source, select, value };
|
||||
return value;
|
||||
}, [getSource, isEqual, select]);
|
||||
return useSyncExternalStore(subscribe, getSnapshot);
|
||||
}
|
||||
|
||||
function sameItems<T>(left: readonly T[], right: readonly T[]): boolean {
|
||||
return left.length === right.length && left.every((item, index) => item === right[index]);
|
||||
}
|
||||
|
||||
function sameRows<T extends object>(keys: readonly (keyof T)[]) {
|
||||
return (left: readonly T[], right: readonly T[]): boolean =>
|
||||
left.length === right.length &&
|
||||
left.every((item, index) => keys.every((key) => item[key] === right[index]![key]));
|
||||
}
|
||||
|
||||
export function useTaskSelector<T>(
|
||||
store: TaskStore,
|
||||
select: (tasks: AssistantTask[]) => T,
|
||||
isEqual: (left: T, right: T) => boolean = Object.is,
|
||||
): T {
|
||||
return useSelection(store.subscribeTasks, store.getTasks, select, isEqual);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every task. With `paused` it keeps returning the list it had, for views
|
||||
* kept alive while hidden; they catch up when shown again.
|
||||
*/
|
||||
export function useAllTasks(store: TaskStore, paused = false): AssistantTask[] {
|
||||
const last = useRef<AssistantTask[] | undefined>(undefined);
|
||||
const select = useCallback(
|
||||
(tasks: AssistantTask[]): AssistantTask[] =>
|
||||
paused && last.current ? last.current : (last.current = tasks),
|
||||
[paused],
|
||||
);
|
||||
return useTaskSelector(store, select);
|
||||
}
|
||||
|
||||
const selectProductTasks = (tasks: AssistantTask[]): AssistantTask[] =>
|
||||
tasks.filter((task) => !task.parentTaskId && task.origin === "schedule");
|
||||
|
||||
/** Top-level scheduled tasks (sidebar, conversation task strips). */
|
||||
export function useProductTasks(store: TaskStore): AssistantTask[] {
|
||||
return useTaskSelector(store, selectProductTasks, sameItems);
|
||||
}
|
||||
|
||||
export type TaskStatusRow = Pick<AssistantTask, "id" | "conversationId" | "parentTaskId" | "status">;
|
||||
// Unviewed completions ignore subagent tasks and tasks without a conversation.
|
||||
const selectStatusRows = (tasks: AssistantTask[]): TaskStatusRow[] =>
|
||||
tasks.flatMap(({ id, conversationId, parentTaskId, status }) =>
|
||||
conversationId && !parentTaskId ? [{ id, conversationId, parentTaskId, status }] : []);
|
||||
|
||||
/** Top-level conversation tasks and their statuses; subagent progress leaves it unchanged. */
|
||||
export function useTaskStatusRows(store: TaskStore): TaskStatusRow[] {
|
||||
return useTaskSelector(
|
||||
store,
|
||||
selectStatusRows,
|
||||
sameRows<TaskStatusRow>(["id", "conversationId", "parentTaskId", "status"]),
|
||||
);
|
||||
}
|
||||
|
||||
export type TaskActivityRow = {
|
||||
conversationId: string;
|
||||
projectId?: string;
|
||||
title: string;
|
||||
/** The conversation activity summary only tells these apart. */
|
||||
status: "running" | "waiting_approval" | "other";
|
||||
};
|
||||
const selectActivityRows = (tasks: AssistantTask[]): TaskActivityRow[] =>
|
||||
tasks.flatMap(({ conversationId, projectId, title, status }) =>
|
||||
conversationId
|
||||
? [{
|
||||
conversationId,
|
||||
projectId,
|
||||
title,
|
||||
status: status === "running" || status === "waiting_approval" ? status : "other",
|
||||
}]
|
||||
: []);
|
||||
|
||||
/** What the conversation activity summary reads from tasks. */
|
||||
export function useTaskActivityRows(store: TaskStore): TaskActivityRow[] {
|
||||
return useTaskSelector(
|
||||
store,
|
||||
selectActivityRows,
|
||||
sameRows<TaskActivityRow>(["conversationId", "projectId", "title", "status"]),
|
||||
);
|
||||
}
|
||||
|
||||
const selectStatsRevision = (tasks: AssistantTask[]): string =>
|
||||
tasks.map((task) => `${task.id}:${task.status}:${task.completedAt ?? ""}`).join("|");
|
||||
const selectNoRevision = (): string => "";
|
||||
|
||||
/**
|
||||
* Changes whenever a task starts, finishes or changes status; constant while
|
||||
* `enabled` is false, so nothing re-renders for statistics nobody sees.
|
||||
*/
|
||||
export function useTaskStatsRevision(store: TaskStore, enabled: boolean): string {
|
||||
return useTaskSelector(store, enabled ? selectStatsRevision : selectNoRevision);
|
||||
}
|
||||
/** Whether a task of this conversation is running or waiting for approval. */
|
||||
export function useConversationHasBusyTask(store: TaskStore, conversationId: string): boolean {
|
||||
const select = useCallback(
|
||||
(tasks: AssistantTask[]): boolean =>
|
||||
tasks.some((task) =>
|
||||
task.conversationId === conversationId &&
|
||||
(task.status === "running" || task.status === "waiting_approval")),
|
||||
[conversationId],
|
||||
);
|
||||
return useTaskSelector(store, select);
|
||||
}
|
||||
|
||||
/** The artifacts by ID; the same map while artifacts are unchanged. */
|
||||
export function useArtifactById(store: TaskStore): ReadonlyMap<string, AssistantArtifact> {
|
||||
return useSyncExternalStore(store.subscribeArtifacts, store.getArtifactById);
|
||||
}
|
||||
|
||||
const sameSidebarArtifacts = sameRows<SidebarArtifact>(["id", "title", "content", "createdAt", "mimeType"]);
|
||||
|
||||
/** The results tab entries of a project (all projects without one). */
|
||||
export function useSidebarArtifacts(store: TaskStore, projectId: string): SidebarArtifact[] {
|
||||
const select = useCallback(
|
||||
(artifacts: AssistantArtifact[]): SidebarArtifact[] =>
|
||||
artifacts
|
||||
.filter((artifact) => !projectId || artifact.projectId === projectId)
|
||||
.map((artifact) => ({
|
||||
id: artifact.id,
|
||||
title: artifact.title,
|
||||
content: artifact.content ?? "",
|
||||
createdAt: new Date(artifact.createdAt).getTime(),
|
||||
mimeType: artifact.mimeType,
|
||||
})),
|
||||
[projectId],
|
||||
);
|
||||
return useSelection(store.subscribeArtifacts, store.getArtifacts, select, sameSidebarArtifacts);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { AssistantArtifact, AssistantTask } from "../../shared/assistant-contracts";
|
||||
import {
|
||||
useArtifactById,
|
||||
useConversationHasBusyTask,
|
||||
useProductTasks,
|
||||
useSidebarArtifacts,
|
||||
useTaskActivityRows,
|
||||
useTaskStatsRevision,
|
||||
useTaskStatusRows,
|
||||
} from "./task-selectors";
|
||||
import { createTaskStore, mergeArtifacts } from "./task-store";
|
||||
|
||||
function task(overrides: Partial<AssistantTask> = {}): AssistantTask {
|
||||
return {
|
||||
id: "t1",
|
||||
conversationId: "c1",
|
||||
title: "Task",
|
||||
instructions: "",
|
||||
origin: "user",
|
||||
status: "running",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
...overrides,
|
||||
} as AssistantTask;
|
||||
}
|
||||
|
||||
function artifact(overrides: Partial<AssistantArtifact> = {}): AssistantArtifact {
|
||||
return {
|
||||
id: "a1",
|
||||
projectId: "p1",
|
||||
kind: "markdown",
|
||||
title: "Result",
|
||||
mimeType: "text/markdown",
|
||||
byteSize: 1,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("task store", () => {
|
||||
it("applies task updates synchronously and in order, notifying only on change", () => {
|
||||
const store = createTaskStore();
|
||||
const listener = vi.fn();
|
||||
store.subscribeTasks(listener);
|
||||
store.setTasks([task()]);
|
||||
store.setTasks((current) => current.map((item) => ({ ...item, status: "completed" })));
|
||||
expect(store.getTasks()[0]!.status).toBe("completed");
|
||||
expect(listener).toHaveBeenCalledTimes(2);
|
||||
store.setTasks((current) => current);
|
||||
expect(listener).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("keeps artifact identity when a refresh returns equal artifacts", () => {
|
||||
const store = createTaskStore();
|
||||
const listener = vi.fn();
|
||||
store.subscribeArtifacts(listener);
|
||||
store.mergeArtifacts([artifact({ content: "body" })]);
|
||||
const before = store.getArtifacts();
|
||||
const map = store.getArtifactById();
|
||||
// artifacts.list() returns metadata without content; the merge keeps it.
|
||||
store.mergeArtifacts([artifact()]);
|
||||
expect(store.getArtifacts()).toBe(before);
|
||||
expect(store.getArtifactById()).toBe(map);
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
store.mergeArtifacts([artifact({ id: "a2", createdAt: "2026-02-01T00:00:00.000Z" })]);
|
||||
expect(store.getArtifacts().map((item) => item.id)).toEqual(["a2", "a1"]);
|
||||
expect(store.getArtifacts()[1]).toBe(before[0]);
|
||||
expect(store.getArtifactById()).not.toBe(map);
|
||||
expect(listener).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("merges artifacts newest first and keeps loaded content", () => {
|
||||
const merged = mergeArtifacts(
|
||||
[artifact({ content: "kept" })],
|
||||
[artifact({ title: "Renamed" }), artifact({ id: "a0", createdAt: "2025-01-01T00:00:00.000Z" })],
|
||||
);
|
||||
expect(merged.map((item) => [item.id, item.title, item.content])).toEqual([
|
||||
["a1", "Renamed", "kept"],
|
||||
["a0", "Result", undefined],
|
||||
]);
|
||||
});
|
||||
|
||||
it("clears tasks and artifacts", () => {
|
||||
const store = createTaskStore({ tasks: [task()], artifacts: [artifact()] });
|
||||
store.clear();
|
||||
expect(store.getTasks()).toEqual([]);
|
||||
expect(store.getArtifacts()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("task selectors", () => {
|
||||
it("do not re-render on subagent progress", () => {
|
||||
const store = createTaskStore({
|
||||
tasks: [task(), task({ id: "s1", origin: "schedule", conversationId: "c2", status: "completed" })],
|
||||
});
|
||||
const renders = vi.fn();
|
||||
const { result } = renderHook(() => {
|
||||
renders();
|
||||
return {
|
||||
product: useProductTasks(store),
|
||||
status: useTaskStatusRows(store),
|
||||
activity: useTaskActivityRows(store),
|
||||
busy: useConversationHasBusyTask(store, "c1"),
|
||||
revision: useTaskStatsRevision(store, false),
|
||||
};
|
||||
});
|
||||
const first = result.current;
|
||||
expect(first.product.map((item) => item.id)).toEqual(["s1"]);
|
||||
expect(first.busy).toBe(true);
|
||||
// A subagent of t1 starts, then reports progress without a status change.
|
||||
act(() => store.setTasks((current) => [...current, task({ id: "child", parentTaskId: "t1", status: "running" })]));
|
||||
renders.mockClear();
|
||||
act(() => store.setTasks((current) => current.map((item) =>
|
||||
item.id === "child" ? { ...item, startedAt: "2026-01-01T00:00:01.000Z" } : item)));
|
||||
expect(renders).not.toHaveBeenCalled();
|
||||
expect(result.current.product).toBe(first.product);
|
||||
expect(result.current.status).toBe(first.status);
|
||||
});
|
||||
|
||||
it("re-render when a conversation task finishes", () => {
|
||||
const store = createTaskStore({ tasks: [task()] });
|
||||
const { result } = renderHook(() => ({
|
||||
busy: useConversationHasBusyTask(store, "c1"),
|
||||
status: useTaskStatusRows(store),
|
||||
revision: useTaskStatsRevision(store, true),
|
||||
}));
|
||||
const revision = result.current.revision;
|
||||
act(() => store.setTasks((current) => current.map((item) => ({ ...item, status: "completed" }))));
|
||||
expect(result.current.busy).toBe(false);
|
||||
expect(result.current.status[0]!.status).toBe("completed");
|
||||
expect(result.current.revision).not.toBe(revision);
|
||||
});
|
||||
|
||||
it("keep the artifact map and sidebar entries while artifacts are unchanged", () => {
|
||||
const store = createTaskStore({ artifacts: [artifact({ content: "x" }), artifact({ id: "b", projectId: "p2" })] });
|
||||
const { result } = renderHook(() => ({
|
||||
byId: useArtifactById(store),
|
||||
sidebar: useSidebarArtifacts(store, "p1"),
|
||||
}));
|
||||
const first = result.current;
|
||||
expect(first.sidebar.map((item) => item.id)).toEqual(["a1"]);
|
||||
act(() => store.mergeArtifacts([artifact()]));
|
||||
expect(result.current.byId).toBe(first.byId);
|
||||
act(() => store.mergeArtifacts([artifact({ id: "b", projectId: "p2", title: "Other project" })]));
|
||||
expect(result.current.byId).not.toBe(first.byId);
|
||||
expect(result.current.sidebar).toBe(first.sidebar);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import type { SetStateAction } from "react";
|
||||
import type { AssistantArtifact, AssistantTask } from "../../shared/assistant-contracts";
|
||||
|
||||
/**
|
||||
* Assistant tasks and result artifacts, kept outside App state.
|
||||
*
|
||||
* Agent events update task status and artifacts many times per run. As App
|
||||
* state every update re-rendered the whole window; here components select
|
||||
* the part they show (see task-selectors.ts) and re-render only when it
|
||||
* changes. `getTasks()` / `getArtifacts()` are always the latest values,
|
||||
* synchronously after an update, replacing the former `assistantTasksRef`.
|
||||
*
|
||||
* Artifact updates keep the previous objects (and the previous array) when
|
||||
* the merged result is equal, so refreshing the list after every run does
|
||||
* not invalidate the artifact map the chat timelines render from.
|
||||
*/
|
||||
type Listener = () => void;
|
||||
|
||||
export type TaskStore = ReturnType<typeof createTaskStore>;
|
||||
|
||||
export function mergeArtifacts(
|
||||
current: readonly AssistantArtifact[],
|
||||
incoming: readonly AssistantArtifact[],
|
||||
): AssistantArtifact[] {
|
||||
const merged = new Map(current.map((artifact) => [artifact.id, artifact]));
|
||||
for (const artifact of incoming) {
|
||||
const existing = merged.get(artifact.id);
|
||||
merged.set(artifact.id, {
|
||||
...existing,
|
||||
...artifact,
|
||||
content: artifact.content ?? existing?.content,
|
||||
});
|
||||
}
|
||||
return [...merged.values()].sort((left, right) =>
|
||||
right.createdAt.localeCompare(left.createdAt),
|
||||
);
|
||||
}
|
||||
|
||||
const artifactKeys: readonly (keyof AssistantArtifact)[] = [
|
||||
"id", "projectId", "taskId", "kind", "title", "mimeType", "content", "byteSize", "createdAt", "updatedAt",
|
||||
];
|
||||
|
||||
function sameArtifact(left: AssistantArtifact, right: AssistantArtifact): boolean {
|
||||
return artifactKeys.every((key) => left[key] === right[key]);
|
||||
}
|
||||
|
||||
/** `next`, reusing equal objects from `previous`; `previous` itself when nothing changed. */
|
||||
function reuseArtifacts(
|
||||
previous: AssistantArtifact[],
|
||||
next: AssistantArtifact[],
|
||||
): AssistantArtifact[] {
|
||||
if (next === previous) return previous;
|
||||
const byId = new Map(previous.map((artifact) => [artifact.id, artifact]));
|
||||
let changed = next.length !== previous.length;
|
||||
const result = next.map((artifact, index) => {
|
||||
const existing = byId.get(artifact.id);
|
||||
const reused = existing && sameArtifact(existing, artifact) ? existing : artifact;
|
||||
if (reused !== previous[index]) changed = true;
|
||||
return reused;
|
||||
});
|
||||
return changed ? result : previous;
|
||||
}
|
||||
|
||||
export function createTaskStore(
|
||||
initial: { tasks?: AssistantTask[]; artifacts?: AssistantArtifact[] } = {},
|
||||
) {
|
||||
let tasks = initial.tasks ?? [];
|
||||
let artifacts = initial.artifacts ?? [];
|
||||
let artifactById: ReadonlyMap<string, AssistantArtifact> | undefined;
|
||||
const taskListeners = new Set<Listener>();
|
||||
const artifactListeners = new Set<Listener>();
|
||||
const notify = (listeners: Set<Listener>): void => {
|
||||
for (const listener of [...listeners]) listener();
|
||||
};
|
||||
const subscribe = (listeners: Set<Listener>) => (listener: Listener): (() => void) => {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
};
|
||||
|
||||
const store = {
|
||||
getTasks(): AssistantTask[] {
|
||||
return tasks;
|
||||
},
|
||||
setTasks(update: SetStateAction<AssistantTask[]>): void {
|
||||
const next = typeof update === "function" ? update(tasks) : update;
|
||||
if (next === tasks) return;
|
||||
tasks = next;
|
||||
notify(taskListeners);
|
||||
},
|
||||
subscribeTasks: subscribe(taskListeners),
|
||||
|
||||
getArtifacts(): AssistantArtifact[] {
|
||||
return artifacts;
|
||||
},
|
||||
/** The artifacts by ID; the same map while the artifacts are unchanged. */
|
||||
getArtifactById(): ReadonlyMap<string, AssistantArtifact> {
|
||||
artifactById ??= new Map(artifacts.map((artifact) => [artifact.id, artifact]));
|
||||
return artifactById;
|
||||
},
|
||||
setArtifacts(update: SetStateAction<AssistantArtifact[]>): void {
|
||||
const next = reuseArtifacts(
|
||||
artifacts,
|
||||
typeof update === "function" ? update(artifacts) : update,
|
||||
);
|
||||
if (next === artifacts) return;
|
||||
artifacts = next;
|
||||
artifactById = undefined;
|
||||
notify(artifactListeners);
|
||||
},
|
||||
mergeArtifacts(incoming: readonly AssistantArtifact[]): void {
|
||||
store.setArtifacts((current) => mergeArtifacts(current, incoming));
|
||||
},
|
||||
subscribeArtifacts: subscribe(artifactListeners),
|
||||
|
||||
clear(): void {
|
||||
store.setTasks([]);
|
||||
store.setArtifacts([]);
|
||||
},
|
||||
};
|
||||
return store;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useEffect } from "react";
|
||||
import type { AssistantTask } from "../../shared/assistant-contracts";
|
||||
import { useArtifactById } from "./task-selectors";
|
||||
import type { TaskStore } from "./task-store";
|
||||
|
||||
/**
|
||||
* Task and artifact IPC with Main: the first load, refreshes after runs and
|
||||
* on-demand loading of artifact content. Moved out of App so the store, not
|
||||
* App state, receives the results.
|
||||
*/
|
||||
|
||||
/** Refreshes the artifact list; unchanged artifacts keep their identity. */
|
||||
export async function refreshArtifacts(store: TaskStore): Promise<void> {
|
||||
store.mergeArtifacts(await window.goodbuddy.artifacts.list());
|
||||
}
|
||||
|
||||
/** Loads one artifact with its content. */
|
||||
export async function loadArtifact(store: TaskStore, artifactId: string): Promise<void> {
|
||||
if (store.getArtifactById().get(artifactId)?.content) return;
|
||||
store.mergeArtifacts([await window.goodbuddy.artifacts.get(artifactId)]);
|
||||
}
|
||||
|
||||
/** Reloads the task list from Main. */
|
||||
export async function refreshTasks(store: TaskStore): Promise<AssistantTask[]> {
|
||||
const tasks = await window.goodbuddy.tasks.list();
|
||||
store.setTasks(tasks);
|
||||
return tasks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the content of the most recent artifacts a conversation references
|
||||
* (at most 32), skipping ones already loaded or loading.
|
||||
*/
|
||||
export function useArtifactHydration(
|
||||
store: TaskStore,
|
||||
artifactIds: readonly string[] | undefined,
|
||||
hydrating: { current: Set<string> },
|
||||
): void {
|
||||
const artifactById = useArtifactById(store);
|
||||
useEffect(() => {
|
||||
const missingIds = [...(artifactIds ?? [])]
|
||||
.filter((artifactId) =>
|
||||
!artifactById.get(artifactId)?.content && !hydrating.current.has(artifactId))
|
||||
.slice(-32);
|
||||
if (missingIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
for (const artifactId of missingIds) {
|
||||
hydrating.current.add(artifactId);
|
||||
}
|
||||
void Promise.allSettled(
|
||||
missingIds.map((artifactId) => window.goodbuddy.artifacts.get(artifactId)),
|
||||
).then((results) => {
|
||||
const artifacts = results.flatMap((result) =>
|
||||
result.status === "fulfilled" ? [result.value] : []);
|
||||
if (artifacts.length > 0) {
|
||||
store.mergeArtifacts(artifacts);
|
||||
}
|
||||
for (const artifactId of missingIds) {
|
||||
hydrating.current.delete(artifactId);
|
||||
}
|
||||
});
|
||||
}, [artifactIds, artifactById, hydrating, store]);
|
||||
}
|
||||
|
||||
/** Loads the artifact list once on mount. */
|
||||
export function useInitialArtifactSync(store: TaskStore, onFailed: () => void): void {
|
||||
useEffect(() => {
|
||||
void refreshArtifacts(store).catch(onFailed);
|
||||
// Runs once, like the former App effect; a later callback is irrelevant.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [store]);
|
||||
}
|
||||
Reference in New Issue
Block a user