diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index 9adce5ae..0f3dc5fd 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -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(); diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 227f4fc1..77fd3189 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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 >({}); const retryingRecoveryProjectIdsRef = useRef(new Set()); - const [assistantTasks, setAssistantTasks] = useState([]); - 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(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( [], ); @@ -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( - () => - 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(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 => { - 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 => { 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 => { 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) => ( :

{t('applications.disabledPage')}

} diff --git a/src/renderer/src/ConversationHistorySlot.tsx b/src/renderer/src/ConversationHistorySlot.tsx index 162ddae5..40f8b624 100644 --- a/src/renderer/src/ConversationHistorySlot.tsx +++ b/src/renderer/src/ConversationHistorySlot.tsx @@ -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 }: { ; } -type PaneProps = Omit, "conversation" | "taskStrip">; +type PaneProps = Omit, "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; /** 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 ; + return ; }); diff --git a/src/renderer/src/agent-event-handler.ts b/src/renderer/src/agent-event-handler.ts index 973b28ef..64a7bb77 100644 --- a/src/renderer/src/agent-event-handler.ts +++ b/src/renderer/src/agent-event-handler.ts @@ -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>; activeConversationIdRef: Ref; activeProjectIdRef: Ref; - assistantTasksRef: Ref; hydratingArtifactIds: Ref>; /** 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>; - setAssistantArtifacts: Dispatch>; + /** Tasks and artifacts; updates re-render only the views that select them. */ + taskStore: TaskStore; setActivityRecords: Dispatch>; setUnreadConversationIds: Dispatch>>; notify: (notification: AppNotificationInput) => void; @@ -61,23 +60,7 @@ export type AgentEventDependencies = { releaseConversationQueueAfterRun: (run: Pick) => 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", diff --git a/src/renderer/src/conversation-selectors.ts b/src/renderer/src/conversation-selectors.ts index d9f43166..b3d6b4fd 100644 --- a/src/renderer/src/conversation-selectors.ts +++ b/src/renderer/src/conversation-selectors.ts @@ -101,7 +101,7 @@ export function useConversationActivitySummary( defaultTitle: string; fallbackProjectName: string; projects: readonly { id: string; name: string }[]; - tasks: readonly AssistantTask[]; + tasks: readonly (Pick & { status: string })[]; }, ): ConversationActivitySummary { const selector = useCallback( diff --git a/src/renderer/src/task-selectors.ts b/src/renderer/src/task-selectors.ts new file mode 100644 index 00000000..edca0bd9 --- /dev/null +++ b/src/renderer/src/task-selectors.ts @@ -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( + 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(left: readonly T[], right: readonly T[]): boolean { + return left.length === right.length && left.every((item, index) => item === right[index]); +} + +function sameRows(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( + 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(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; +// 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(["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(["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 { + return useSyncExternalStore(store.subscribeArtifacts, store.getArtifactById); +} + +const sameSidebarArtifacts = sameRows(["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); +} diff --git a/src/renderer/src/task-store.test.ts b/src/renderer/src/task-store.test.ts new file mode 100644 index 00000000..3a43922a --- /dev/null +++ b/src/renderer/src/task-store.test.ts @@ -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 { + 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 { + 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); + }); +}); diff --git a/src/renderer/src/task-store.ts b/src/renderer/src/task-store.ts new file mode 100644 index 00000000..6777281e --- /dev/null +++ b/src/renderer/src/task-store.ts @@ -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; + +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 | undefined; + const taskListeners = new Set(); + const artifactListeners = new Set(); + const notify = (listeners: Set): void => { + for (const listener of [...listeners]) listener(); + }; + const subscribe = (listeners: Set) => (listener: Listener): (() => void) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }; + + const store = { + getTasks(): AssistantTask[] { + return tasks; + }, + setTasks(update: SetStateAction): 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 { + artifactById ??= new Map(artifacts.map((artifact) => [artifact.id, artifact])); + return artifactById; + }, + setArtifacts(update: SetStateAction): 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; +} diff --git a/src/renderer/src/task-sync.ts b/src/renderer/src/task-sync.ts new file mode 100644 index 00000000..708de58e --- /dev/null +++ b/src/renderer/src/task-sync.ts @@ -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 { + store.mergeArtifacts(await window.goodbuddy.artifacts.list()); +} + +/** Loads one artifact with its content. */ +export async function loadArtifact(store: TaskStore, artifactId: string): Promise { + 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 { + 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 }, +): 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]); +}