perf(renderer): move assistant tasks and artifacts into a task store with narrow selectors and a sync module

This commit is contained in:
mesalogo
2026-10-03 18:58:01 +08:00
parent 2e3d2e77a9
commit 651475d17a
9 changed files with 589 additions and 173 deletions
+5 -6
View File
@@ -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
View File
@@ -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>
+8 -2
View File
@@ -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} />;
});
+10 -35
View File
@@ -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",
+1 -1
View File
@@ -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(
+158
View File
@@ -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);
}
+150
View File
@@ -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);
});
});
+123
View File
@@ -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;
}
+73
View File
@@ -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]);
}