diff --git a/docs/architecture.md b/docs/architecture.md index 0ed8c46ade..071cb94496 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -104,7 +104,7 @@ Cross-platform React Native app that connects to one or more daemons. - Expo Router navigation (`/h/[serverId]/workspace/[workspaceId]`, `/h/[serverId]/agent/[agentId]`, etc.). The `workspaceId` URL segment is an opaque workspace id, not a directly meaningful filesystem path. - `HostRuntimeController` manages saved host connections, reconnection, and per-host runtime state. Direct TCP and relay connections use the ordinary client transport; desktop socket, pipe, and SSH connections cross one Electron-owned transport boundary. SSH only tunnels to an already-running daemon. -- `runtime/replica-cache` is typed storage behind the directory and timeline owners. It never observes or mutates `SessionStore`. +- `runtime/client-replica` owns typed storage behind narrow host-lifecycle, directory, and timeline capabilities. Its cache internals never escape the module or observe `SessionStore`. - `runtime/directory-sync` owns directory cache selection and network reconciliation. On demand it paints accepted rows for one host, then passes the persisted per-entity cursor through `project.list`, `fetch_workspaces`, and `fetch_agents`; the daemon returns each entity's latest projection when its sequence is newer, plus tombstones. - `workspace-labels` owns one sequenced catalog replica per connected host, the deterministic cross-host projection that surfaces spanning hosts use (the filter page, the manager), and the per-host resolution a workspace row's chips use. Two hosts may give one name different colors, so a row resolves against its own host's catalog and a merged answer would be wrong there. Catalogs never synchronize between hosts; assignment creates a missing definition only on the target host. On the daemon, catalog and assignment rewrites share a journaled commit boundary. Startup recovery completes that commit before workspace or catalog publication. - `SessionContext` wraps the daemon client for the active session diff --git a/docs/data-model.md b/docs/data-model.md index 4c86286c2a..f0d1d0b3bc 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -574,8 +574,8 @@ Right-sidebar client state splits on whether it is determined by the directory o The durable client replica uses IndexedDB on browser/Electron and expo-sqlite on native. Rows use the compound key `(serverId, kind, id)`; kinds are `agent`, `workspace`, `project`, `timeline`, and -`checkpoint`. Directory entities have individual rows. Timeline and checkpoint use the singleton id -and have at most one row per host. +`checkpoint`. Directory entities have individual rows, timelines use the agent id, and the checkpoint +uses the singleton id. The store is a typed persistence boundary. It returns values to directory and timeline owners and accepts their explicit commits; it never reads or writes UI state. Reads are scoped to the requested @@ -585,6 +585,10 @@ An invalid directory row and its affected checkpoint cursor are repaired in one later launch cannot accept a checkpoint for a partial baseline. Directory changes and their checkpoint are also applied in one transaction. +Owners submit keyed values and tombstones, not directory snapshots. The cache coalesces each key while +the value remains structured, then serializes only the surviving changed rows during the deferred +write. App backgrounding flushes immediately; the ordinary write window is one second. + The cache is capped at 32 MiB and evicts whole hosts in least-recently-written order. Budget bookkeeping may scan opaque row sizes during a deferred write, never during host registry startup or before a requested cache row can paint. The row store is not encrypted. A cached timeline can contain diff --git a/docs/timeline-sync.md b/docs/timeline-sync.md index 3f0b6e790a..2fbffdada3 100644 --- a/docs/timeline-sync.md +++ b/docs/timeline-sync.md @@ -152,7 +152,7 @@ remaining page through the existing stream reducer. It must not append full proj live prefix. Every path that sends a message to an agent — composer send, dictation accept-and-send, queued -send-now, and the automatic queue drain in `HostRuntime` — goes through +send-now, and the session-data owner's automatic queue drain — goes through `dispatchComposerAgentMessage` with a submission writer. There is no second transport for the same product action: calling `client.sendAgentMessage` directly skips the submitted row and the pending footer, and permanently drops attachments because the daemon does not echo them back. @@ -196,9 +196,9 @@ fork positions retain the canonical `turnId` boundaries. The compatibility boundary for older daemons is snapshot normalization: running/idle status becomes an anonymous active turn or idle state once, and downstream code consumes the same activity shape. The app does not combine anonymous lifecycle events, timestamps, timeline rows, and resume coverage to infer a -second running state. Disconnect and replica removal remain destructive close boundaries. Elapsed time -comes only from turn liveness, never from submission records or whichever timeline rows happen to be -mounted. +second running state. Disconnect preserves the last replicated turn until cache or network hydration +advances it; replica removal remains the destructive close boundary. Elapsed time comes only from turn +liveness, never from submission records or whichever timeline rows happen to be mounted. The daemon records one canonical submitted user row at acceptance. Its wire `messageId` is the submission's `clientMessageId`, so the row is born with its final identity and remains immutable on diff --git a/packages/app/src/agent-profiles/internal/use-agent-profile-picker.ts b/packages/app/src/agent-profiles/internal/use-agent-profile-picker.ts index c4b97ec229..7589ae6b9b 100644 --- a/packages/app/src/agent-profiles/internal/use-agent-profile-picker.ts +++ b/packages/app/src/agent-profiles/internal/use-agent-profile-picker.ts @@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next"; import { mergeCreateAgentSelectionPreferences } from "@/create-agent-preferences/preferences"; import { useFormPreferences } from "@/hooks/use-form-preferences"; import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot"; -import { useSessionStore } from "@/stores/session-store"; +import { useHostRuntimeClient } from "@/runtime/host-runtime"; import { useToast } from "@/contexts/toast-context"; import { toErrorMessage } from "@/utils/error-messages"; import { showProviderNoticeToast } from "@/utils/provider-notice-toast"; @@ -73,7 +73,7 @@ export function useAgentProfilePicker( // so every composer on a host shares one query instead of adding its own. const { entries } = useProvidersSnapshot(serverId, { cwd: null }); const { updatePreferences } = useFormPreferences(); - const client = useSessionStore((state) => state.sessions[serverId ?? ""]?.client ?? null); + const client = useHostRuntimeClient(serverId ?? ""); const toast = useToast(); const applicableProfiles = useMemo(() => { diff --git a/packages/app/src/agent-profiles/internal/use-agent-profiles.ts b/packages/app/src/agent-profiles/internal/use-agent-profiles.ts index fe5105b18c..8737028a5b 100644 --- a/packages/app/src/agent-profiles/internal/use-agent-profiles.ts +++ b/packages/app/src/agent-profiles/internal/use-agent-profiles.ts @@ -1,8 +1,7 @@ import { useCallback } from "react"; import type { AgentProfile } from "@getpaseo/protocol/messages"; import { useDaemonConfig } from "@/hooks/use-daemon-config"; -import { useSessionStore } from "@/stores/session-store"; -import { supportsAgentProfiles } from "./capabilities"; +import { useServerFeature } from "@/stores/session-store-hooks"; export interface UseAgentProfilesResult { /** `null` until the daemon config has arrived. */ @@ -15,9 +14,9 @@ export interface UseAgentProfilesResult { export function useAgentProfiles(serverId: string | null): UseAgentProfilesResult { const { config, patchConfig } = useDaemonConfig(serverId); - const isSupported = useSessionStore((state) => { - return supportsAgentProfiles(state.sessions[serverId ?? ""]?.serverInfo?.features); - }); + const supportsProfiles = useServerFeature(serverId, "agentProfiles"); + const supportsApply = useServerFeature(serverId, "agentConfigApply"); + const isSupported = supportsProfiles && supportsApply; const saveProfiles = useCallback( async (next: AgentProfile[]) => { diff --git a/packages/app/src/agent-skills/legacy-migration-service.ts b/packages/app/src/agent-skills/legacy-migration-service.ts new file mode 100644 index 0000000000..48a2bf56d3 --- /dev/null +++ b/packages/app/src/agent-skills/legacy-migration-service.ts @@ -0,0 +1,12 @@ +import type { AgentSkillSelection } from "@getpaseo/protocol/messages"; +import { getHostClient } from "@/runtime/host-runtime"; + +export async function importLegacyAgentSkillsSelection( + serverId: string, + selection: AgentSkillSelection, +): Promise { + const client = getHostClient(serverId); + if (!client) return false; + await client.importLegacyAgentSkillsSelection(selection); + return true; +} diff --git a/packages/app/src/agent-skills/legacy-migration.tsx b/packages/app/src/agent-skills/legacy-migration.tsx index 2a1ab9aa33..34ae719925 100644 --- a/packages/app/src/agent-skills/legacy-migration.tsx +++ b/packages/app/src/agent-skills/legacy-migration.tsx @@ -5,8 +5,8 @@ import { deleteLegacySkillSelection, readLegacySkillSelection, } from "@/desktop/daemon/desktop-daemon"; -import { getHostRuntimeStore } from "@/runtime/host-runtime"; -import { useSessionStore } from "@/stores/session-store"; +import { useConnectedDesktopManagedHostIds } from "@/stores/session-store-hooks"; +import { importLegacyAgentSkillsSelection } from "./legacy-migration-service"; interface LegacySelectionClient { importLegacyAgentSkillsSelection(selection: AgentSkillSelection): Promise; @@ -26,21 +26,16 @@ export async function migrateLegacyAgentSkillsSelection( // COMPAT(desktopSkillSelectionMigration): added in v0.4.0; remove after 2027-02-16. export function LegacyAgentSkillsMigration() { - const connectedManagedHosts = useSessionStore((state) => - Object.entries(state.sessions) - .filter(([, session]) => session.serverInfo?.desktopManaged === true) - .map(([serverId]) => serverId) - .sort() - .join(","), - ); + const connectedManagedHosts = useConnectedDesktopManagedHostIds().join(","); useEffect(() => { if (!shouldUseDesktopDaemon() || !connectedManagedHosts) return; async function migrate(): Promise { const status = await getDesktopDaemonStatus(); if (status.status !== "running" || !status.desktopManaged) return; - const client = getHostRuntimeStore().getSnapshot(status.serverId)?.client; - if (!client) return; - await migrateLegacyAgentSkillsSelection(client); + const selection = await readLegacySkillSelection(); + if (!selection) return; + const imported = await importLegacyAgentSkillsSelection(status.serverId, selection); + if (imported) await deleteLegacySkillSelection(); } void migrate().catch((error) => { console.error("[Agent skills] Legacy selection migration failed; will retry", error); diff --git a/packages/app/src/agent-stream/chat-outline/use-chat-outline.ts b/packages/app/src/agent-stream/chat-outline/use-chat-outline.ts index b88876ef49..4a6337409d 100644 --- a/packages/app/src/agent-stream/chat-outline/use-chat-outline.ts +++ b/packages/app/src/agent-stream/chat-outline/use-chat-outline.ts @@ -2,7 +2,11 @@ import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } fro import type { AgentTimelinePromptIndexPayload } from "@getpaseo/client/internal/daemon-client"; import { isWeb } from "@/constants/platform"; import { useStableEvent } from "@/hooks/use-stable-event"; -import { getHostRuntimeStore } from "@/runtime/host-runtime"; +import { + listAgentTimelinePrompts, + loadAgentTimelineWindow, + subscribeToAgentTimelinePrompts, +} from "@/timeline/agent-timeline-service"; import { planTimelinePromptJump } from "@/timeline/timeline-sync-plan"; import type { StreamItem } from "@/types/stream"; import type { StreamViewportHandle } from "../strategy"; @@ -71,15 +75,13 @@ export function useChatOutline({ return; } setIndex(null); - const client = getHostRuntimeStore().getClient(serverId); - if (!client) return; let active = true; const refresh = () => { const requestId = ++nextIndexRequestIdRef.current; - void client - .listAgentTimelinePrompts(agentId) + void listAgentTimelinePrompts(serverId, agentId) .then((payload) => { if ( + payload && active && requestId === nextIndexRequestIdRef.current && shouldAcceptPromptIndexEpoch(timelineEpoch, payload.epoch) @@ -91,16 +93,7 @@ export function useChatOutline({ .catch(() => undefined); }; refresh(); - const unsubscribe = client.on("agent_stream", (message) => { - if ( - message.type === "agent_stream" && - message.payload.agentId === agentId && - message.payload.event.type === "timeline" && - message.payload.event.item.type === "user_message" - ) { - refresh(); - } - }); + const unsubscribe = subscribeToAgentTimelinePrompts(serverId, agentId, refresh); return () => { active = false; unsubscribe(); @@ -172,8 +165,11 @@ export function useChatOutline({ if (!index) return; const requestId = nextJumpRequestIdRef.current; setPendingJump({ requestId, seq, fetchSettled: false, hasScrolled: false }); - void getHostRuntimeStore() - .fetchAgentTimeline(serverId, agentId, planTimelinePromptJump({ epoch: index.epoch, seq })) + void loadAgentTimelineWindow( + serverId, + agentId, + planTimelinePromptJump({ epoch: index.epoch, seq }), + ) .catch((error: unknown) => { console.warn("Failed to load a Chat outline window", error); onJumpError(); diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx index eb68675580..c167dbba79 100644 --- a/packages/app/src/agent-stream/view.tsx +++ b/packages/app/src/agent-stream/view.tsx @@ -50,7 +50,8 @@ import type { AgentPermissionResponse, } from "@getpaseo/protocol/agent-types"; import type { AgentScreenAgent } from "@/hooks/use-agent-screen-state-machine"; -import { useSessionStore } from "@/stores/session-store"; +import { useHostRuntimeClient } from "@/runtime/host-runtime"; +import { useAgentStreamView, useServerFeature } from "@/stores/session-store-hooks"; import { useRevealedText } from "@/hooks/use-revealed-text"; import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions"; import { useLoadOlderAgentHistory } from "@/hooks/use-load-older-agent-history"; @@ -71,7 +72,7 @@ import { resolveStreamRenderStrategy } from "./strategy-resolver"; import { type StreamSegmentRenderers, type StreamViewportHandle } from "./strategy"; import { ChatOutlineRail } from "@/agent-stream/chat-outline/rail"; import { useChatOutline } from "@/agent-stream/chat-outline/use-chat-outline"; -import { getHostRuntimeStore } from "@/runtime/host-runtime"; +import { loadAgentTimelineWindow } from "@/timeline/agent-timeline-service"; import { planTimelineTailFetch } from "@/timeline/timeline-sync-plan"; import { CompletedTurnFooterRow, @@ -378,26 +379,18 @@ const AgentStreamViewComponent = forwardRef state.sessions[resolvedServerId]?.client ?? null); - const sessionStreamHead = useSessionStore((state) => - state.sessions[resolvedServerId]?.agentStreamHead?.get(agentId), - ); + const client = useHostRuntimeClient(resolvedServerId); + const sessionStream = useAgentStreamView(resolvedServerId, agentId); + const sessionStreamHead = sessionStream.head; const streamHead = providedStreamHead ?? sessionStreamHead; const forkAgent = useForkAgent({ serverId: resolvedServerId, toast, readOnly }); - const supportsAgentForkContextCursor = useSessionStore( - (state) => - state.sessions[resolvedServerId]?.serverInfo?.features?.agentForkContextCursor === true, - ); - const supportsChatOutline = useSessionStore( - (state) => - state.sessions[resolvedServerId]?.serverInfo?.features?.agentTimelinePromptIndex === true, - ); - const timelineEpoch = useSessionStore( - (state) => state.sessions[resolvedServerId]?.agentTimelineCursor.get(agentId)?.epoch ?? null, - ); - const isTimelineDetached = useSessionStore( - (state) => state.sessions[resolvedServerId]?.agentTimelineHasNewer.get(agentId) === true, + const supportsAgentForkContextCursor = useServerFeature( + resolvedServerId, + "agentForkContextCursor", ); + const supportsChatOutline = useServerFeature(resolvedServerId, "agentTimelinePromptIndex"); + const timelineEpoch = sessionStream.timelineEpoch; + const isTimelineDetached = sessionStream.hasNewerTimeline; const workspaceRoot = context.cwd?.trim() || ""; const { requestDirectoryListing } = useFileExplorerActions({ @@ -658,7 +651,7 @@ const AgentStreamViewComponent = forwardRef - getHostRuntimeStore().fetchAgentTimeline(resolvedServerId, agentId, { + loadAgentTimelineWindow(resolvedServerId, agentId, { ...planTimelineTailFetch(), }), scrollToBottom: () => viewportRef.current?.scrollToBottom("jump-to-bottom"), diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index c57dba4429..cb0c725a5a 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -68,7 +68,7 @@ import { resolveStartupBlocker, resolveStartupNavigationReady, shouldRunStartupGiveUpTimer, - startHostRuntimeBootstrap, + startDefaultHostRuntimeBootstrap, type StartupBlocker, } from "@/navigation/host-runtime-bootstrap"; import { registerWorkspaceRouteNavigationRef } from "@/navigation/workspace-route-navigation"; @@ -105,19 +105,20 @@ import { polyfillCrypto } from "@/polyfills/crypto"; import { polyfillNavigator } from "@/polyfills/navigator"; import { queryClient } from "@/data/query-client"; import { - getHostRuntimeStore, hasConfiguredLocalDaemonOverride, + setAppVisible, + useEarliestOnlineHostServerId as useRuntimeEarliestOnlineHostServerId, useHostRegistryLoaded, useHostMutations, useHostRuntimeClient, useHostRuntimeIsConnected, useHosts, } from "@/runtime/host-runtime"; -import { getDaemonStartService } from "@/runtime/daemon-start-service"; +import { getManagedDaemonStartService } from "@/runtime/daemon-start-service"; import { usePanelStore } from "@/stores/panel-store"; import { flushDraftPersistStorage } from "@/stores/draft-store"; import { getNextThemePreference } from "@/styles/theme"; -import { useSessionStore } from "@/stores/session-store"; +import { useConnection } from "@/stores/session-store-hooks"; import { installWebScrollbarStyles } from "@/styles/install-web-scrollbar-styles"; import type { HostProfile } from "@/types/host-connection"; import { @@ -289,7 +290,7 @@ function LegacyFavoriteProfileMigrationBootstrap({ serverId: string; client: NonNullable>; }) { - const serverInfo = useSessionStore((state) => state.sessions[serverId]?.serverInfo ?? null); + const serverInfo = useConnection(serverId, (connection) => connection.serverInfo); const isConnected = useHostRuntimeIsConnected(serverId); useEffect(() => { @@ -321,27 +322,11 @@ function HostSessionManager() { } export function useEarliestOnlineHostServerId(): string | null { - const store = getHostRuntimeStore(); - const subscribe = useCallback( - (listener: () => void) => { - const unsubscribeAll = store.subscribeAll(listener); - const unsubscribeHostList = store.subscribeHostList(listener); - return () => { - unsubscribeAll(); - unsubscribeHostList(); - }; - }, - [store], - ); - return useSyncExternalStore( - subscribe, - () => store.getEarliestOnlineHostServerId(), - () => store.getEarliestOnlineHostServerId(), - ); + return useRuntimeEarliestOnlineHostServerId(); } function useDaemonStartLastError(): string | null { - const service = getDaemonStartService({ store: getHostRuntimeStore() }); + const service = getManagedDaemonStartService(); return useSyncExternalStore( (listener) => service.subscribe(listener), () => service.getLastError(), @@ -350,7 +335,7 @@ function useDaemonStartLastError(): string | null { } function useDaemonStartIsRunning(): boolean { - const service = getDaemonStartService({ store: getHostRuntimeStore() }); + const service = getManagedDaemonStartService(); return useSyncExternalStore( (listener) => service.subscribe(listener), () => service.isRunning(), @@ -372,16 +357,20 @@ async function shouldStartBuiltInDaemon(): Promise { } function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) { + const pathname = usePathname(); + const routeServerId = useMemo(() => parseServerIdFromPathname(pathname), [pathname]); + useEffect(() => { - const store = getHostRuntimeStore(); - const daemonStartService = getDaemonStartService({ store }); - startHostRuntimeBootstrap({ - store, - daemonStartService, - shouldStartDaemon: shouldStartBuiltInDaemon, - }); + startDefaultHostRuntimeBootstrap(shouldStartBuiltInDaemon); }, []); + useEffect(() => { + const subscription = AppState.addEventListener("change", (nextState) => { + setAppVisible(nextState === "active", routeServerId); + }); + return () => subscription.remove(); + }, [routeServerId]); + const anyOnlineHostServerId = useEarliestOnlineHostServerId(); const daemonStartError = useDaemonStartLastError(); const daemonStartIsRunning = useDaemonStartIsRunning(); @@ -416,7 +405,7 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) { }, [shouldRunGiveUpTimer]); const retry = useCallback(() => { - const daemonStartService = getDaemonStartService({ store: getHostRuntimeStore() }); + const daemonStartService = getManagedDaemonStartService(); void daemonStartService.startIfEnabled({ shouldStart: shouldStartBuiltInDaemon }); }, []); diff --git a/packages/app/src/app/h/[serverId]/agent/[agentId].tsx b/packages/app/src/app/h/[serverId]/agent/[agentId].tsx index 7f66a07b25..bb449b1c1a 100644 --- a/packages/app/src/app/h/[serverId]/agent/[agentId].tsx +++ b/packages/app/src/app/h/[serverId]/agent/[agentId].tsx @@ -4,8 +4,13 @@ import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-bo import { useFetchQuery } from "@/data/query"; import { resolveAgentRoute, type AgentRouteLookup } from "@/navigation/agent-route-resolution"; import { AgentRouteResolutionView } from "@/navigation/agent-route-resolution-view"; -import { useSessionStore } from "@/stores/session-store"; -import { getHostRuntimeStore, useHostRuntimeSnapshot, useHosts } from "@/runtime/host-runtime"; +import { useAgentWorkspaceId } from "@/stores/session-store-hooks"; +import { + prepareAgentRoute, + probeHostConnections, + useHostRuntimeSnapshot, + useHosts, +} from "@/runtime/host-runtime"; import { buildHostRootRoute, buildSettingsHostRoute } from "@/utils/host-routes"; import { toErrorMessage } from "@/utils/error-messages"; import { navigateToAgent } from "@/utils/navigate-to-agent"; @@ -32,17 +37,10 @@ function HostAgentReadyRouteContent() { const client = runtimeSnapshot?.client ?? null; const connectionStatus = runtimeSnapshot?.connectionStatus ?? "connecting"; const hostName = hosts.find((host) => host.serverId === serverId)?.label ?? serverId; - const agentWorkspaceId = useSessionStore((state) => { - if (!serverId || !agentId) { - return null; - } - return state.sessions[serverId]?.agents?.get(agentId)?.workspaceId ?? null; - }); + const agentWorkspaceId = useAgentWorkspaceId(serverId, agentId); useEffect(() => { if (!serverId || !agentId || agentWorkspaceId) return; - void getHostRuntimeStore() - .prepareAgentRoute(serverId, agentId) - .catch(() => undefined); + void prepareAgentRoute(serverId, agentId).catch(() => undefined); }, [agentId, agentWorkspaceId, serverId]); const shouldLookupAgent = Boolean( serverId && agentId && client && connectionStatus === "online" && !agentWorkspaceId, @@ -118,7 +116,7 @@ function HostAgentReadyRouteContent() { return; } if (serverId) { - void getHostRuntimeStore().runProbeCycleNow(serverId); + void probeHostConnections(serverId); } }, [lookupQuery, resolution.kind, serverId]); const handleManageHost = useCallback(() => { diff --git a/packages/app/src/architecture-boundaries.test.ts b/packages/app/src/architecture-boundaries.test.ts new file mode 100644 index 0000000000..a96f9025c2 --- /dev/null +++ b/packages/app/src/architecture-boundaries.test.ts @@ -0,0 +1,137 @@ +import { readdirSync, readFileSync } from "node:fs"; +import { dirname, extname, join, relative } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const SOURCE_ROOT = dirname(fileURLToPath(import.meta.url)); +const UI_ROOTS = [ + "app", + "agent-skills", + "agent-stream", + "command-center", + "components", + "composer", + "contexts", + "desktop", + "file-pane", + "git", + "hooks", + "panels", + "plugins", + "projects", + "provider-usage", + "screens", + "subagents", + "workspace", + "workspace-recovery", + "workspace-tabs", +] as const; +const SESSION_STORE_OWNER_FILES = new Set(["plugins/client-state/source.ts"]); + +function productionSources(root: string): string[] { + const sources: string[] = []; + for (const entry of readdirSync(root, { withFileTypes: true })) { + const path = join(root, entry.name); + if (entry.isDirectory()) { + sources.push(...productionSources(path)); + continue; + } + if (![".ts", ".tsx"].includes(extname(entry.name))) continue; + if (/\.(test|spec)\.[^.]+$/.test(entry.name)) continue; + sources.push(path); + } + return sources; +} + +function importedModules(source: string): string[] { + return Array.from( + source.matchAll(/(?:from\s+|import\s*)["']([^"']+)["']/g), + (match) => match[1] ?? "", + ); +} + +function importsSessionStoreValue(source: string): boolean { + return Array.from(source.matchAll(/import\s+(?!type\b)[\s\S]*?from\s+["']([^"']+)["']/g)).some( + (match) => match[1] === "@/stores/session-store", + ); +} + +function importsAgentStoreProjection(path: string): boolean { + return importedModules(readFileSync(path, "utf8")).some((module) => + module.includes("runtime/directory-sync/internal/agent-store"), + ); +} + +describe("app architecture boundaries", () => { + it("keeps UI leaves behind semantic session queries and commands", () => { + const violations: string[] = []; + for (const root of UI_ROOTS) { + for (const path of productionSources(join(SOURCE_ROOT, root))) { + const source = readFileSync(path, "utf8"); + const sourcePath = relative(SOURCE_ROOT, path); + const modules = importedModules(source); + const reasons = [ + ...(importsSessionStoreValue(source) && !SESSION_STORE_OWNER_FILES.has(sourcePath) + ? ["imports session-store"] + : []), + ...(modules.some( + (module) => + module.includes("runtime/replica-cache") || + module.includes("runtime/client-replica/internal/cache"), + ) + ? ["imports replica cache internals"] + : []), + ...(source.includes("useSessionStore") && !SESSION_STORE_OWNER_FILES.has(sourcePath) + ? ["uses useSessionStore"] + : []), + ...(source.includes("getHostRuntimeStore") ? ["uses getHostRuntimeStore"] : []), + ]; + for (const reason of reasons) { + violations.push(`${sourcePath}: ${reason}`); + } + } + } + expect(violations).toEqual([]); + }); + + it("keeps cache internals private to the client-replica entry point", () => { + const publicEntry = join(SOURCE_ROOT, "runtime/client-replica/index.ts"); + const violations: string[] = []; + for (const path of productionSources(SOURCE_ROOT)) { + if (path === publicEntry || path.includes("/runtime/client-replica/internal/cache/")) + continue; + const modules = importedModules(readFileSync(path, "utf8")); + if ( + modules.some( + (module) => + module.includes("client-replica/internal/cache") || + module.includes("runtime/replica-cache"), + ) + ) { + violations.push(relative(SOURCE_ROOT, path)); + } + } + expect(violations).toEqual([]); + }); + + it("keeps the session hook entry point query-only", () => { + const source = readFileSync(join(SOURCE_ROOT, "stores/session-store-hooks/index.ts"), "utf8"); + expect(source).not.toMatch(/export\s+(?:const|function)\s+read[A-Z]/); + expect(source).not.toMatch(/export\s+type\s+\{[^}]*SessionState/s); + expect(source).not.toMatch(/Commands\s*[={]/); + expect(source).not.toContain("useSessionStore.getState"); + expect(importedModules(source)).not.toContain("@getpaseo/client/internal/daemon-client"); + }); + + it("keeps agent store projection private to the agent directory owner", () => { + const owner = join(SOURCE_ROOT, "runtime/directory-sync/agent-replica.ts"); + const violations = productionSources(SOURCE_ROOT) + .filter((path) => path !== owner && !path.includes("/runtime/directory-sync/internal/")) + .filter(importsAgentStoreProjection) + .map((path) => relative(SOURCE_ROOT, path)); + expect(violations).toEqual([]); + expect(readFileSync(join(SOURCE_ROOT, "utils/agent-directory-sync.ts"), "utf8")).not.toContain( + "useSessionStore", + ); + }); +}); diff --git a/packages/app/src/client-slash-commands/index.test.ts b/packages/app/src/client-slash-commands/index.test.ts index 768602a3d8..c6e86be582 100644 --- a/packages/app/src/client-slash-commands/index.test.ts +++ b/packages/app/src/client-slash-commands/index.test.ts @@ -53,7 +53,7 @@ function createAgent(overrides: Partial = {}): Agent { parentAgentId: null, labels: {}, ...overrides, - activeTurn: overrides.activeTurn ?? null, + turn: overrides.turn ?? { phase: "idle", cancellationRequestId: null }, }; } diff --git a/packages/app/src/command-center/workspace-file-search.ts b/packages/app/src/command-center/workspace-file-search.ts index 1008604bec..f27792df5e 100644 --- a/packages/app/src/command-center/workspace-file-search.ts +++ b/packages/app/src/command-center/workspace-file-search.ts @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { openWorkspaceFileFromExplorer } from "@/screens/workspace/workspace-file-open-command"; import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; import { usePanelStore } from "@/stores/panel-store"; -import { useSessionStore } from "@/stores/session-store"; +import { useHostRuntimeClient } from "@/runtime/host-runtime"; import { useWorkspaceDirectory } from "@/stores/session-store-hooks"; import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; import { clearCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore"; @@ -57,9 +57,7 @@ export function useWorkspaceFileSearch(input: { enabled: boolean; query: string const serverId = selection?.serverId ?? null; const workspaceId = selection?.workspaceId ?? null; const cwd = useWorkspaceDirectory(serverId, workspaceId); - const client = useSessionStore((state) => - serverId ? (state.sessions[serverId]?.client ?? null) : null, - ); + const client = useHostRuntimeClient(serverId ?? ""); const [state, setState] = useState(EMPTY_STATE); const sourceKey = useMemo( () => (serverId && workspaceId && cwd && client ? `${serverId}\0${workspaceId}\0${cwd}` : null), diff --git a/packages/app/src/command-center/workspace-registration.tsx b/packages/app/src/command-center/workspace-registration.tsx index 70789e5353..d04d428e2d 100644 --- a/packages/app/src/command-center/workspace-registration.tsx +++ b/packages/app/src/command-center/workspace-registration.tsx @@ -179,10 +179,6 @@ export function useWorkspaceCommandCenterActions(): void { const activeTabIndex = focusedTabs.findIndex((tab) => tab.tabId === focusedPane?.focusedTabId); const activeTabKind = activeTabIndex >= 0 ? (focusedTabs[activeTabIndex]?.target.kind ?? null) : null; - // One narrow projection for the workspace management contribution fields. The registry's snapshot - // dedup is unreachable (registry.ts spreads a fresh object per contribution, then compares by - // reference), so the array-identity guard in registry.replace() is the only thing stopping a - // re-render — a churny subscription here rebuilds the list while the user is looking at it. const fields = useWorkspaceFields(serverId, workspaceId, (workspace) => ({ id: workspace.id, workspaceDirectory: workspace.workspaceDirectory ?? null, diff --git a/packages/app/src/components/add-project-flow.tsx b/packages/app/src/components/add-project-flow.tsx index 80cc9075be..219423d010 100644 --- a/packages/app/src/components/add-project-flow.tsx +++ b/packages/app/src/components/add-project-flow.tsx @@ -89,8 +89,11 @@ import { useHostRuntimeConnectionStatuses, } from "@/runtime/host-runtime"; import { useHostFeatureMap } from "@/runtime/host-features"; -import { useSessionStore } from "@/stores/session-store"; import { useRecommendedProjectPaths } from "@/stores/session-store-hooks"; +import { + acceptProjectSnapshot as upsertProject, + publishWorkspaceHydration as setHasHydratedWorkspaces, +} from "@/runtime/session-data"; import type { AddProjectFlowRequest } from "@/stores/add-project-flow-store"; import type { Theme } from "@/styles/theme"; import { shortenPath } from "@/utils/shorten-path"; @@ -372,8 +375,6 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) { const recommendedPaths = useRecommendedProjectPaths(hostId); const openProject = useOpenProject(hostId); const cloneGithubProject = useCloneGithubProject(hostId); - const upsertProject = useSessionStore((store) => store.upsertProject); - const setHasHydratedWorkspaces = useSessionStore((store) => store.setHasHydratedWorkspaces); const inputRef = useRef(null); const submissionInFlightRef = useRef(false); const browseInFlightRef = useRef(false); @@ -751,7 +752,7 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) { } finally { submissionInFlightRef.current = false; } - }, [client, openNewWorkspaceForProject, page, setHasHydratedWorkspaces, upsertProject]); + }, [client, openNewWorkspaceForProject, page]); const submitActive = useCallback(() => { if (page.kind === "new-directory-name") { diff --git a/packages/app/src/components/agent-list.tsx b/packages/app/src/components/agent-list.tsx index 58023da386..d03afef42f 100644 --- a/packages/app/src/components/agent-list.tsx +++ b/packages/app/src/components/agent-list.tsx @@ -16,7 +16,7 @@ import { useTranslation } from "react-i18next"; import { useIsCompactFormFactor } from "@/constants/layout"; import { formatTimeAgo } from "@/utils/time"; import { type AggregatedAgent } from "@/hooks/use-aggregated-agents"; -import { useSessionStore } from "@/stores/session-store"; +import { getHostClient, useHostRuntimeClient } from "@/runtime/host-runtime"; import { Archive, ChevronRight } from "lucide-react-native"; import { getProviderIcon } from "@/components/provider-icons"; import { navigateToAgent } from "@/utils/navigate-to-agent"; @@ -390,9 +390,7 @@ export function AgentList({ const isMobile = useIsCompactFormFactor(); const { archiveAgent } = useArchiveAgent(); - const actionClient = useSessionStore((state) => - actionAgent?.serverId ? (state.sessions[actionAgent.serverId]?.client ?? null) : null, - ); + const actionClient = useHostRuntimeClient(actionAgent?.serverId ?? ""); const isActionSheetVisible = actionAgent !== null; const isActionDaemonUnavailable = Boolean(actionAgent?.serverId && !actionClient); @@ -419,13 +417,13 @@ export function AgentList({ const handleAgentLongPress = useCallback( (agent: AggregatedAgent) => { - const isRunning = agent.status === "running"; + const isRunning = agent.turn.phase === "open"; if (isRunning) { setActionAgent(agent); return; } - const client = useSessionStore.getState().sessions[agent.serverId]?.client ?? null; + const client = getHostClient(agent.serverId); if (!client) { setActionAgent(agent); return; diff --git a/packages/app/src/components/app-diagnostic-sheet.tsx b/packages/app/src/components/app-diagnostic-sheet.tsx index 9cf74a9323..dcc08762d2 100644 --- a/packages/app/src/components/app-diagnostic-sheet.tsx +++ b/packages/app/src/components/app-diagnostic-sheet.tsx @@ -17,7 +17,11 @@ import { redactAppDiagnosticReport, } from "@/diagnostics/app-diagnostic-report"; import { collectDesktopDiagnosticSections } from "@/diagnostics/desktop-diagnostic-report"; -import { getHostRuntimeStore, useHosts, type HostRuntimeSnapshot } from "@/runtime/host-runtime"; +import { + readHostRuntimeSnapshot, + useHosts, + type HostRuntimeSnapshot, +} from "@/runtime/host-runtime"; import { ICON_SIZE, type Theme } from "@/styles/theme"; import type { HostProfile } from "@/types/host-connection"; @@ -108,11 +112,10 @@ export function AppDiagnosticSheet({ updateRunProgress("desktop", desktopLabel, desktopResult.status); } - const store = getHostRuntimeStore(); for (const host of hosts) { const hostProgressId = `host:${host.serverId}`; updateRunProgress(hostProgressId, host.label, "running"); - const snapshot = store.getSnapshot(host.serverId); + const snapshot = readHostRuntimeSnapshot(host.serverId); const hostResult = await collectHostDiagnosticSections(host, snapshot); sections.push(...hostResult.sections); updateRunProgress(hostProgressId, host.label, hostResult.status); diff --git a/packages/app/src/components/compact-explorer-sidebar-host-state.ts b/packages/app/src/components/compact-explorer-sidebar-host-state.ts index 8d44a9d767..9ca12b2d2f 100644 --- a/packages/app/src/components/compact-explorer-sidebar-host-state.ts +++ b/packages/app/src/components/compact-explorer-sidebar-host-state.ts @@ -1,6 +1,6 @@ import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model"; import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; -import type { WorkspaceDescriptor } from "@/stores/session-store"; +import type { WorkspaceDescriptor } from "@/stores/session-store-hooks"; export interface CompactExplorerSidebarHostModel { serverId: string; diff --git a/packages/app/src/components/file-explorer-pane.tsx b/packages/app/src/components/file-explorer-pane.tsx index e014928531..cd6b279d69 100644 --- a/packages/app/src/components/file-explorer-pane.tsx +++ b/packages/app/src/components/file-explorer-pane.tsx @@ -54,8 +54,8 @@ import type { AgentFileExplorerState, ExplorerDirectory, ExplorerEntry, -} from "@/stores/session-store"; -import { useSessionStore } from "@/stores/session-store"; +} from "@/stores/session-store-hooks"; +import { useFileExplorer, useServerFeature } from "@/stores/session-store-hooks"; import { FileActionsContextMenuContent } from "@/components/file-actions-menu"; import { ContextMenu, ContextMenuTrigger, useContextMenu } from "@/components/ui/context-menu"; import { useFileDownload } from "@/hooks/use-file-download"; @@ -426,11 +426,7 @@ export function FileExplorerPane({ [normalizedWorkspaceRoot, workspaceId], ); const hasWorkspaceScope = Boolean(workspaceStateKey && normalizedWorkspaceRoot); - const explorerState = useSessionStore((state) => - workspaceStateKey && state.sessions[serverId] - ? state.sessions[serverId]?.fileExplorer.get(workspaceStateKey) - : undefined, - ); + const explorerState = useFileExplorer(serverId, workspaceStateKey); const { requestDirectoryListing, @@ -455,13 +451,9 @@ export function FileExplorerPane({ workspaceDirectory: normalizedWorkspaceRoot, }); // COMPAT(fsEntryOps): added in v0.3.0, remove gate after 2027-02-08. - const fsEntryOpsEnabled = useSessionStore( - (state) => state.sessions[serverId]?.serverInfo?.features?.fsEntryOps === true, - ); + const fsEntryOpsEnabled = useServerFeature(serverId, "fsEntryOps"); // COMPAT(fsEntryDuplicate): added in v0.3.0, remove gate after 2027-02-09. - const fsEntryDuplicateEnabled = useSessionStore( - (state) => state.sessions[serverId]?.serverInfo?.features?.fsEntryDuplicate === true, - ); + const fsEntryDuplicateEnabled = useServerFeature(serverId, "fsEntryDuplicate"); const [pendingEdit, setPendingEdit] = useState(null); const downloadFile = useFileDownload({ serverId, diff --git a/packages/app/src/components/rewind/use-rewind-agent-mutation.ts b/packages/app/src/components/rewind/use-rewind-agent-mutation.ts index d482bb2d42..8ca21f8cfb 100644 --- a/packages/app/src/components/rewind/use-rewind-agent-mutation.ts +++ b/packages/app/src/components/rewind/use-rewind-agent-mutation.ts @@ -5,9 +5,9 @@ import { useToast } from "@/contexts/toast-context"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { RewindMode } from "./use-rewind-capabilities"; import { useRewindComposerRestore } from "./composer-restore"; -import { useSessionStore } from "@/stores/session-store"; +import { getAgentTimelineLoadSnapshot } from "@/runtime/session-data"; import { shouldRestoreComposerForRewindMode } from "./rewind-mode"; -import { getHostRuntimeStore } from "@/runtime/host-runtime"; +import { fetchAgentTimeline } from "@/runtime/host-runtime"; interface UseRewindAgentMutationInput { serverId?: string; @@ -35,13 +35,12 @@ export function useRewindAgentMutation(input: UseRewindAgentMutationInput): { } await input.client.rewindAgent(input.agentId, input.messageId, mode); if (mode !== "files") { - const cursor = input.serverId - ? useSessionStore - .getState() - .sessions[input.serverId]?.agentTimelineCursor.get(input.agentId) - : undefined; + const timeline = input.serverId + ? getAgentTimelineLoadSnapshot(input.serverId, input.agentId).timeline + : null; + const cursor = timeline?.status === "synced" ? timeline.range : undefined; if (!input.serverId) throw new Error(t("common.errors.daemonClientUnavailable")); - await getHostRuntimeStore().fetchAgentTimeline(input.serverId, input.agentId, { + await fetchAgentTimeline(input.serverId, input.agentId, { direction: "tail", projection: "projected", ...(cursor ? { cursor: { epoch: cursor.epoch, seq: cursor.endSeq } } : {}), diff --git a/packages/app/src/components/schedules/schedule-form-sheet.tsx b/packages/app/src/components/schedules/schedule-form-sheet.tsx index 2a3cd97c10..9b2748b987 100644 --- a/packages/app/src/components/schedules/schedule-form-sheet.tsx +++ b/packages/app/src/components/schedules/schedule-form-sheet.tsx @@ -14,7 +14,6 @@ import { Brain, Folder, GitBranch } from "lucide-react-native"; import { StyleSheet } from "react-native-unistyles"; import type { AgentProvider } from "@getpaseo/protocol/agent-types"; import type { ScheduleCadence, ScheduleSummary } from "@getpaseo/protocol/schedule/types"; -import { useStoreWithEqualityFn } from "zustand/traditional"; import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; import { ComboboxItem } from "@/components/ui/combobox"; import { Button } from "@/components/ui/button"; @@ -43,7 +42,7 @@ import { useScheduleMutations } from "@/hooks/use-schedule-mutations"; import { useAggregatedAgents } from "@/hooks/use-aggregated-agents"; import { useProjects } from "@/hooks/use-projects"; import { useHosts } from "@/runtime/host-runtime"; -import { useSessionStore } from "@/stores/session-store"; +import { useConnections, type ConnectionView } from "@/stores/session-store-hooks"; import { buildScheduleProjectTargets } from "@/schedules/schedule-project-targets"; import { useScheduleFormModel } from "@/schedules/use-schedule-form-model"; import { useScheduleFormProviderSnapshot } from "@/schedules/use-schedule-form-provider-snapshot"; @@ -114,14 +113,14 @@ function openKey(props: ScheduleFormSheetProps): string { function selectScheduleHosts( hosts: readonly { serverId: string; label: string }[], -): (state: ReturnType) => ScheduleFormHost[] { - return (state) => - hosts.map((host) => ({ - serverId: host.serverId, - label: host.label, - supportsWorkspaceMultiplicity: - state.sessions[host.serverId]?.serverInfo?.features?.workspaceMultiplicity === true, - })); + connections: ReadonlyMap, +): ScheduleFormHost[] { + return hosts.map((host) => ({ + serverId: host.serverId, + label: host.label, + supportsWorkspaceMultiplicity: + connections.get(host.serverId)?.serverInfo?.features?.workspaceMultiplicity === true, + })); } function buildSnapshot(input: { @@ -242,9 +241,10 @@ function OpenScheduleFormSheet({ const controlSize: FieldControlSize = useIsCompactFormFactor() ? "md" : "sm"; const { projects } = useProjects(); const hostProfiles = useHosts(); - const hosts = useStoreWithEqualityFn( - useSessionStore, - useMemo(() => selectScheduleHosts(hostProfiles), [hostProfiles]), + const hostIds = useMemo(() => hostProfiles.map((host) => host.serverId), [hostProfiles]); + const hosts = useConnections( + hostIds, + (connections) => selectScheduleHosts(hostProfiles, connections), equal, ); const { preferences, updatePreferences } = useFormPreferences(); diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index 347315bd19..9f768c4b8f 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -40,7 +40,7 @@ import { ExternalLink, Settings, MoreVertical, Plus, Trash2 } from "lucide-react import { NestableScrollContainer } from "react-native-draggable-flatlist"; import { DraggableList, type DraggableRenderItemInfo } from "./draggable-list"; import type { DraggableListDragHandleProps } from "./draggable-list.types"; -import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime"; +import { getHostClient, useHosts } from "@/runtime/host-runtime"; import type { PinnedSidebarGroups } from "@/hooks/use-sidebar-pins"; import { useSidebarWorkspacePinController, @@ -1703,7 +1703,7 @@ function ProjectBlock({ void removeProjectFromHosts({ targets: readiness.targets, - getClient: (serverId) => getHostRuntimeStore().getClient(serverId), + getClient: (serverId) => getHostClient(serverId), }) .then((outcome) => { if (outcome.kind === "host_disconnected") { diff --git a/packages/app/src/components/sidebar/sidebar-help-menu.tsx b/packages/app/src/components/sidebar/sidebar-help-menu.tsx index eb00df6e12..7c5f707af3 100644 --- a/packages/app/src/components/sidebar/sidebar-help-menu.tsx +++ b/packages/app/src/components/sidebar/sidebar-help-menu.tsx @@ -19,7 +19,7 @@ import { useAppDiagnosticStore } from "@/diagnostics/store"; import { useKeyboardShortcutsAvailable } from "@/keyboard/availability"; import { useHostRuntimeIsConnected, useHosts } from "@/runtime/host-runtime"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; -import { useSessionStore } from "@/stores/session-store"; +import { useConnection } from "@/stores/session-store-hooks"; import { ICON_SIZE, type Theme } from "@/styles/theme"; import type { HostProfile } from "@/types/host-connection"; import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates"; @@ -58,8 +58,9 @@ const changelogLeadingIcon = ( function HostVersionHint({ host }: { host: HostProfile }) { const { t } = useTranslation(); const isConnected = useHostRuntimeIsConnected(host.serverId); - const daemonVersion = useSessionStore( - (state) => state.sessions[host.serverId]?.serverInfo?.version ?? null, + const daemonVersion = useConnection( + host.serverId, + ({ serverInfo }) => serverInfo?.version ?? null, ); const version = isConnected ? formatVersionWithPrefix(daemonVersion) diff --git a/packages/app/src/components/terminal-pane.tsx b/packages/app/src/components/terminal-pane.tsx index 3e98799035..7b39a7d179 100644 --- a/packages/app/src/components/terminal-pane.tsx +++ b/packages/app/src/components/terminal-pane.tsx @@ -50,7 +50,8 @@ import { import { resolveTerminalRestoreOptions } from "@/terminal/runtime/terminal-restore-options"; import { usePanelStore } from "@/stores/panel-store"; import { useBlockMobilePanelOpenGestures } from "@/mobile-panels/provider"; -import { useSessionStore } from "@/stores/session-store"; +import { useServerFeature } from "@/stores/session-store-hooks"; +import { publishFocusedTerminal as setFocusedTerminalId } from "@/runtime/session-data"; import { toXtermTheme } from "@/utils/to-xterm-theme"; import TerminalEmulator, { type TerminalEmulatorHandle } from "./terminal-emulator"; import { TerminalFloatingCopyAction, TerminalPasteAction } from "./terminal-copy-paste-actions"; @@ -233,17 +234,9 @@ export function TerminalPane({ const client = useHostRuntimeClient(serverId); const isConnected = useHostRuntimeIsConnected(serverId); const isTerminalPresented = retainedPanelActive && isWorkspaceFocused; - const supportsTerminalRestoreModes = useSessionStore( - (state) => state.sessions[serverId]?.serverInfo?.features?.["terminal-restore-modes"] === true, - ); - const supportsTerminalInputModeReplay = useSessionStore( - (state) => - state.sessions[serverId]?.serverInfo?.features?.["terminal-input-mode-replay"] === true, - ); - const supportsTerminalSizeOwnership = useSessionStore( - (state) => state.sessions[serverId]?.serverInfo?.features?.["terminal-size-ownership"] === true, - ); - const setFocusedTerminalId = useSessionStore((state) => state.setFocusedTerminalId); + const supportsTerminalRestoreModes = useServerFeature(serverId, "terminal-restore-modes"); + const supportsTerminalInputModeReplay = useServerFeature(serverId, "terminal-input-mode-replay"); + const supportsTerminalSizeOwnership = useServerFeature(serverId, "terminal-size-ownership"); const scopeKey = useMemo(() => terminalScopeKey({ serverId, cwd }), [serverId, cwd]); const terminalStreamKey = useMemo(() => `${scopeKey}:${terminalId}`, [scopeKey, terminalId]); @@ -393,14 +386,7 @@ export function TerminalPane({ lastSentTerminalSizeRef.current = null; requestTerminalReflow(); emulatorRef.current?.claimSize(); - }, [ - isPaneFocused, - isWorkspaceFocused, - requestTerminalReflow, - serverId, - setFocusedTerminalId, - terminalId, - ]); + }, [isPaneFocused, isWorkspaceFocused, requestTerminalReflow, serverId, terminalId]); const clearKeyboardRefitTimeouts = useCallback(() => { if (keyboardRefitTimeoutsRef.current.length === 0) { diff --git a/packages/app/src/components/welcome-screen.tsx b/packages/app/src/components/welcome-screen.tsx index 1b3cdbe237..c1d85a1cd1 100644 --- a/packages/app/src/components/welcome-screen.tsx +++ b/packages/app/src/components/welcome-screen.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { Pressable, Text, View, ScrollView } from "react-native"; import { useRouter } from "expo-router"; @@ -13,7 +13,12 @@ import { } from "lucide-react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import type { HostProfile } from "@/types/host-connection"; -import { getHostRuntimeStore, isHostRuntimeConnected, useHosts } from "@/runtime/host-runtime"; +import { + isHostRuntimeConnected, + readHostRuntimeSnapshot, + useHostRuntimeVersion, + useHosts, +} from "@/runtime/host-runtime"; import { AddHostModal } from "./add-host-modal"; import { AddRemoteSshHostModal } from "./add-remote-ssh-host-modal"; import { PairLinkModal } from "./pair-link-modal"; @@ -124,42 +129,24 @@ const styles = StyleSheet.create((theme) => ({ })); function useAnyHostOnline(serverIds: string[]): string | null { - const runtime = getHostRuntimeStore(); - return useSyncExternalStore( - (onStoreChange) => runtime.subscribeAll(onStoreChange), - () => { - let firstOnlineServerId: string | null = null; - let firstOnlineAt: string | null = null; - for (const serverId of serverIds) { - const snapshot = runtime.getSnapshot(serverId); - const lastOnlineAt = snapshot?.lastOnlineAt ?? null; - if (!isHostRuntimeConnected(snapshot) || !lastOnlineAt) { - continue; - } - if (!firstOnlineAt || lastOnlineAt < firstOnlineAt) { - firstOnlineAt = lastOnlineAt; - firstOnlineServerId = serverId; - } + const runtimeVersion = useHostRuntimeVersion(); + return useMemo(() => { + void runtimeVersion; + let firstOnlineServerId: string | null = null; + let firstOnlineAt: string | null = null; + for (const serverId of serverIds) { + const snapshot = readHostRuntimeSnapshot(serverId); + const lastOnlineAt = snapshot?.lastOnlineAt ?? null; + if (!isHostRuntimeConnected(snapshot) || !lastOnlineAt) { + continue; } - return firstOnlineServerId; - }, - () => { - let firstOnlineServerId: string | null = null; - let firstOnlineAt: string | null = null; - for (const serverId of serverIds) { - const snapshot = runtime.getSnapshot(serverId); - const lastOnlineAt = snapshot?.lastOnlineAt ?? null; - if (!isHostRuntimeConnected(snapshot) || !lastOnlineAt) { - continue; - } - if (!firstOnlineAt || lastOnlineAt < firstOnlineAt) { - firstOnlineAt = lastOnlineAt; - firstOnlineServerId = serverId; - } + if (!firstOnlineAt || lastOnlineAt < firstOnlineAt) { + firstOnlineAt = lastOnlineAt; + firstOnlineServerId = serverId; } - return firstOnlineServerId; - }, - ); + } + return firstOnlineServerId; + }, [runtimeVersion, serverIds]); } export interface WelcomeScreenProps { diff --git a/packages/app/src/components/workspace-rename-modal.tsx b/packages/app/src/components/workspace-rename-modal.tsx index 2f96534ea0..2402c67c01 100644 --- a/packages/app/src/components/workspace-rename-modal.tsx +++ b/packages/app/src/components/workspace-rename-modal.tsx @@ -2,7 +2,7 @@ import { useCallback } from "react"; import { useTranslation } from "react-i18next"; import { useMutation } from "@tanstack/react-query"; import { AdaptiveRenameModal } from "@/components/rename-modal"; -import { getHostRuntimeStore } from "@/runtime/host-runtime"; +import { getHostClient } from "@/runtime/host-runtime"; // The subset of a workspace the rename dialog needs. Narrower than SidebarWorkspaceEntry so the // command center can build one from the active route selection without a sidebar row. @@ -39,7 +39,7 @@ export function WorkspaceRenameModal({ const renameMutation = useMutation({ mutationFn: async (title: string) => { - const client = getHostRuntimeStore().getClient(workspace.serverId); + const client = getHostClient(workspace.serverId); if (!client) { throw new Error(t("sidebar.workspace.toasts.hostDisconnected")); } diff --git a/packages/app/src/components/workspace-setup-dialog.tsx b/packages/app/src/components/workspace-setup-dialog.tsx index 4dabf067ba..39d602c5c6 100644 --- a/packages/app/src/components/workspace-setup-dialog.tsx +++ b/packages/app/src/components/workspace-setup-dialog.tsx @@ -12,7 +12,12 @@ import { useToast } from "@/contexts/toast-context"; import { useAgentInputDraft } from "@/composer/draft/input-draft"; import { useProjectIcon } from "@/projects/icons"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; -import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store"; +import { + acceptAgentSnapshot, + acceptWorkspaceSnapshots as mergeWorkspaces, + publishWorkspaceHydration as setHasHydratedWorkspaces, +} from "@/runtime/session-data"; +import { normalizeWorkspaceDescriptor, useServerFeature } from "@/stores/session-store-hooks"; import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store"; import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; import { applyLegacyDaemonWorkspaceOwnership } from "@/workspace/legacy-daemon-workspaces"; @@ -164,9 +169,6 @@ export function WorkspaceSetupDialog() { const toast = useToast(); const pendingWorkspaceSetup = useWorkspaceSetupStore((state) => state.pendingWorkspaceSetup); const clearWorkspaceSetup = useWorkspaceSetupStore((state) => state.clearWorkspaceSetup); - const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces); - const setHasHydratedWorkspaces = useSessionStore((state) => state.setHasHydratedWorkspaces); - const setAgents = useSessionStore((state) => state.setAgents); const [errorMessage, setErrorMessage] = useState(null); const [createdWorkspace, setCreatedWorkspace] = useState(null); const serverId = pendingWorkspaceSetup?.serverId ?? ""; - const supportsForgeSearch = useSessionStore( - (state) => state.sessions[serverId]?.serverInfo?.features?.forgeSearch === true, - ); + const supportsForgeSearch = useServerFeature(serverId, "forgeSearch"); const sourceDirectory = pendingWorkspaceSetup?.sourceDirectory ?? ""; const displayName = pendingWorkspaceSetup?.displayName?.trim() ?? ""; const workspace = createdWorkspace; @@ -278,14 +278,7 @@ export function WorkspaceSetupDialog() { setCreatedWorkspace(normalizedWorkspace); return normalizedWorkspace; }, - [ - createdWorkspace, - mergeWorkspaces, - pendingWorkspaceSetup, - setHasHydratedWorkspaces, - t, - withConnectedClient, - ], + [createdWorkspace, pendingWorkspaceSetup, t, withConnectedClient], ); const getIsStillActive = useCallback(() => { @@ -341,17 +334,13 @@ export function WorkspaceSetupDialog() { return; } - setAgents(serverId, (previous) => { - const next = new Map(previous); - next.set( - agent.id, - applyLegacyDaemonWorkspaceOwnership({ - serverId, - agent: normalizeAgentSnapshot(agent, serverId), - }), - ); - return next; - }); + acceptAgentSnapshot( + serverId, + applyLegacyDaemonWorkspaceOwnership({ + serverId, + agent: normalizeAgentSnapshot(agent, serverId), + }), + ); navigateAfterCreation(ensuredWorkspace.id, { kind: "agent", agentId: agent.id }); } catch (error) { const message = toErrorMessage(error); @@ -368,7 +357,6 @@ export function WorkspaceSetupDialog() { getIsStillActive, navigateAfterCreation, serverId, - setAgents, ensureWorkspace, t, toast, diff --git a/packages/app/src/composer/agent-controls/index.tsx b/packages/app/src/composer/agent-controls/index.tsx index 32941e46e7..7c006238d8 100644 --- a/packages/app/src/composer/agent-controls/index.tsx +++ b/packages/app/src/composer/agent-controls/index.tsx @@ -22,7 +22,6 @@ import { type ViewStyle, } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; -import { useShallow } from "zustand/shallow"; import { Settings2 } from "lucide-react-native"; import { getAgentFeatureIcon, ThinkingIcon } from "@/agent-controls/icons"; import { formatThinkingOptionLabel } from "@/agent-controls/labels"; @@ -34,7 +33,8 @@ import { type ProviderSelectorProvider, } from "@/provider-selection/provider-selection"; import { filterSelectableModels } from "@/provider-selection/model-catalog"; -import { useSessionStore } from "@/stores/session-store"; +import { useActiveAgentFields, type Agent } from "@/stores/session-store-hooks"; +import { useHostRuntimeClient } from "@/runtime/host-runtime"; import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot"; import { resolveProviderDefinition } from "@/utils/provider-definitions"; import { mergeProviderPreferences, useFormPreferences } from "@/hooks/use-form-preferences"; @@ -398,15 +398,7 @@ type AgentControlsSlice = { lastUsage: unknown; } | null; -function selectAgentControlsSlice( - state: ReturnType, - serverId: string, - agentId: string, -): AgentControlsSlice { - const currentAgent = state.sessions[serverId]?.agents?.get(agentId) ?? null; - if (!currentAgent) { - return null; - } +function selectAgentControlsSlice(currentAgent: Agent): AgentControlsSlice { return { provider: currentAgent.provider, cwd: currentAgent.cwd, @@ -1541,10 +1533,8 @@ export const AgentControls = memo(function AgentControls({ isCompactLayout, }: AgentControlsProps) { const { updatePreferences } = useFormPreferences(); - const agent = useSessionStore( - useShallow((state) => selectAgentControlsSlice(state, serverId, agentId)), - ); - const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null); + const agent = useActiveAgentFields(serverId, agentId, selectAgentControlsSlice); + const client = useHostRuntimeClient(serverId); const toast = useToast(); const modeControl = useLiveAgentModeControl(serverId, agentId); const commandCenterModes = toCommandCenterModes(modeControl); diff --git a/packages/app/src/composer/agent-controls/mode-control.tsx b/packages/app/src/composer/agent-controls/mode-control.tsx index 59fb515ce5..f13e882209 100644 --- a/packages/app/src/composer/agent-controls/mode-control.tsx +++ b/packages/app/src/composer/agent-controls/mode-control.tsx @@ -2,8 +2,6 @@ import { useCallback, useMemo, useRef, useState, type ReactElement } from "react import { useTranslation } from "react-i18next"; import { Text, View } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; -import { useShallow } from "zustand/shallow"; -import { useStoreWithEqualityFn } from "zustand/traditional"; import { type SheetHeader } from "@/components/adaptive-modal-sheet"; import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; @@ -17,7 +15,8 @@ import { resolveNextAgentModeId } from "@/composer/agent-controls/mode"; import { useComposerKeyboardScope } from "@/composer/keyboard-scope"; import { useComposerControlLayout } from "@/composer/agent-controls/layout-context"; import { AgentControlTrigger } from "@/composer/agent-controls/control"; -import { useSessionStore } from "@/stores/session-store"; +import { useActiveAgentFields } from "@/stores/session-store-hooks"; +import { useHostRuntimeClient } from "@/runtime/host-runtime"; import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot"; import { mergeProviderPreferences, useFormPreferences } from "@/hooks/use-form-preferences"; import { resolveProviderDefinition } from "@/utils/provider-definitions"; @@ -238,31 +237,18 @@ export function AgentModeControl({ const EMPTY_MODES: AgentMode[] = []; -function compareAvailableModes(a: AgentMode[], b: AgentMode[]): boolean { - return a === b || JSON.stringify(a) === JSON.stringify(b); -} - export function useLiveAgentModeControl( serverId: string, agentId: string, ): AgentModeControlValue | null { - const slice = useSessionStore( - useShallow((state) => { - const agent = state.sessions[serverId]?.agents?.get(agentId); - if (!agent) return null; - return { - provider: agent.provider, - cwd: agent.cwd, - currentModeId: agent.currentModeId, - }; - }), - ); - const availableModes = useStoreWithEqualityFn( - useSessionStore, - (state) => state.sessions[serverId]?.agents?.get(agentId)?.availableModes ?? EMPTY_MODES, - compareAvailableModes, - ); - const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null); + const slice = useActiveAgentFields(serverId, agentId, (agent) => ({ + provider: agent.provider, + cwd: agent.cwd, + currentModeId: agent.currentModeId, + availableModes: agent.availableModes, + })); + const availableModes = slice?.availableModes ?? EMPTY_MODES; + const client = useHostRuntimeClient(serverId); const { updatePreferences } = useFormPreferences(); const toast = useToast(); const { entries: snapshotEntries } = useProvidersSnapshot(serverId, { cwd: slice?.cwd }); diff --git a/packages/app/src/composer/draft/create-flow.ts b/packages/app/src/composer/draft/create-flow.ts index d1cfce1161..5157c067f2 100644 --- a/packages/app/src/composer/draft/create-flow.ts +++ b/packages/app/src/composer/draft/create-flow.ts @@ -7,7 +7,7 @@ import { } from "@/composer/attachments/submit"; import { useCreateFlowStore } from "@/stores/create-flow-store"; import { handoffCreatedAgentMessageSubmission } from "@/composer/submission/writer"; -import { useSessionStore } from "@/stores/session-store"; +import { hostSupports } from "@/runtime/session-data"; import { createUserMessage, generateMessageId, @@ -267,9 +267,7 @@ export function useDraftAgentCreateFlow({ dispatch({ type: "DRAFT_SET_ERROR", message: error.message }); throw error; } - const supportsForgeSearch = - useSessionStore.getState().sessions[pendingServerId]?.serverInfo?.features?.forgeSearch === - true; + const supportsForgeSearch = hostSupports(pendingServerId, "forgeSearch"); const wirePayload = splitComposerAttachmentsForSubmit(attachments, { format: resolveComposerAttachmentSubmitFormat({ supportsForgeAttachments: supportsForgeSearch, diff --git a/packages/app/src/composer/draft/workspace-tab.tsx b/packages/app/src/composer/draft/workspace-tab.tsx index e9047eb60a..7c133b2e2e 100644 --- a/packages/app/src/composer/draft/workspace-tab.tsx +++ b/packages/app/src/composer/draft/workspace-tab.tsx @@ -20,7 +20,7 @@ import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host- import { buildWorkspaceDraftAgentConfig } from "@/screens/workspace/workspace-draft-agent-config"; import { buildDraftStoreKey } from "@/stores/draft-keys"; import { useCreateFlowStore } from "@/stores/create-flow-store"; -import type { Agent } from "@/stores/session-store"; +import type { Agent } from "@/stores/session-store-hooks"; import { useWorkspaceFields } from "@/stores/session-store-hooks"; import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store"; import { useAgentControlCommandCenterActions } from "@/command-center/agent-control-registration"; @@ -248,7 +248,12 @@ function buildDraftAgentSnapshot(input: { id: tabId, provider, status: "running", - activeTurn: null, + turn: { + phase: "open", + turnId: null, + startedAt: now, + cancellationRequestId: null, + }, createdAt: now, updatedAt: now, lastUserMessageAt: now, @@ -342,9 +347,9 @@ export function WorkspaceDraftAgentTab({ const insets = useSafeAreaInsets(); const client = useHostRuntimeClient(serverId); const isConnected = useHostRuntimeIsConnected(serverId); - const workspaceFields = useWorkspaceFields(serverId, workspaceId, (w) => ({ - workspaceDirectory: w.workspaceDirectory, - id: w.id, + const workspaceFields = useWorkspaceFields(serverId, workspaceId, (workspace) => ({ + id: workspace.id, + workspaceDirectory: workspace.workspaceDirectory, })); const workspaceDirectory = workspaceFields?.workspaceDirectory || null; const draftSetup = initialSetup ?? null; diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index 5a1d51f1fc..450ab70a38 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -20,7 +20,6 @@ import { import { useTranslation } from "react-i18next"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; -import { useShallow } from "zustand/shallow"; import { ArrowUp, Square, @@ -43,7 +42,18 @@ import { import { ContextWindowMeter } from "@/components/context-window-meter"; import { KeyboardTranslateView } from "@/components/keyboard-translate-view"; import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker"; -import { selectAgentTurnPresentation, useSessionStore } from "@/stores/session-store"; +import { + useActiveAgentFields, + type Agent, + useAgentTurn, + useComposerSession, + useServerFeature, +} from "@/stores/session-store-hooks"; +import { + getAgentTurnSnapshot, + getQueuedMessagesSnapshot, + publishQueuedMessages as setQueuedMessages, +} from "@/runtime/session-data"; import { useFilePicker } from "@/hooks/use-file-picker"; import { useFileDrop } from "@/components/file-drop/use-file-drop"; import type { DroppedItem } from "@/components/file-drop/types"; @@ -93,6 +103,7 @@ import { useHostRuntimeClient, useHostRuntimeIsConnected, } from "@/runtime/host-runtime"; +import { beginAgentCancellation, settleAgentCancellation } from "@/runtime/session-data"; import { deleteAttachments, persistAttachmentFromBlob, @@ -254,17 +265,14 @@ function buildRealtimeVoiceButtonStyle( ); } -function buildAgentStateSelector(serverId: string, agentId: string) { - return (state: ReturnType) => { - const agent = state.sessions[serverId]?.agents?.get(agentId) ?? null; - return { - status: agent?.status ?? null, - contextWindowMaxTokens: agent?.lastUsage?.contextWindowMaxTokens ?? null, - contextWindowUsedTokens: agent?.lastUsage?.contextWindowUsedTokens ?? null, - totalCostUsd: agent?.lastUsage?.totalCostUsd ?? null, - model: agent?.model ?? null, - provider: agent?.provider ?? null, - }; +function buildAgentState(agent: Agent) { + return { + status: agent.status, + contextWindowMaxTokens: agent.lastUsage?.contextWindowMaxTokens ?? null, + contextWindowUsedTokens: agent.lastUsage?.contextWindowUsedTokens ?? null, + totalCostUsd: agent.lastUsage?.totalCostUsd ?? null, + model: agent.model, + provider: agent.provider, }; } @@ -981,7 +989,6 @@ interface ComposerProps { const MAX_FILE_SIZE_BYTES = 50 * 1024 * 1024; -const EMPTY_ARRAY: readonly QueuedMessage[] = []; const StableMessageInput = memo(MessageInput); function resolveContextWindowValues( @@ -1203,14 +1210,15 @@ function ComposerContentImpl({ const { settings: appSettings } = useAppSettings(); - const agentState = useSessionStore(useShallow(buildAgentStateSelector(serverId, agentId))); - - const queuedMessagesRaw = useSessionStore((state) => - state.sessions[serverId]?.queuedMessages?.get(agentId), - ); - const queuedMessages = queuedMessagesRaw ?? EMPTY_ARRAY; - - const setQueuedMessages = useSessionStore((state) => state.setQueuedMessages); + const agentState = useActiveAgentFields(serverId, agentId, buildAgentState) ?? { + status: null, + contextWindowMaxTokens: null, + contextWindowUsedTokens: null, + totalCostUsd: null, + model: null, + provider: null, + }; + const { queuedMessages, hasPendingPermission } = useComposerSession(serverId, agentId); const isCompactFormFactor = useIsCompactFormFactor(); const isCompactLayout = resolveCompactLayout(isCompactLayoutOverride, isCompactFormFactor); @@ -1236,9 +1244,7 @@ function ComposerContentImpl({ }); const setSelectedAttachments = onChangeAttachments; const checkoutStatusQuery = useCheckoutStatusQuery({ serverId, cwd }); - const supportsForgeSearch = useSessionStore( - (state) => state.sessions[serverId]?.serverInfo?.features?.forgeSearch === true, - ); + const supportsForgeSearch = useServerFeature(serverId, "forgeSearch"); const forgeAutoAttach = useComposerForgeAutoAttach({ text: userInput, remoteUrl: resolveCheckoutRemoteUrl(checkoutStatusQuery.status), @@ -1487,8 +1493,7 @@ function ComposerContentImpl({ activeTurnBehavior, activeTurnId: activeTurnBehavior === "steer" - ? (useSessionStore.getState().sessions[serverId]?.agents.get(targetAgentId)?.activeTurn - ?.turnId ?? undefined) + ? (getAgentTurnSnapshot(serverId, targetAgentId).turnId ?? undefined) : undefined, }); onAttentionPromptSend?.(); @@ -1499,25 +1504,11 @@ function ComposerContentImpl({ onSubmitMessageRef.current = onSubmitMessage; }, [onSubmitMessage]); - const hasActiveTurn = useSessionStore( - (state) => selectAgentTurnPresentation(state.sessions[serverId], agentId).isActive, - ); - const isCancellingAgent = useSessionStore( - (state) => selectAgentTurnPresentation(state.sessions[serverId], agentId).isCancelling, - ); - const beginAgentCancellation = useSessionStore((state) => state.beginAgentCancellation); - const settleAgentCancellation = useSessionStore((state) => state.settleAgentCancellation); + const hasActiveTurn = useAgentTurn(serverId, agentId, (turn) => turn.isActive); + const isCancellingAgent = useAgentTurn(serverId, agentId, (turn) => turn.isCancelling); const isAgentRunning = hasActiveTurn; // Queueing behind a permission prompt would strand the message: the turn is // parked until the request is answered. - const hasPendingPermission = useSessionStore((state) => { - const pendingPermissions = state.sessions[serverId]?.pendingPermissions; - if (!pendingPermissions) return false; - for (const permission of pendingPermissions.values()) { - if (permission.agentId === agentId) return true; - } - return false; - }); const activeSendBehavior = resolveActiveSendBehavior( appSettings.sendBehavior, hasPendingPermission, @@ -1526,10 +1517,10 @@ function ComposerContentImpl({ const queueWriter = useMemo( () => ({ - read: (id) => useSessionStore.getState().sessions[serverId]?.queuedMessages?.get(id) ?? [], + read: (id) => getQueuedMessagesSnapshot(serverId, id), write: (updater) => setQueuedMessages(serverId, updater), }), - [serverId, setQueuedMessages], + [serverId], ); const queueMessage = useCallback( @@ -1824,15 +1815,7 @@ function ComposerContentImpl({ settleAgentCancellation(serverId, targetAgentId, requestId); }); messageInputRef.current?.focus(); - }, [ - beginAgentCancellation, - client, - isAgentRunning, - isCancellingAgent, - isConnected, - serverId, - settleAgentCancellation, - ]); + }, [client, isAgentRunning, isCancellingAgent, isConnected, serverId]); const focusMessageInputForKeyboardAction = useCallback(() => { focusMessageInputWithPlatformStrategy(messageInputRef); diff --git a/packages/app/src/composer/input/input.tsx b/packages/app/src/composer/input/input.tsx index fc8a260cfb..84b3988d33 100644 --- a/packages/app/src/composer/input/input.tsx +++ b/packages/app/src/composer/input/input.tsx @@ -26,7 +26,7 @@ import { useDictation } from "@/hooks/use-dictation"; import { DictationOverlay } from "@/components/dictation-controls"; import { RealtimeVoiceOverlay } from "@/components/realtime-voice-overlay"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; -import { useSessionStore } from "@/stores/session-store"; +import { useConnection } from "@/stores/session-store-hooks"; import { useVoiceOptional } from "@/contexts/voice-context"; import { useToast } from "@/contexts/toast-context"; import { resolveVoiceUnavailableMessage } from "@/utils/server-info-capabilities"; @@ -1274,16 +1274,11 @@ export const MessageInput = forwardRef( getNativeElement: () => (isWeb ? getTextInputNativeElement(textInputRef.current) : null), })); const sendAfterTranscriptRef = useRef(false); - const serverInfo = useSessionStore( - useCallback( - (state) => { - if (!voiceServerId) { - return null; - } - return state.sessions[voiceServerId]?.serverInfo ?? null; - }, - [voiceServerId], - ), + const serverInfo = useConnection( + voiceServerId, + useCallback((connection) => { + return connection.serverInfo; + }, []), ); useEffect(() => { diff --git a/packages/app/src/composer/submission/writer.ts b/packages/app/src/composer/submission/writer.ts index f8b40034b6..6a6758359e 100644 --- a/packages/app/src/composer/submission/writer.ts +++ b/packages/app/src/composer/submission/writer.ts @@ -1,5 +1,13 @@ import type { MessageSubmissionWriter } from "@/composer/actions"; -import { useSessionStore } from "@/stores/session-store"; +import { + acceptMessageSubmission, + beginMessageSubmission, + handoffCreatedAgentSubmission, + getAgentStreamSnapshot, + hostSupports, + publishAgentStreamState, + rejectMessageSubmission, +} from "@/runtime/session-data"; import { appendSubmittedUserMessage, removeSubmittedUserMessage, @@ -7,13 +15,8 @@ import { } from "@/types/stream"; function appendUntrackedSubmission(serverId: string, agentId: string, message: UserMessageItem) { - const session = useSessionStore.getState().sessions[serverId]; - if (!session) return; - const tail = session.agentStreamTail.get(agentId) ?? []; - const head = session.agentStreamHead.get(agentId) ?? []; - useSessionStore - .getState() - .setAgentStreamState(serverId, agentId, appendSubmittedUserMessage({ tail, head, message })); + const { tail, head } = getAgentStreamSnapshot(serverId, agentId); + publishAgentStreamState(serverId, agentId, appendSubmittedUserMessage({ tail, head, message })); } function removeUntrackedSubmission( @@ -21,17 +24,12 @@ function removeUntrackedSubmission( agentId: string, clientMessageId: string, ): void { - const session = useSessionStore.getState().sessions[serverId]; - if (!session) return; - const tail = session.agentStreamTail.get(agentId) ?? []; - const head = session.agentStreamHead.get(agentId) ?? []; - useSessionStore - .getState() - .setAgentStreamState( - serverId, - agentId, - removeSubmittedUserMessage({ tail, head, clientMessageId }), - ); + const { tail, head } = getAgentStreamSnapshot(serverId, agentId); + publishAgentStreamState( + serverId, + agentId, + removeSubmittedUserMessage({ tail, head, clientMessageId }), + ); } function createUntrackedMessageSubmissionWriter(serverId: string): MessageSubmissionWriter { @@ -46,20 +44,17 @@ function createUntrackedMessageSubmissionWriter(serverId: string): MessageSubmis } export function createMessageSubmissionWriter(serverId: string): MessageSubmissionWriter { - const supportsTrackedMessageSubmissions = - useSessionStore.getState().sessions[serverId]?.serverInfo?.features - ?.canonicalSubmittedPrompts === true; + const supportsTrackedMessageSubmissions = hostSupports(serverId, "canonicalSubmittedPrompts"); if (!supportsTrackedMessageSubmissions) { // COMPAT(canonicalSubmittedPrompts): added in v0.2.6; remove the gate after 2027-01-31 once daemon floor >= v0.2.6. return createUntrackedMessageSubmissionWriter(serverId); } return { - begin: (agentId, message) => - useSessionStore.getState().beginAgentMessageSubmission(serverId, agentId, message), + begin: (agentId, message) => beginMessageSubmission(serverId, agentId, message), accept: (agentId, clientMessageId) => - useSessionStore.getState().acceptAgentMessageSubmission(serverId, agentId, clientMessageId), + acceptMessageSubmission(serverId, agentId, clientMessageId), reject: (agentId, clientMessageId) => - useSessionStore.getState().rejectAgentMessageSubmission(serverId, agentId, clientMessageId), + rejectMessageSubmission(serverId, agentId, clientMessageId), }; } @@ -68,5 +63,5 @@ export function handoffCreatedAgentMessageSubmission( agentId: string, message: UserMessageItem, ): boolean { - return useSessionStore.getState().handoffCreatedAgentUserMessage(serverId, agentId, message); + return handoffCreatedAgentSubmission(serverId, agentId, message); } diff --git a/packages/app/src/composer/workspace-diff-stat.ts b/packages/app/src/composer/workspace-diff-stat.ts index 1cef419e6b..19b2d0ed2d 100644 --- a/packages/app/src/composer/workspace-diff-stat.ts +++ b/packages/app/src/composer/workspace-diff-stat.ts @@ -1,4 +1,4 @@ -import type { WorkspaceDescriptor } from "@/stores/session-store"; +import type { WorkspaceDescriptor } from "@/stores/session-store-hooks"; import { useWorkspaceFields } from "@/stores/session-store-hooks"; type DiffStat = NonNullable; diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index 475eb26f27..f5401a36bf 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -33,14 +33,29 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { AgentSessionConfig } from "@getpaseo/protocol/agent-types"; import type { GitSetupOptions } from "@getpaseo/protocol/messages"; import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types"; -import { getHostRuntimeStore, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; +import { + createViewedTimelineOwnerForHost, + fetchAgentTimeline, + useHostRuntimeIsConnected, +} from "@/runtime/host-runtime"; +import { + applyAgentTurn, + getAgentConversationSnapshot, + getAgentSnapshot, + getAgentStreamSnapshot, + getAgentTimelineSnapshot, + getSessionFocusSnapshot, + publishAudioPlayback as setIsPlayingAudio, + publishAgentInitialization as setInitializingAgents, + advanceHistorySyncGeneration as bumpHistorySyncGeneration, + flushAgentActivity as flushAgentLastActivity, + publishPendingPermissions as setPendingPermissions, + publishServerInfo as updateSessionServerInfo, + publishViewedTimeline as setViewedTimelineSync, +} from "@/runtime/session-data"; import { useVoiceAudioEngineOptional, useVoiceRuntimeOptional } from "@/contexts/voice-context"; import type { AudioPlaybackSource } from "@/voice/audio-engine-types"; -import { - selectAgentTimelineState, - useSessionStore, - type SessionState, -} from "@/stores/session-store"; +import { useSessionFocus } from "@/stores/session-store-hooks"; import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store"; import { sendOsNotification } from "@/utils/os-notifications"; import { getIsAppActivelyVisible, getIsAppVisible } from "@/utils/app-visibility"; @@ -70,7 +85,7 @@ export type { ExplorerFileKind, ExplorerEncoding, AgentFileExplorerState, -} from "@/stores/session-store"; +} from "@/stores/session-store-hooks"; type AudioOutputPayload = Extract["payload"]; @@ -126,32 +141,6 @@ const findLatestAssistantMessageText = (items: StreamItem[]): string | null => { return null; }; -const getLatestPermissionRequest = ( - session: SessionState | undefined, - agentId: string, -): NotificationPermissionRequest | null => { - if (!session) { - return null; - } - - let latest: NotificationPermissionRequest | null = null; - for (const pending of session.pendingPermissions.values()) { - if (pending.agentId === agentId) { - latest = pending.request; - } - } - if (latest) { - return latest; - } - - const agentPending = session.agents.get(agentId)?.pendingPermissions; - if (agentPending && agentPending.length > 0) { - return agentPending[agentPending.length - 1] as NotificationPermissionRequest; - } - - return null; -}; - interface AgentAttentionNotificationInput { notification?: AgentAttentionNotificationPayload; reason: AgentAttentionReason; @@ -227,29 +216,10 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider const isConnected = useHostRuntimeIsConnected(serverId); const toast = useToast(); - // Zustand store actions - const setIsPlayingAudio = useSessionStore((state) => state.setIsPlayingAudio); - const setAgentStreamTail = useSessionStore((state) => state.setAgentStreamTail); - const setAgentStreamHead = useSessionStore((state) => state.setAgentStreamHead); - const applyAgentTurnLiveness = useSessionStore((state) => state.applyAgentTurnLiveness); - const clearAgentTurnLiveness = useSessionStore((state) => state.clearAgentTurnLiveness); - const clearAgentStreamHead = useSessionStore((state) => state.clearAgentStreamHead); - const setInitializingAgents = useSessionStore((state) => state.setInitializingAgents); - const bumpHistorySyncGeneration = useSessionStore((state) => state.bumpHistorySyncGeneration); - const setAgents = useSessionStore((state) => state.setAgents); - const flushAgentLastActivity = useSessionStore((state) => state.flushAgentLastActivity); - const setPendingPermissions = useSessionStore((state) => state.setPendingPermissions); - const updateSessionServerInfo = useSessionStore((state) => state.updateSessionServerInfo); - const setViewedTimelineSync = useSessionStore((state) => state.setViewedTimelineSync); const upsertWorkspaceSetupProgress = useWorkspaceSetupStore((state) => state.upsertProgress); // Track focused agent for heartbeat - const focusedAgentId = useSessionStore( - (state) => state.sessions[serverId]?.focusedAgentId ?? null, - ); - const focusedTerminalId = useSessionStore( - (state) => state.sessions[serverId]?.focusedTerminalId ?? null, - ); + const { focusedAgentId, focusedTerminalId } = useSessionFocus(serverId); const _sessionStateTimeoutRef = useRef | null>(null); const attentionNotifiedRef = useRef>(new Map()); const appStateRef = useRef(AppState.currentState); @@ -281,7 +251,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider bumpHistorySyncGeneration, }); }, - [bumpHistorySyncGeneration, serverId], + [serverId], ); // Client activity tracking (heartbeat, push token registration) @@ -301,8 +271,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider notification?: AgentAttentionNotificationPayload; }) => { const appState = appStateRef.current; - const session = useSessionStore.getState().sessions[serverId]; - const attentionFocusedAgentId = session?.focusedAgentId ?? null; + const attentionFocusedAgentId = getSessionFocusSnapshot(serverId).focusedAgentId; if (params.reason === "error") { return; } @@ -319,12 +288,16 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider } attentionNotifiedRef.current.set(params.agentId, timestampMs); - const head = session?.agentStreamHead.get(params.agentId) ?? []; - const tail = session?.agentStreamTail.get(params.agentId) ?? []; + const { head, tail } = getAgentStreamSnapshot(serverId, params.agentId); + const conversation = getAgentConversationSnapshot(serverId, params.agentId); + const agent = getAgentSnapshot(serverId, params.agentId); const assistantMessage = findLatestAssistantMessageText(head) ?? findLatestAssistantMessageText(tail); - const permissionRequest = getLatestPermissionRequest(session, params.agentId); - const workspaceId = session?.agents?.get(params.agentId)?.workspaceId; + const permissionRequest = + conversation.pendingPermissions.at(-1)?.request ?? + (agent?.pendingPermissions.at(-1) as NotificationPermissionRequest | undefined) ?? + null; + const workspaceId = agent?.workspaceId; const notification = resolveAgentAttentionNotification({ notification: params.notification, @@ -364,7 +337,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider ...(serverInfo.capabilities ? { capabilities: serverInfo.capabilities } : {}), ...(serverInfo.features ? { features: serverInfo.features } : {}), }); - }, [client, serverId, updateSessionServerInfo]); + }, [client, serverId]); useEffect(() => { const unregister = voiceRuntime?.registerSession({ @@ -398,7 +371,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider }, }); return () => unregister?.(); - }, [client, serverId, setIsPlayingAudio, t, voiceRuntime]); + }, [client, serverId, t, voiceRuntime]); useEffect(() => { voiceRuntime?.updateSessionConnection(serverId, isConnected); @@ -410,16 +383,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider flushAgentLastActivity(); setInitializingAgents(serverId, new Map()); } - }, [flushAgentLastActivity, serverId, isConnected, setInitializingAgents]); - - useEffect( - () => - client.subscribeConnectionStatus((connection) => { - if (connection.status === "connected") return; - clearAgentTurnLiveness(serverId); - }), - [clearAgentTurnLiveness, client, serverId], - ); + }, [serverId, isConnected]); const applyWorkspaceSetupProgress = useCallback( (payload: WorkspaceSetupProgressPayload) => { @@ -443,22 +407,18 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider const initialDeliveryMode = getTimelineDeliveryMode( client.getLastServerInfoMessage()?.features?.selectiveAgentTimeline, ); - const sync = getHostRuntimeStore().createViewedTimelineOwner(serverId, { + const sync = createViewedTimelineOwnerForHost(serverId, { initialDeliveryMode, setSubscription: (agentIds) => client.setAgentTimelineSubscription(agentIds), readCursor: (agentId) => { - const timeline = selectAgentTimelineState( - useSessionStore.getState().sessions[serverId], - agentId, - ); + const timeline = getAgentTimelineSnapshot(serverId, agentId); return timeline.status === "synced" && timeline.range ? { epoch: timeline.range.epoch, endSeq: timeline.range.endSeq } : undefined; }, fetchPage: async (agentId, request) => { - const session = useSessionStore.getState().sessions[serverId]; const initKey = getInitKey(serverId, agentId); - const shouldInitialize = selectAgentTimelineState(session, agentId).status !== "synced"; + const shouldInitialize = getAgentTimelineSnapshot(serverId, agentId).status !== "synced"; if (shouldInitialize) { if (!getInitDeferred(initKey)) { const deferred = createInitDeferred(initKey, request.direction ?? "tail"); @@ -472,7 +432,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider setAgentInitializing(agentId, true); } try { - const page = await getHostRuntimeStore().fetchAgentTimeline(serverId, agentId, request); + const page = await fetchAgentTimeline(serverId, agentId, request); if (shouldInitialize && getInitDeferred(initKey)) { refreshAgentInitializationTimeout({ key: initKey, agentId, setAgentInitializing }); } @@ -488,11 +448,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider fetchLatestTail: async (agentId) => { forcedTimelineTailReplacements.current.add(agentId); try { - return await getHostRuntimeStore().fetchAgentTimeline( - serverId, - agentId, - planTimelineTailFetch(), - ); + return await fetchAgentTimeline(serverId, agentId, planTimelineTailFetch()); } finally { forcedTimelineTailReplacements.current.delete(agentId); } @@ -516,7 +472,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider setViewedTimelineSync(serverId, null); sync.dispose(); }; - }, [client, serverId, setInitializingAgents, setViewedTimelineSync]); + }, [client, serverId]); useEffect(() => { viewedTimelineSyncRef.current?.setConnected(isConnected); @@ -544,7 +500,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider { event: streamEvent, seq, epoch, timestamp: parsedTimestamp }, ]); if (turnLiveness.length > 0) { - applyAgentTurnLiveness(serverId, agentId, turnLiveness); + applyAgentTurn(serverId, agentId, turnLiveness); } owner.enqueueStreamEvent(agentId, { event: streamEvent, @@ -574,8 +530,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider if (message.type !== "agent.timeline.replacement") return; void requestTimelineReplacement( { - fetchAgentTimeline: (agentId, request) => - getHostRuntimeStore().fetchAgentTimeline(serverId, agentId, request), + fetchAgentTimeline: (agentId, request) => fetchAgentTimeline(serverId, agentId, request), }, message.payload.agentId, ).catch((error: unknown) => { @@ -794,18 +749,9 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider client, queryClient, serverId, - setIsPlayingAudio, - setAgentStreamTail, - setAgentStreamHead, - applyAgentTurnLiveness, - clearAgentStreamHead, - setInitializingAgents, - setAgents, - setPendingPermissions, notifyAgentAttention, applyWorkspaceSetupProgress, applyTimelineResponse, - updateSessionServerInfo, toast, voiceRuntime, voiceAudioEngine, diff --git a/packages/app/src/contexts/session-status-tracking.test.ts b/packages/app/src/contexts/session-status-tracking.test.ts index 346127b1ff..dcd150d764 100644 --- a/packages/app/src/contexts/session-status-tracking.test.ts +++ b/packages/app/src/contexts/session-status-tracking.test.ts @@ -8,7 +8,7 @@ function createAgent(status: Agent["status"]): Agent { id: "agent-1", provider: "codex", status, - activeTurn: null, + turn: { phase: "idle", cancellationRequestId: null }, createdAt: new Date(0), updatedAt: new Date(0), lastUserMessageAt: null, diff --git a/packages/app/src/contexts/session-status-tracking.ts b/packages/app/src/contexts/session-status-tracking.ts index 3e56167d4a..f411ceb52a 100644 --- a/packages/app/src/contexts/session-status-tracking.ts +++ b/packages/app/src/contexts/session-status-tracking.ts @@ -1,5 +1,5 @@ import type { AgentLifecycleStatus } from "@getpaseo/protocol/agent-lifecycle"; -import type { Agent } from "@/stores/session-store"; +import type { Agent } from "@/stores/session-store-hooks"; export function reconcilePreviousAgentStatuses( previousStatuses: Map, diff --git a/packages/app/src/contexts/session-workspace-upserts.ts b/packages/app/src/contexts/session-workspace-upserts.ts index 1b4279631f..97108b962a 100644 --- a/packages/app/src/contexts/session-workspace-upserts.ts +++ b/packages/app/src/contexts/session-workspace-upserts.ts @@ -1,4 +1,4 @@ -import type { WorkspaceDescriptor } from "@/stores/session-store"; +import type { WorkspaceDescriptor } from "@/stores/session-store-hooks"; import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity"; interface PendingWorkspaceArchive { diff --git a/packages/app/src/contexts/voice-context.tsx b/packages/app/src/contexts/voice-context.tsx index 7bb0b9b7e1..6547a198ef 100644 --- a/packages/app/src/contexts/voice-context.tsx +++ b/packages/app/src/contexts/voice-context.tsx @@ -8,7 +8,7 @@ import { type ReactNode, } from "react"; import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake"; -import { useSessionStore } from "@/stores/session-store"; +import { readServerInfo } from "@/runtime/session-data"; import { createAudioEngine } from "@/voice/audio-engine"; import type { AudioEngine } from "@/voice/audio-engine-types"; import { @@ -137,8 +137,7 @@ export function VoiceProvider({ children }: VoiceProviderProps) { runtime = createVoiceRuntime({ engine, - getServerInfo: (serverId) => - useSessionStore.getState().getSession(serverId)?.serverInfo ?? null, + getServerInfo: readServerInfo, activateKeepAwake: async (tag) => { await activateKeepAwakeAsync(tag); }, diff --git a/packages/app/src/contexts/workspace-directory-reconciliation.ts b/packages/app/src/contexts/workspace-directory-reconciliation.ts index 4be9cdd9ff..d81adf3683 100644 --- a/packages/app/src/contexts/workspace-directory-reconciliation.ts +++ b/packages/app/src/contexts/workspace-directory-reconciliation.ts @@ -1,5 +1,8 @@ import type { SessionOutboundMessage } from "@getpaseo/protocol/messages"; -import { normalizeWorkspaceDescriptor, type WorkspaceDescriptor } from "@/stores/session-store"; +import { + normalizeWorkspaceDescriptor, + type WorkspaceDescriptor, +} from "@/stores/session-store-hooks"; import { shouldSuppressWorkspaceForLocalArchive } from "./session-workspace-upserts"; type WorkspaceDelta = Extract["payload"]; diff --git a/packages/app/src/desktop/hooks/use-built-in-daemon-management.ts b/packages/app/src/desktop/hooks/use-built-in-daemon-management.ts index 16076b4d6d..8e33d651c6 100644 --- a/packages/app/src/desktop/hooks/use-built-in-daemon-management.ts +++ b/packages/app/src/desktop/hooks/use-built-in-daemon-management.ts @@ -17,8 +17,7 @@ import { } from "@/desktop/daemon/daemon-management-error"; import { useDesktopIpcErrorReporter } from "@/desktop/hooks/desktop-ipc-error"; import type { DesktopSettings } from "@/desktop/settings/desktop-settings"; -import { getHostRuntimeStore } from "@/runtime/host-runtime"; -import { upsertDesktopDaemonConnection } from "@/runtime/daemon-start-service"; +import { registerDesktopDaemonConnection } from "@/runtime/daemon-start-service"; import { confirmDialog } from "@/utils/confirm-dialog"; type DesktopDaemonSettings = DesktopSettings["daemon"]; @@ -68,10 +67,7 @@ export function useBuiltInDaemonManagement( stopDaemon: () => stopDesktopDaemon("settings"), }); if (result.kind === "enabled") { - const upsertResult = await upsertDesktopDaemonConnection( - getHostRuntimeStore(), - result.newStatus, - ); + const upsertResult = await registerDesktopDaemonConnection(result.newStatus); if (!upsertResult.ok) { throw new DaemonConnectionRegistrationError(upsertResult.error); } diff --git a/packages/app/src/file-pane/pane.tsx b/packages/app/src/file-pane/pane.tsx index a5630ed82f..e56bca3027 100644 --- a/packages/app/src/file-pane/pane.tsx +++ b/packages/app/src/file-pane/pane.tsx @@ -13,7 +13,8 @@ import { ScrollView as RNScrollView, Text, View } from "react-native"; import { StyleSheet, UnistylesRuntime, withUnistyles } from "react-native-unistyles"; import { useTranslation } from "react-i18next"; import { useIsCompactFormFactor } from "@/constants/layout"; -import { useSessionStore, type ExplorerFile } from "@/stores/session-store"; +import { useHostRuntimeClient } from "@/runtime/host-runtime"; +import { useServerFeature, type ExplorerFile } from "@/stores/session-store-hooks"; import { filePreviewRenderKind } from "@/components/file-pane-render-mode"; import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url"; import { getFileNameFromPath } from "@/attachments/utils"; @@ -234,11 +235,9 @@ export function FilePane({ const isMobile = useIsCompactFormFactor(); const [previewMode, setPreviewMode] = useState<"preview" | "source">("preview"); - const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null); + const client = useHostRuntimeClient(serverId); // COMPAT(workspaceFileEditing): added in v0.2.0, remove after 2027-01-18 once daemon floor >= v0.2.0. - const supportsEditing = useSessionStore( - (state) => state.sessions[serverId]?.serverInfo?.features?.workspaceFileEditing === true, - ); + const supportsEditing = useServerFeature(serverId, "workspaceFileEditing"); const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]); const normalizedFilePath = useMemo(() => trimNonEmpty(location.path), [location.path]); const readTarget = useMemo( diff --git a/packages/app/src/git/actions-store.ts b/packages/app/src/git/actions-store.ts index 0e38f04811..30705461c0 100644 --- a/packages/app/src/git/actions-store.ts +++ b/packages/app/src/git/actions-store.ts @@ -1,7 +1,8 @@ import type { CheckoutPrMergeMethod } from "@getpaseo/protocol/messages"; import { create } from "zustand"; import { queryClient as appQueryClient } from "@/data/query-client"; -import { useSessionStore } from "@/stores/session-store"; +import { getHostClient } from "@/runtime/host-runtime"; +import { hostSupports } from "@/runtime/session-data"; import { invalidateCheckoutGitQueriesForClient } from "@/git/query-keys"; import { i18n } from "@/i18n/i18next"; @@ -35,8 +36,7 @@ function checkoutKey(serverId: string, cwd: string): CheckoutKey { } function resolveClient(serverId: string) { - const session = useSessionStore.getState().sessions[serverId]; - const client = session?.client ?? null; + const client = getHostClient(serverId); if (!client) { throw new Error(i18n.t("common.errors.daemonClientUnavailable")); } @@ -46,14 +46,13 @@ function resolveClient(serverId: string) { type AutoMergeActionsRpc = "forge" | "github"; function resolveAutoMergeActionsRpc(serverId: string): AutoMergeActionsRpc { - const session = useSessionStore.getState().sessions[serverId]; - if (session?.serverInfo?.features?.checkoutForgeSetAutoMerge === true) { + if (hostSupports(serverId, "checkoutForgeSetAutoMerge")) { return "forge"; } // COMPAT(githubAutoMergeRpc): use the legacy GitHub RPC with daemons that // predate checkout.forge.set_auto_merge.*. Remove after 2027-01-17 once the // supported daemon floor is >= v0.2.0. - if (session?.serverInfo?.features?.checkoutGithubSetAutoMerge === true) { + if (hostSupports(serverId, "checkoutGithubSetAutoMerge")) { return "github"; } throw new Error("Update the host to use auto-merge actions."); diff --git a/packages/app/src/git/diff-pane.tsx b/packages/app/src/git/diff-pane.tsx index b934d56b25..e372f59f48 100644 --- a/packages/app/src/git/diff-pane.tsx +++ b/packages/app/src/git/diff-pane.tsx @@ -72,7 +72,8 @@ import { parseGitRemoteLocation } from "@getpaseo/protocol/git-remote"; import type { ForgeAuthState } from "@getpaseo/protocol/messages"; import { useCheckoutGitActionsStore } from "@/git/actions-store"; import { useToast } from "@/contexts/toast-context"; -import { useSessionStore } from "@/stores/session-store"; +import { useHostRuntimeClient } from "@/runtime/host-runtime"; +import { useServerFeature } from "@/stores/session-store-hooks"; import { confirmDialog } from "@/utils/confirm-dialog"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { Button } from "@/components/ui/button"; @@ -143,9 +144,7 @@ function useDiscardChangesAction({ const toast = useToast(); const discardChanges = useCheckoutGitActionsStore((state) => state.discardChanges); // COMPAT(checkoutDiscardChanges): added in v0.3.0, remove gate after 2027-02-08. - const discardSupported = useSessionStore( - (s) => s.sessions[serverId]?.serverInfo?.features?.checkoutDiscardChanges === true, - ); + const discardSupported = useServerFeature(serverId, "checkoutDiscardChanges"); const discardPath = useCallback( async (path: string, oldPath?: string) => { const confirmed = await confirmDialog({ @@ -1632,14 +1631,10 @@ export function ChangesSurface({ isMobile, pullRequestOpenLocation: appSettings.pullRequestOpenLocation, }); - const refreshSupported = useSessionStore( - (s) => s.sessions[serverId]?.serverInfo?.features?.checkoutRefresh === true, - ); - const client = useSessionStore((state) => state.sessions[serverId]?.client); + const refreshSupported = useServerFeature(serverId, "checkoutRefresh"); + const client = useHostRuntimeClient(serverId); // COMPAT(fsEntryDuplicate): added in v0.3.0, remove gate after 2027-02-09. - const fsEntryDuplicateEnabled = useSessionStore( - (state) => state.sessions[serverId]?.serverInfo?.features?.fsEntryDuplicate === true, - ); + const fsEntryDuplicateEnabled = useServerFeature(serverId, "fsEntryDuplicate"); const runRefresh = useCheckoutGitActionsStore((s) => s.refresh); const isRefreshing = useCheckoutGitActionsStore((s) => s.getStatus({ serverId, cwd, actionId: "refresh" })) === @@ -1696,9 +1691,7 @@ export function ChangesSurface({ cwd, enabled: isGit, }); - const forgeProvidersSupported = useSessionStore( - (s) => s.sessions[serverId]?.serverInfo?.features?.forgeProviders === true, - ); + const forgeProvidersSupported = useServerFeature(serverId, "forgeProviders"); const forgeSetupAction = computeForgeSetupAction({ forge, forgeProvidersSupported, diff --git a/packages/app/src/git/pull-request-panel/pane.tsx b/packages/app/src/git/pull-request-panel/pane.tsx index b7c0890c3a..b3b184fef9 100644 --- a/packages/app/src/git/pull-request-panel/pane.tsx +++ b/packages/app/src/git/pull-request-panel/pane.tsx @@ -31,7 +31,7 @@ import { MarkdownRenderer } from "@/components/markdown/renderer"; import { getDefaultMarkdownClipboardEnvironment } from "@/utils/rich-clipboard-default-environment"; import { writeMarkdownToRichClipboard } from "@/utils/rich-clipboard"; import { useHostRuntimeClient } from "@/runtime/host-runtime"; -import { useSessionStore } from "@/stores/session-store"; +import { useServerFeature } from "@/stores/session-store-hooks"; import { useWorkspaceAttachmentsStore } from "@/attachments/workspace-attachments-store"; import { useToast } from "@/contexts/toast-context"; import { useCheckoutGitActionsStore } from "@/git/actions-store"; @@ -209,15 +209,9 @@ export function PullRequestPane({ // COMPAT(githubCheckDetailsRpc): recognize the legacy capability on daemons // predating checkout.forge.get_check_details.*. Remove after 2027-01-17 once // the supported daemon floor is >= v0.2.0. - const canFetchGitHubCheckDetails = useSessionStore( - (state) => state.sessions[serverId]?.serverInfo?.features?.githubCheckDetails === true, - ); - const canFetchForgeCheckDetails = useSessionStore( - (state) => state.sessions[serverId]?.serverInfo?.features?.forgeCheckDetails === true, - ); - const forgeProvidersEnabled = useSessionStore( - (state) => state.sessions[serverId]?.serverInfo?.features?.forgeProviders === true, - ); + const canFetchGitHubCheckDetails = useServerFeature(serverId, "githubCheckDetails"); + const canFetchForgeCheckDetails = useServerFeature(serverId, "forgeCheckDetails"); + const forgeProvidersEnabled = useServerFeature(serverId, "forgeProviders"); const addWorkspaceAttachment = useWorkspaceAttachmentsStore( (state) => state.addWorkspaceAttachment, ); @@ -230,9 +224,7 @@ export function PullRequestPane({ void openExternalUrl(data.url); }, [data.url]); - const refreshSupported = useSessionStore( - (state) => state.sessions[serverId]?.serverInfo?.features?.checkoutRefresh === true, - ); + const refreshSupported = useServerFeature(serverId, "checkoutRefresh"); const runRefresh = useCheckoutGitActionsStore((state) => state.refresh); const isRefreshing = useCheckoutGitActionsStore((state) => diff --git a/packages/app/src/git/use-actions.tsx b/packages/app/src/git/use-actions.tsx index 05e65a21a5..4df7bfbbf6 100644 --- a/packages/app/src/git/use-actions.tsx +++ b/packages/app/src/git/use-actions.tsx @@ -21,7 +21,12 @@ import { deriveMergeCapability } from "@/git/merge-capability"; import type { CheckoutPrMergeMethod } from "@getpaseo/protocol/messages"; import { openExternalUrl } from "@/utils/open-external-url"; import { useToast } from "@/contexts/toast-context"; -import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store"; +import { + useServerFeature, + useWorkspaceByDirectoryFields, + useWorkspaceFields, + type WorkspaceDescriptor, +} from "@/stores/session-store-hooks"; import { useActiveWorkspaceSelection, type ActiveWorkspaceSelection, @@ -29,7 +34,6 @@ import { import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect"; import { type WorktreeArchiveWarningLabels } from "@/git/worktree-archive-warning"; import { useWorkspaceArchive } from "@/workspace/use-workspace-archive"; -import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity"; import { readValidatedString } from "@/storage/validated-storage"; export type { GitActionId, GitAction, GitActions } from "@/git/policy"; @@ -216,33 +220,13 @@ interface UseWorkspaceScreenArchiveControllerInput { t: (key: string, options?: Record) => string; } -function resolveArchiveWorkspaceDescriptor(input: { - workspaces: Map | undefined; - activeWorkspaceSelection: ActiveWorkspaceSelection | null; - workspaceDirectory: string | null | undefined; -}): WorkspaceDescriptor | null { - const activeWorkspaceKey = input.activeWorkspaceSelection - ? resolveWorkspaceMapKeyByIdentity({ - workspaces: input.workspaces, - workspaceId: input.activeWorkspaceSelection.workspaceId, - }) - : null; - if (activeWorkspaceKey) { - return input.workspaces?.get(activeWorkspaceKey) ?? null; - } - if (!input.workspaceDirectory) { - return null; - } - for (const candidate of input.workspaces?.values() ?? []) { - if (candidate.workspaceDirectory === input.workspaceDirectory) { - return candidate; - } - } - return null; -} +type ArchiveWorkspaceProjection = Pick< + WorkspaceDescriptor, + "id" | "workspaceKind" | "name" | "diffStat" | "archivingAt" | "gitRuntime" +>; function resolveWorkspaceArchiveRisk( - workspace: WorkspaceDescriptor | null, + workspace: ArchiveWorkspaceProjection | null, gitStatus: CheckoutStatusPayload | null, ): { isDirty: boolean | null | undefined; aheadOfOrigin: number | null | undefined } { return { @@ -252,7 +236,7 @@ function resolveWorkspaceArchiveRisk( } function canArchiveWorkspace( - workspace: WorkspaceDescriptor | null, + workspace: ArchiveWorkspaceProjection | null, risk: ReturnType, ): boolean { return ( @@ -270,17 +254,29 @@ function useWorkspaceScreenArchiveController({ gitStatus, t, }: UseWorkspaceScreenArchiveControllerInput) { - const sessionWorkspaces = useSessionStore((state) => state.sessions[serverId]?.workspaces); const [isHidingWorkspace, setIsHidingWorkspace] = useState(false); - const workspaceDescriptor = useMemo( - () => - resolveArchiveWorkspaceDescriptor({ - workspaces: sessionWorkspaces, - activeWorkspaceSelection, - workspaceDirectory, - }), - [activeWorkspaceSelection, sessionWorkspaces, workspaceDirectory], + const projectArchiveFields = useCallback( + (workspace: WorkspaceDescriptor) => ({ + id: workspace.id, + workspaceKind: workspace.workspaceKind, + name: workspace.name, + diffStat: workspace.diffStat, + archivingAt: workspace.archivingAt, + gitRuntime: workspace.gitRuntime, + }), + [], ); + const selectedWorkspace = useWorkspaceFields( + serverId, + activeWorkspaceSelection?.workspaceId ?? null, + projectArchiveFields, + ); + const directoryWorkspace = useWorkspaceByDirectoryFields( + serverId, + workspaceDirectory, + projectArchiveFields, + ); + const workspaceDescriptor = selectedWorkspace ?? directoryWorkspace; const archiveRisk = resolveWorkspaceArchiveRisk(workspaceDescriptor, gitStatus); const controller = useWorkspaceArchive({ @@ -453,11 +449,9 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use const runDisablePrAutoMerge = useCheckoutGitActionsStore((s) => s.disablePrAutoMerge); const runMergeBranch = useCheckoutGitActionsStore((s) => s.mergeBranch); const runMergeFromBase = useCheckoutGitActionsStore((s) => s.mergeFromBase); - const githubAutoMergeActionsEnabled = useSessionStore( - (s) => - s.sessions[serverId]?.serverInfo?.features?.checkoutForgeSetAutoMerge === true || - s.sessions[serverId]?.serverInfo?.features?.checkoutGithubSetAutoMerge === true, - ); + const supportsForgeAutoMerge = useServerFeature(serverId, "checkoutForgeSetAutoMerge"); + const supportsGithubAutoMerge = useServerFeature(serverId, "checkoutGithubSetAutoMerge"); + const githubAutoMergeActionsEnabled = supportsForgeAutoMerge || supportsGithubAutoMerge; const toastActionError = useCallback( (error: unknown, fallback: string) => { diff --git a/packages/app/src/git/use-commits-query.ts b/packages/app/src/git/use-commits-query.ts index bb77f50942..b369f93c3b 100644 --- a/packages/app/src/git/use-commits-query.ts +++ b/packages/app/src/git/use-commits-query.ts @@ -4,7 +4,7 @@ import { useRetainedPanelActive } from "@/components/retained-panel"; import { useFetchQuery } from "@/data/query"; import { checkoutCommitsQueryKey } from "@/git/query-keys"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; -import { useSessionStore } from "@/stores/session-store"; +import { useServerFeature } from "@/stores/session-store-hooks"; // Commits ahead of base change rarely while the section is open; this keeps a // collapse/re-expand cycle warm without leaving the fetch result stale for long. @@ -80,11 +80,9 @@ export function useCheckoutCommitsQuery({ // COMPAT(commitsList): added in v0.1.110, remove after 2027-01-16. // COMPAT(commitBaseClassification): added in v0.2.0, remove after 2027-01-23. // Single capability-detection site; downstream reads a clean load-state union. - const capabilityPresent = useSessionStore( - (state) => - state.sessions[serverId]?.serverInfo?.features?.commitsList === true && - state.sessions[serverId]?.serverInfo?.features?.commitBaseClassification === true, - ); + const supportsCommitsList = useServerFeature(serverId, "commitsList"); + const supportsBaseClassification = useServerFeature(serverId, "commitBaseClassification"); + const capabilityPresent = supportsCommitsList && supportsBaseClassification; const canFetch = Boolean(cwd) && Boolean(client) && isConnected; const queryEnabled = queryEnabledByCaller && capabilityPresent && canFetch; diff --git a/packages/app/src/hooks/open-project.ts b/packages/app/src/hooks/open-project.ts index aae61c8b1d..03f53f471b 100644 --- a/packages/app/src/hooks/open-project.ts +++ b/packages/app/src/hooks/open-project.ts @@ -4,7 +4,7 @@ import type { ProjectAddResponse, WorkspaceProjectDescriptorPayload, } from "@getpaseo/protocol/messages"; -import { normalizeProjectDescriptor, type ProjectDescriptor } from "@/stores/session-store"; +import { normalizeProjectDescriptor, type ProjectDescriptor } from "@/stores/session-store-hooks"; type OpenProjectPayload = ProjectAddResponse["payload"]; type OpenProjectErrorCode = NonNullable; diff --git a/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts b/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts index f9d192dbb9..01d5c6d6cd 100644 --- a/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts +++ b/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts @@ -701,7 +701,7 @@ function agent(input: { id: input.id, provider: "claude" as Agent["provider"], status: input.status, - activeTurn: null, + turn: { phase: "idle", cancellationRequestId: null }, createdAt: new Date(0), updatedAt: input.updatedAt ?? new Date(1_000), lastUserMessageAt: null, diff --git a/packages/app/src/hooks/sidebar-workspaces-view-model.ts b/packages/app/src/hooks/sidebar-workspaces-view-model.ts index 842844cdc2..6a6f1ae7fd 100644 --- a/packages/app/src/hooks/sidebar-workspaces-view-model.ts +++ b/packages/app/src/hooks/sidebar-workspaces-view-model.ts @@ -2,7 +2,7 @@ import type { PrHint } from "@/git/pr-hint"; import { selectPrHintFromStatus } from "@/git/pr-hint"; import { type HostProjectListItem } from "@/projects/host-project-model"; import type { PendingCreateAttempt } from "@/stores/create-flow-store"; -import type { WorkspaceDescriptor } from "@/stores/session-store"; +import type { WorkspaceDescriptor } from "@/stores/session-store-hooks"; import type { WorkspaceStructureHostPlacement, WorkspaceStructureProject, @@ -71,13 +71,13 @@ export interface SidebarWorkspacePlacementModel { export interface SidebarWorkspaceSession { serverId: string; - workspaces: Map; - workspaceAgentActivity: Map; + workspaces: ReadonlyMap; + workspaceAgentActivity: ReadonlyMap; } interface SidebarWorkspaceSessionSource { - workspaces: Map; - workspaceAgentActivity: Map; + workspaces: ReadonlyMap; + workspaceAgentActivity: ReadonlyMap; } export function selectSidebarWorkspaceSessions( @@ -232,8 +232,8 @@ function getPendingInitialAgentCreateStartedAt(input: { } export interface ProjectStatusSession { - workspaces: Map; - workspaceAgentActivity: Map; + workspaces: ReadonlyMap; + workspaceAgentActivity: ReadonlyMap; } /** diff --git a/packages/app/src/hooks/use-agent-autocomplete.ts b/packages/app/src/hooks/use-agent-autocomplete.ts index 5ab515d8b1..386e055dd1 100644 --- a/packages/app/src/hooks/use-agent-autocomplete.ts +++ b/packages/app/src/hooks/use-agent-autocomplete.ts @@ -10,7 +10,7 @@ import { } from "./use-agent-commands-query"; import { orderAutocompleteOptions } from "@/components/ui/autocomplete-utils"; import { useAutocomplete } from "./use-autocomplete"; -import { useSessionStore } from "@/stores/session-store"; +import { useActiveAgentFields } from "@/stores/session-store-hooks"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { CLIENT_SLASH_COMMANDS, type ClientSlashCommand } from "@/client-slash-commands"; import type { PluginClientSlashCommand } from "@/plugins/client-slash-commands"; @@ -389,9 +389,7 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut const queryDraftConfig = normalizedDraftConfig; const canLoadCommands = resolveCanLoadCommands({ serverId, agentId, isDraftContext }); - const agentCwd = useSessionStore( - (state) => state.sessions[serverId]?.agents?.get(agentId)?.cwd ?? "", - ); + const agentCwd = useActiveAgentFields(serverId, agentId, (agent) => agent.cwd, Object.is) ?? ""; const autocompleteCwd = useMemo(() => { if (isDraftContext) { return queryDraftConfig?.cwd ?? ""; diff --git a/packages/app/src/hooks/use-agent-history.ts b/packages/app/src/hooks/use-agent-history.ts index b24c5f8ee7..fb80c9f5cd 100644 --- a/packages/app/src/hooks/use-agent-history.ts +++ b/packages/app/src/hooks/use-agent-history.ts @@ -5,11 +5,17 @@ import type { } from "@getpaseo/client/internal/daemon-client"; import type { AgentSearchMatch } from "@getpaseo/protocol/messages"; import { useInfiniteQuery } from "@tanstack/react-query"; -import { useCallback, useMemo, useSyncExternalStore } from "react"; +import { useCallback, useMemo } from "react"; import { useTranslation } from "react-i18next"; import type { AggregatedAgent } from "@/hooks/use-aggregated-agents"; -import { getHostRuntimeStore, isHostRuntimeConnected, useHosts } from "@/runtime/host-runtime"; -import { useSessionStore } from "@/stores/session-store"; +import { + getHostClient, + isHostRuntimeConnected, + readHostRuntimeSnapshot, + useHostRuntimeVersion, + useHosts, +} from "@/runtime/host-runtime"; +import { useConnections } from "@/stores/session-store-hooks"; import { buildAgentDirectoryState } from "@/utils/agent-directory-sync"; import { agentHistoryQueryKey, allAgentHistoryQueryKey } from "./agent-history-query-key"; @@ -134,6 +140,7 @@ export async function fetchAgentHistoryPage(input: { serverLabel: input.serverId, title: agent.title ?? null, status: agent.status, + turn: agent.turn, lastActivityAt: agent.lastActivityAt, cwd: agent.cwd, workspaceId: agent.workspaceId, @@ -298,12 +305,7 @@ export function useAgentHistory(options: { }): AgentHistoryResult { const { t } = useTranslation(); const daemons = useHosts(); - const runtime = getHostRuntimeStore(); - const runtimeVersion = useSyncExternalStore( - (onStoreChange) => runtime.subscribeAll(onStoreChange), - () => runtime.getVersion(), - () => runtime.getVersion(), - ); + const runtimeVersion = useHostRuntimeVersion(); const serverId = useMemo(() => { const value = options.serverId; return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; @@ -321,8 +323,8 @@ export function useAgentHistory(options: { const unreachable: AgentHistoryHostError[] = []; for (const targetServerId of serverIds) { - const snapshot = runtime.getSnapshot(targetServerId); - const client = runtime.getClient(targetServerId); + const snapshot = readHostRuntimeSnapshot(targetServerId); + const client = getHostClient(targetServerId); const serverName = serverLabelById.get(targetServerId) ?? targetServerId; if (!client || !isHostRuntimeConnected(snapshot)) { unreachable.push({ serverId: targetServerId, serverName }); @@ -332,17 +334,18 @@ export function useAgentHistory(options: { } return { targetHosts: hosts, unreachableHosts: unreachable }; - }, [daemons, runtime, runtimeVersion, serverId]); + }, [daemons, runtimeVersion, serverId]); const targetServerIds = useMemo(() => targetHosts.map((host) => host.serverId), [targetHosts]); // One gate, checked before the field is offered: a fleet where any host // predates search has no search, rather than a list that silently omits that // host's sessions. - const isSearchSupported = useSessionStore( + const isSearchSupported = useConnections( + targetServerIds, useCallback( - (state) => + (connections) => targetServerIds.length > 0 && targetServerIds.every( - (id) => state.sessions[id]?.serverInfo?.features?.agentHistorySearch === true, + (id) => connections.get(id)?.serverInfo?.features?.agentHistorySearch === true, ), [targetServerIds], ), diff --git a/packages/app/src/hooks/use-agent-initialization.ts b/packages/app/src/hooks/use-agent-initialization.ts index a19f36bf20..22f44c7950 100644 --- a/packages/app/src/hooks/use-agent-initialization.ts +++ b/packages/app/src/hooks/use-agent-initialization.ts @@ -1,7 +1,7 @@ import { useCallback, useMemo } from "react"; import { useTranslation } from "react-i18next"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; -import { selectAgentTimelineState, useSessionStore } from "@/stores/session-store"; +import { getAgentTimelineSnapshot, publishAgentInitialization } from "@/runtime/session-data"; import { createInitDeferred, getInitDeferred, @@ -10,7 +10,7 @@ import { rejectInitDeferred, refreshInitTimeout, } from "@/utils/agent-initialization"; -import { getHostRuntimeStore, type HostRuntimeStore } from "@/runtime/host-runtime"; +import { agentTimelineRuntime } from "@/runtime/host-runtime"; import { planTimelineResumeFetch, planTimelineTailFetch } from "@/timeline/timeline-sync-plan"; import { i18n } from "@/i18n/i18next"; @@ -38,7 +38,7 @@ export interface EnsureAgentIsInitializedInput { serverId: string; agentId: string; client: Pick | null; - runtime: Pick; + runtime: typeof agentTimelineRuntime; setAgentInitializing: SetAgentInitializing; hostDisconnectedMessage?: string; } @@ -51,7 +51,7 @@ export function ensureAgentIsInitialized(input: EnsureAgentIsInitializedInput): return existing.promise; } - const timeline = selectAgentTimelineState(useSessionStore.getState().sessions[serverId], agentId); + const timeline = getAgentTimelineSnapshot(serverId, agentId); const timelineRequest = planTimelineResumeFetch( timeline.status === "synced" ? timeline.range : null, ); @@ -82,7 +82,7 @@ export interface RefreshAgentInput { serverId: string; agentId: string; client: Pick | null; - runtime: Pick; + runtime: typeof agentTimelineRuntime; setAgentInitializing: SetAgentInitializing; hostDisconnectedMessage?: string; } @@ -105,7 +105,7 @@ export async function refreshAgent(input: RefreshAgentInput): Promise { export function createSetAgentInitializing( serverId: string, - setInitializingAgents: ReturnType["setInitializingAgents"], + setInitializingAgents: typeof publishAgentInitialization, ): SetAgentInitializing { return (agentId, initializing) => { setInitializingAgents(serverId, (prev) => { @@ -127,10 +127,9 @@ export function useAgentInitialization({ client: DaemonClient | null; }) { const { t } = useTranslation(); - const setInitializingAgents = useSessionStore((state) => state.setInitializingAgents); const setAgentInitializing = useMemo( - () => createSetAgentInitializing(serverId, setInitializingAgents), - [serverId, setInitializingAgents], + () => createSetAgentInitializing(serverId, publishAgentInitialization), + [serverId], ); const ensureAgentIsInitializedCallback = useCallback( @@ -139,7 +138,7 @@ export function useAgentInitialization({ serverId, agentId, client, - runtime: getHostRuntimeStore(), + runtime: agentTimelineRuntime, setAgentInitializing, hostDisconnectedMessage: t("workspace.terminal.hostDisconnected"), }), @@ -152,7 +151,7 @@ export function useAgentInitialization({ serverId, agentId, client, - runtime: getHostRuntimeStore(), + runtime: agentTimelineRuntime, setAgentInitializing, hostDisconnectedMessage: t("workspace.terminal.hostDisconnected"), }), diff --git a/packages/app/src/hooks/use-agent-screen-state-machine.test.ts b/packages/app/src/hooks/use-agent-screen-state-machine.test.ts index a7ed05e906..6c188d3e54 100644 --- a/packages/app/src/hooks/use-agent-screen-state-machine.test.ts +++ b/packages/app/src/hooks/use-agent-screen-state-machine.test.ts @@ -17,7 +17,12 @@ function createAgent(id: string): Agent { id, provider: "claude", status: "running", - activeTurn: { turnId: "turn-1", startedAt: now }, + turn: { + phase: "open", + turnId: "turn-1", + startedAt: now, + cancellationRequestId: null, + }, createdAt: now, updatedAt: now, lastUserMessageAt: now, diff --git a/packages/app/src/hooks/use-aggregated-agents.ts b/packages/app/src/hooks/use-aggregated-agents.ts index 1a016e9b13..020617e141 100644 --- a/packages/app/src/hooks/use-aggregated-agents.ts +++ b/packages/app/src/hooks/use-aggregated-agents.ts @@ -1,16 +1,38 @@ -import { useMemo, useCallback, useEffect, useRef, useSyncExternalStore } from "react"; +import { useMemo, useCallback, useEffect, useRef } from "react"; import equal from "fast-deep-equal"; -import { useShallow } from "zustand/shallow"; -import { useSessionStore } from "@/stores/session-store"; +import { useAgentDirectories } from "@/stores/session-store-hooks"; +import type { Agent } from "@/stores/session-store-hooks"; import type { AgentDirectoryEntry } from "@/types/agent-directory"; -import type { Agent } from "@/stores/session-store"; -import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime"; +import { + acquireDirectoryDemand, + readHostRuntimeSnapshot, + refreshHostDirectories, + useHostRuntimeVersion, + useHosts, +} from "@/runtime/host-runtime"; export interface AggregatedAgent extends AgentDirectoryEntry { serverId: string; serverLabel: string; } +interface AgentSource { + serverId: string; + agents: ReadonlyMap; +} + +const EMPTY_AGENT_MAP: ReadonlyMap = new Map(); + +function agentSourcesEqual(left: AgentSource[], right: AgentSource[]): boolean { + return ( + left.length === right.length && + left.every( + (source, index) => + source.serverId === right[index]?.serverId && source.agents === right[index]?.agents, + ) + ); +} + export interface AggregatedAgentsResult { agents: AggregatedAgent[]; isLoading: boolean; @@ -24,35 +46,30 @@ export function useAggregatedAgents(options?: { demand?: boolean; }): AggregatedAgentsResult { const daemons = useHosts(); - const runtime = getHostRuntimeStore(); const includeArchived = options?.includeArchived ?? false; const demand = options?.demand ?? true; const serverIds = useMemo(() => daemons.map((daemon) => daemon.serverId), [daemons]); useEffect(() => { if (!demand) return; - const releases = serverIds.map((serverId) => runtime.acquireDirectoryDemand(serverId)); + const releases = serverIds.map(acquireDirectoryDemand); return () => releases.forEach((release) => release()); - }, [demand, runtime, serverIds]); - const runtimeVersion = useSyncExternalStore( - (onStoreChange) => runtime.subscribeAll(onStoreChange), - () => runtime.getVersion(), - () => runtime.getVersion(), - ); + }, [demand, serverIds]); + const runtimeVersion = useHostRuntimeVersion(); - const sessionAgents = useSessionStore( - useShallow((state) => { - const result: Record | undefined> = {}; - for (const [serverId, session] of Object.entries(state.sessions)) { - result[serverId] = session.agents; - } - return result; - }), + const sessionAgents = useAgentDirectories( + serverIds, + (directories) => + serverIds.map((serverId) => ({ + serverId, + agents: directories.get(serverId)?.agents ?? EMPTY_AGENT_MAP, + })), + agentSourcesEqual, ); const refreshAll = useCallback(() => { if (!demand) return; - for (const serverId of serverIds) void runtime.refreshDirectories(serverId); - }, [demand, runtime, serverIds]); + for (const serverId of serverIds) void refreshHostDirectories(serverId); + }, [demand, serverIds]); // Keyed by "serverId:agentId" — reuse the previous AggregatedAgent object when // none of its fields changed, so downstream memo/shallow comparisons can bail early. @@ -70,8 +87,8 @@ export function useAggregatedAgents(options?: { ); // Derive agent directory from all sessions - for (const [serverId, agents] of Object.entries(sessionAgents)) { - if (!agents || agents.size === 0) { + for (const { serverId, agents } of sessionAgents) { + if (agents.size === 0) { continue; } const serverLabel = serverLabelById.get(serverId) ?? serverId; @@ -85,6 +102,7 @@ export function useAggregatedAgents(options?: { serverLabel, title: agent.title ?? null, status: agent.status, + turn: agent.turn, lastActivityAt: agent.lastActivityAt, cwd: agent.cwd, workspaceId: agent.workspaceId, @@ -108,8 +126,8 @@ export function useAggregatedAgents(options?: { // Sort by: running agents first, then by most recent activity allAgents.sort((left, right) => { - const leftRunning = left.status === "running"; - const rightRunning = right.status === "running"; + const leftRunning = left.turn.phase === "open"; + const rightRunning = right.turn.phase === "open"; if (leftRunning && !rightRunning) { return -1; } @@ -144,7 +162,7 @@ export function useAggregatedAgents(options?: { // Align list loading with the runtime directory-sync machine. const isLoading = daemons.some((daemon) => { const status = - runtime.getSnapshot(daemon.serverId)?.agentDirectoryStatus ?? "initial_loading"; + readHostRuntimeSnapshot(daemon.serverId)?.agentDirectoryStatus ?? "initial_loading"; return status === "initial_loading" || status === "revalidating"; }); const isInitialLoad = isLoading && !hasAnyData; @@ -156,7 +174,7 @@ export function useAggregatedAgents(options?: { isInitialLoad, isRevalidating, }; - }, [daemons, includeArchived, runtime, runtimeVersion, sessionAgents]); + }, [daemons, includeArchived, runtimeVersion, sessionAgents]); return { ...result, diff --git a/packages/app/src/hooks/use-archive-agent.test.ts b/packages/app/src/hooks/use-archive-agent.test.ts index cd4d433f74..d6112644ff 100644 --- a/packages/app/src/hooks/use-archive-agent.test.ts +++ b/packages/app/src/hooks/use-archive-agent.test.ts @@ -41,7 +41,7 @@ function makeAgent(overrides: Partial = {}): Agent { labels: {}, archivedAt: null, ...overrides, - activeTurn: overrides.activeTurn ?? null, + turn: overrides.turn ?? { phase: "idle", cancellationRequestId: null }, }; } diff --git a/packages/app/src/hooks/use-archive-agent.ts b/packages/app/src/hooks/use-archive-agent.ts index 437ed6df49..66ed312635 100644 --- a/packages/app/src/hooks/use-archive-agent.ts +++ b/packages/app/src/hooks/use-archive-agent.ts @@ -7,7 +7,12 @@ import { type QueryKey, } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; -import { useSessionStore } from "@/stores/session-store"; +import { getActiveAgentSnapshot } from "@/runtime/session-data"; +import { + archiveAgentSnapshot, + restoreAgentSnapshot as restoreStoredAgent, +} from "@/runtime/session-data"; +import { getHostClient } from "@/runtime/host-runtime"; import { agentHistoryQueryKey, allAgentHistoryQueryRootKey } from "./agent-history-query-key"; export const ARCHIVE_AGENT_PENDING_QUERY_KEY = ["archive-agent-pending"] as const; @@ -241,33 +246,13 @@ interface ArchiveAgentMutationContext { } function getStoredAgentSnapshot(input: ArchiveAgentInput) { - return useSessionStore.getState().sessions[input.serverId]?.agents.get(input.agentId); + return getActiveAgentSnapshot(input.serverId, input.agentId) ?? undefined; } function restoreAgentSnapshot( input: ArchiveAgentInput & { agent: ReturnType }, ): void { - const setAgents = useSessionStore.getState().setAgents; - setAgents(input.serverId, (prev) => { - const hasAgent = prev.has(input.agentId); - if (!input.agent) { - if (!hasAgent) { - return prev; - } - const next = new Map(prev); - next.delete(input.agentId); - return next; - } - - const current = prev.get(input.agentId); - if (current === input.agent) { - return prev; - } - - const next = new Map(prev); - next.set(input.agentId, input.agent); - return next; - }); + restoreStoredAgent(input.serverId, input.agentId, input.agent); } function getArchivedAgentListCacheSnapshot( @@ -324,22 +309,7 @@ function markAgentArchivedInStore(input: ArchiveAgentInput & { archivedAt: strin return; } - const setAgents = useSessionStore.getState().setAgents; - setAgents(input.serverId, (prev) => { - const existing = prev.get(input.agentId); - if (!existing) { - return prev; - } - if (existing.archivedAt && existing.archivedAt.getTime() === archivedAt.getTime()) { - return prev; - } - const next = new Map(prev); - next.set(input.agentId, { - ...existing, - archivedAt, - }); - return next; - }); + archiveAgentSnapshot(input.serverId, input.agentId, archivedAt.toISOString()); } interface ApplyArchivedAgentCloseResultsInput { @@ -413,7 +383,7 @@ export function useArchiveAgent() { const archiveMutation = useMutation({ mutationFn: async (input: ArchiveAgentInput): Promise<{ archivedAt: string }> => { - const client = useSessionStore.getState().sessions[input.serverId]?.client ?? null; + const client = getHostClient(input.serverId); if (!client) { throw new Error(t("common.errors.daemonClientUnavailable")); } diff --git a/packages/app/src/hooks/use-clear-workspace-attention.ts b/packages/app/src/hooks/use-clear-workspace-attention.ts index 0ba74c97e6..0c4d3253d5 100644 --- a/packages/app/src/hooks/use-clear-workspace-attention.ts +++ b/packages/app/src/hooks/use-clear-workspace-attention.ts @@ -1,7 +1,7 @@ import { useCallback, useMemo } from "react"; import { i18n } from "@/i18n/i18next"; -import { getHostRuntimeStore } from "@/runtime/host-runtime"; -import { useSessionStore } from "@/stores/session-store"; +import { getHostClient } from "@/runtime/host-runtime"; +import { useWorkspaceFields } from "@/stores/session-store-hooks"; export interface ClearWorkspaceAttentionController { hasClearableAttention: boolean; @@ -15,16 +15,18 @@ export function useClearWorkspaceAttention({ serverId: string; workspaceId: string; }): ClearWorkspaceAttentionController { - const hasClearableAttention = useSessionStore((state) => { - const workspace = state.sessions[serverId]?.workspaces.get(workspaceId); - return workspace?.status === "attention" || workspace?.status === "failed"; - }); + const hasClearableAttention = + useWorkspaceFields( + serverId, + workspaceId, + (workspace) => workspace.status === "attention" || workspace.status === "failed", + ) ?? false; const clearAttention = useCallback(async () => { if (!hasClearableAttention) { return; } - const client = getHostRuntimeStore().getClient(serverId); + const client = getHostClient(serverId); if (!client) { throw new Error(i18n.t("workspace.terminal.hostDisconnected")); } diff --git a/packages/app/src/hooks/use-favicon-status.ts b/packages/app/src/hooks/use-favicon-status.ts index 5fcce9a24c..12b954cb16 100644 --- a/packages/app/src/hooks/use-favicon-status.ts +++ b/packages/app/src/hooks/use-favicon-status.ts @@ -27,7 +27,7 @@ const FAVICON_IMAGES: Record["agents"], ): FaviconStatus { - const hasRunning = agents.some((agent) => agent.status === "running"); + const hasRunning = agents.some((agent) => agent.turn.phase === "open"); if (hasRunning) { return "running"; } diff --git a/packages/app/src/hooks/use-file-explorer-actions.ts b/packages/app/src/hooks/use-file-explorer-actions.ts index 53714e37b3..4bbfd3eccd 100644 --- a/packages/app/src/hooks/use-file-explorer-actions.ts +++ b/packages/app/src/hooks/use-file-explorer-actions.ts @@ -1,10 +1,8 @@ import { useCallback, useMemo } from "react"; import { useTranslation } from "react-i18next"; -import { - useSessionStore, - type AgentFileExplorerState, - type ExplorerDirectory, -} from "@/stores/session-store"; +import { type AgentFileExplorerState, type ExplorerDirectory } from "@/stores/session-store-hooks"; +import { publishFileExplorer as setFileExplorer } from "@/runtime/session-data"; +import { useHostRuntimeClient } from "@/runtime/host-runtime"; import { explorerFileFromReadResult } from "@/file-explorer/read-result"; import { parentExplorerPath } from "@/utils/explorer-paths"; @@ -59,8 +57,7 @@ export function buildWorkspaceExplorerStateKey(scope: FileExplorerWorkspaceScope export function useFileExplorerActions(params: { serverId: string } & FileExplorerWorkspaceScope) { const { t } = useTranslation(); const { serverId, workspaceId, workspaceRoot } = params; - const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null); - const setFileExplorer = useSessionStore((state) => state.setFileExplorer); + const client = useHostRuntimeClient(serverId); const normalizedWorkspaceRoot = useMemo( () => normalizeWorkspaceValue(workspaceRoot), [workspaceRoot], @@ -86,7 +83,7 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor return next; }); }, - [serverId, setFileExplorer, workspaceStateKey], + [serverId, workspaceStateKey], ); const requestDirectoryListing = useCallback( diff --git a/packages/app/src/hooks/use-fork-agent.ts b/packages/app/src/hooks/use-fork-agent.ts index f2cdf3f911..d567fa256c 100644 --- a/packages/app/src/hooks/use-fork-agent.ts +++ b/packages/app/src/hooks/use-fork-agent.ts @@ -12,7 +12,7 @@ import { useStableEvent } from "@/hooks/use-stable-event"; import { useHostFeature } from "@/runtime/host-features"; import { generateDraftId } from "@/stores/draft-keys"; import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store"; -import { useSessionStore } from "@/stores/session-store"; +import { useHostRuntimeClient } from "@/runtime/host-runtime"; import { buildDraftWorkspaceAttachmentScopeKey, useWorkspaceAttachmentsStore, @@ -129,7 +129,7 @@ export function useForkAgent( const { serverId, toast, readOnly = false } = input; const { t } = useTranslation(); const router = useRouter(); - const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null); + const client = useHostRuntimeClient(serverId); const supportsAgentForkContext = useHostFeature(serverId, "agentForkContext") && !readOnly; return useStableEvent(async ({ agentId, agent, workspaceId, target, boundary }) => { diff --git a/packages/app/src/hooks/use-is-dictation-ready.ts b/packages/app/src/hooks/use-is-dictation-ready.ts index ee74ee3886..cad33d2c05 100644 --- a/packages/app/src/hooks/use-is-dictation-ready.ts +++ b/packages/app/src/hooks/use-is-dictation-ready.ts @@ -1,7 +1,7 @@ import { useCallback } from "react"; import type { HostRuntimeAgentDirectoryStatus } from "@/runtime/host-runtime"; -import { useSessionStore } from "@/stores/session-store"; +import { useConnection } from "@/stores/session-store-hooks"; import { getVoiceReadinessState } from "@/utils/server-info-capabilities"; function isLegacyDictationReady(agentDirectoryStatus: HostRuntimeAgentDirectoryStatus): boolean { @@ -21,19 +21,16 @@ export function useIsDictationReady({ isConnected: boolean; agentDirectoryStatus: HostRuntimeAgentDirectoryStatus; }): boolean { - const dictationCapabilityEnabled = useSessionStore( - useCallback( - (state) => { - const serverInfo = state.sessions[serverId]?.serverInfo ?? null; - return ( - getVoiceReadinessState({ - serverInfo, - mode: "dictation", - })?.enabled ?? null - ); - }, - [serverId], - ), + const dictationCapabilityEnabled = useConnection( + serverId, + useCallback(({ serverInfo }) => { + return ( + getVoiceReadinessState({ + serverInfo, + mode: "dictation", + })?.enabled ?? null + ); + }, []), ); if (!isConnected) { diff --git a/packages/app/src/hooks/use-load-older-agent-history.ts b/packages/app/src/hooks/use-load-older-agent-history.ts index 2bc3f5c9df..af4a3f3a5a 100644 --- a/packages/app/src/hooks/use-load-older-agent-history.ts +++ b/packages/app/src/hooks/use-load-older-agent-history.ts @@ -2,13 +2,13 @@ import { useCallback } from "react"; import { useTranslation } from "react-i18next"; import type { ToastApi } from "@/components/toast-host"; import { i18n } from "@/i18n/i18next"; +import { useAgentTimeline, type AgentTimelineCursorState } from "@/stores/session-store-hooks"; import { - selectAgentTimelineState, - useSessionStore, - type AgentTimelineCursorState, -} from "@/stores/session-store"; + getAgentTimelineLoadSnapshot, + publishOlderTimelineFetch as setOlderFetchInFlight, +} from "@/runtime/session-data"; import { planTimelineOlderFetch } from "@/timeline/timeline-sync-plan"; -import { getHostRuntimeStore } from "@/runtime/host-runtime"; +import { fetchAgentTimeline, getHostClient } from "@/runtime/host-runtime"; export interface LoadOlderAgentHistoryClient { fetchAgentTimeline: ( @@ -78,22 +78,15 @@ export function useLoadOlderAgentHistory({ toast?: ToastApi | null; }) { const { t } = useTranslation(); - const hasOlder = useSessionStore((state) => { - const timeline = selectAgentTimelineState(state.sessions[serverId], agentId); - return timeline.status === "synced" && timeline.older === "available"; - }); - const isLoadingOlder = - useSessionStore((state) => - state.sessions[serverId]?.agentTimelineOlderFetchInFlight.get(agentId), - ) === true; - const progressKey = useSessionStore((state) => { - const timeline = selectAgentTimelineState(state.sessions[serverId], agentId); + const timelineState = useAgentTimeline(serverId, agentId, (timeline, meta) => { const cursor = timeline.status === "synced" ? timeline.range : null; - return cursor ? `${cursor.epoch}:${cursor.startSeq}` : null; + return { + hasOlder: timeline.status === "synced" && timeline.older === "available", + isLoadingOlder: meta.isLoadingOlder, + progressKey: cursor ? `${cursor.epoch}:${cursor.startSeq}` : null, + }; }); - const setOlderFetchInFlight = useSessionStore( - (state) => state.setAgentTimelineOlderFetchInFlight, - ); + const { hasOlder, isLoadingOlder, progressKey } = timelineState; const setInFlight = useCallback( (value: boolean) => { @@ -106,22 +99,24 @@ export function useLoadOlderAgentHistory({ return next; }); }, - [agentId, serverId, setOlderFetchInFlight], + [agentId, serverId], ); const loadOlder = useCallback(async (): Promise => { - const session = useSessionStore.getState().sessions[serverId]; - const timeline = selectAgentTimelineState(session, agentId); + const { timeline, isLoadingOlder: currentIsLoadingOlder } = getAgentTimelineLoadSnapshot( + serverId, + agentId, + ); return await loadOlderAgentHistory(agentId, { - client: session?.client + client: getHostClient(serverId) ? { fetchAgentTimeline: (timelineAgentId, request) => - getHostRuntimeStore().fetchAgentTimeline(serverId, timelineAgentId, request), + fetchAgentTimeline(serverId, timelineAgentId, request), } : null, cursor: timeline.status === "synced" ? (timeline.range ?? undefined) : undefined, hasOlder: timeline.status === "synced" && timeline.older === "available", - isLoadingOlder: session?.agentTimelineOlderFetchInFlight.get(agentId) === true, + isLoadingOlder: currentIsLoadingOlder, setInFlight, toast, failedMessage: t("loadOlderHistory.failed"), diff --git a/packages/app/src/hooks/use-open-project.ts b/packages/app/src/hooks/use-open-project.ts index 71a0a78da2..3bd546a9e4 100644 --- a/packages/app/src/hooks/use-open-project.ts +++ b/packages/app/src/hooks/use-open-project.ts @@ -1,6 +1,10 @@ import { useCallback } from "react"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; -import { useSessionStore } from "@/stores/session-store"; +import { useServerFeature } from "@/stores/session-store-hooks"; +import { + acceptProjectSnapshot as upsertProject, + publishWorkspaceHydration as setHasHydratedWorkspaces, +} from "@/runtime/session-data"; import { cloneGithubProjectDirectly, openProjectDirectly, @@ -14,14 +18,12 @@ export function useOpenProject( const normalizedServerId = serverId?.trim() ?? ""; const client = useHostRuntimeClient(normalizedServerId); const isConnected = useHostRuntimeIsConnected(normalizedServerId); - const canAddProject = useSessionStore((state) => - normalizedServerId - ? state.sessions[normalizedServerId]?.serverInfo?.features?.projectAdd === true && - state.sessions[normalizedServerId]?.serverInfo?.features?.stableProjectIdentity === true - : false, + const supportsProjectAdd = useServerFeature(normalizedServerId, "projectAdd"); + const supportsStableProjectIdentity = useServerFeature( + normalizedServerId, + "stableProjectIdentity", ); - const upsertProject = useSessionStore((state) => state.upsertProject); - const setHasHydratedWorkspaces = useSessionStore((state) => state.setHasHydratedWorkspaces); + const canAddProject = supportsProjectAdd && supportsStableProjectIdentity; return useCallback( async (path: string) => { @@ -36,14 +38,7 @@ export function useOpenProject( }); return result; }, - [ - upsertProject, - canAddProject, - client, - isConnected, - normalizedServerId, - setHasHydratedWorkspaces, - ], + [canAddProject, client, isConnected, normalizedServerId], ); } @@ -57,8 +52,6 @@ export function useCloneGithubProject( const normalizedServerId = serverId?.trim() ?? ""; const client = useHostRuntimeClient(normalizedServerId); const isConnected = useHostRuntimeIsConnected(normalizedServerId); - const upsertProject = useSessionStore((state) => state.upsertProject); - const setHasHydratedWorkspaces = useSessionStore((state) => state.setHasHydratedWorkspaces); return useCallback( async (repo: string, targetDirectory: string, cloneProtocol?: ProjectGithubCloneProtocol) => { @@ -73,6 +66,6 @@ export function useCloneGithubProject( setHasHydratedWorkspaces, }); }, - [client, isConnected, normalizedServerId, setHasHydratedWorkspaces, upsertProject], + [client, isConnected, normalizedServerId], ); } diff --git a/packages/app/src/hooks/use-projects.ts b/packages/app/src/hooks/use-projects.ts index ecf44a91b2..fca8207fff 100644 --- a/packages/app/src/hooks/use-projects.ts +++ b/packages/app/src/hooks/use-projects.ts @@ -1,17 +1,20 @@ -import { useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from "react"; +import { useCallback, useEffect, useMemo, useRef } from "react"; import equal from "fast-deep-equal"; -import { useStoreWithEqualityFn } from "zustand/traditional"; import { - getHostRuntimeStore, + acquireDirectoryDemand, isHostRuntimeDirectoryLoading, + readHostRuntimeSnapshot, + refreshHostDirectories, + useHostRuntimeVersion, useHosts, type HostRuntimeSnapshot, } from "@/runtime/host-runtime"; import { - useSessionStore, + useWorkspaceDirectories, + type WorkspaceDirectoryView, type ProjectDescriptor, type WorkspaceDescriptor, -} from "@/stores/session-store"; +} from "@/stores/session-store-hooks"; import { buildProjects, type ProjectHost, type ProjectSummary } from "@/utils/projects"; export interface ProjectHostError { @@ -73,23 +76,41 @@ function toProjectHostRuntimeState( }; } -function selectProjectHostReplicas( +function createProjectHostReplicasSelector( hosts: readonly { serverId: string; label: string }[], enabled: boolean, -): (state: ReturnType) => ProjectHostReplica[] { - if (!enabled) { - return () => EMPTY_PROJECT_HOST_REPLICAS; - } - return (state) => - hosts.map((host) => { - const session = state.sessions[host.serverId]; - return { - serverId: host.serverId, - serverName: host.label, - workspaces: Array.from(session?.workspaces.values() ?? []), - projects: Array.from(session?.projects.values() ?? []), - }; +): (directories: ReadonlyMap) => ProjectHostReplica[] { + let previousInputs: Array<{ + workspaces: WorkspaceDirectoryView["workspaces"] | undefined; + projects: WorkspaceDirectoryView["projects"] | undefined; + }> | null = null; + let previousResult = EMPTY_PROJECT_HOST_REPLICAS; + return (directories) => { + if (!enabled) return EMPTY_PROJECT_HOST_REPLICAS; + const inputs = hosts.map((host) => { + const directory = directories.get(host.serverId); + return { workspaces: directory?.workspaces, projects: directory?.projects }; }); + const priorInputs = previousInputs; + if ( + priorInputs && + inputs.every( + (input, index) => + input.workspaces === priorInputs[index]?.workspaces && + input.projects === priorInputs[index]?.projects, + ) + ) { + return previousResult; + } + previousInputs = inputs; + previousResult = hosts.map((host, index) => ({ + serverId: host.serverId, + serverName: host.label, + workspaces: Array.from(inputs[index]?.workspaces?.values() ?? []), + projects: Array.from(inputs[index]?.projects?.values() ?? []), + })); + return previousResult; + }; } export function deriveProjectsFromReplica(input: { @@ -134,15 +155,8 @@ function useProjectHostRuntimeStates( serverIds: readonly string[], enabled: boolean, ): ProjectHostRuntimeState[] { - const runtime = getHostRuntimeStore(); const previousStatesRef = useRef([]); - const subscribe = useCallback( - (onStoreChange: () => void) => - enabled ? runtime.subscribeAll(onStoreChange) : () => undefined, - [enabled, runtime], - ); - const getSnapshot = useCallback(() => (enabled ? runtime.getVersion() : 0), [enabled, runtime]); - const runtimeSnapshotTick = useSyncExternalStore(subscribe, getSnapshot, getSnapshot); + const runtimeSnapshotTick = useHostRuntimeVersion(); return useMemo(() => { if (!enabled) { previousStatesRef.current = EMPTY_PROJECT_HOST_RUNTIME_STATES; @@ -150,33 +164,32 @@ function useProjectHostRuntimeStates( } void runtimeSnapshotTick; const nextStates = serverIds.map((serverId) => - toProjectHostRuntimeState(serverId, runtime.getSnapshot(serverId)), + toProjectHostRuntimeState(serverId, readHostRuntimeSnapshot(serverId)), ); if (equal(previousStatesRef.current, nextStates)) { return previousStatesRef.current; } previousStatesRef.current = nextStates; return nextStates; - }, [enabled, runtime, runtimeSnapshotTick, serverIds]); + }, [enabled, runtimeSnapshotTick, serverIds]); } export function useProjects(options: UseProjectsOptions = {}): UseProjectsResult { const enabled = options.enabled ?? true; const hosts = useHosts(); - const runtime = getHostRuntimeStore(); const serverIds = useMemo( () => (enabled ? hosts.map((host) => host.serverId) : []), [enabled, hosts], ); useEffect(() => { - const releases = serverIds.map((serverId) => runtime.acquireDirectoryDemand(serverId)); + const releases = serverIds.map(acquireDirectoryDemand); return () => releases.forEach((release) => release()); - }, [runtime, serverIds]); - const replicaSelector = useMemo( - () => selectProjectHostReplicas(hosts, enabled), + }, [serverIds]); + const selectReplicas = useMemo( + () => createProjectHostReplicasSelector(hosts, enabled), [enabled, hosts], ); - const replicas = useStoreWithEqualityFn(useSessionStore, replicaSelector, equal); + const replicas = useWorkspaceDirectories(serverIds, selectReplicas); const runtimeStates = useProjectHostRuntimeStates(serverIds, enabled); const derived = useMemo( () => deriveProjectsFromReplica({ replicas, runtimeStates }), @@ -184,8 +197,8 @@ export function useProjects(options: UseProjectsOptions = {}): UseProjectsResult ); const refetch = useCallback(() => { if (!enabled) return; - for (const serverId of serverIds) void runtime.refreshDirectories(serverId); - }, [enabled, runtime, serverIds]); + for (const serverId of serverIds) void refreshHostDirectories(serverId); + }, [enabled, serverIds]); return { ...derived, diff --git a/packages/app/src/hooks/use-providers-snapshot.ts b/packages/app/src/hooks/use-providers-snapshot.ts index 52fff6e36a..b04ec8025b 100644 --- a/packages/app/src/hooks/use-providers-snapshot.ts +++ b/packages/app/src/hooks/use-providers-snapshot.ts @@ -5,7 +5,7 @@ import type { AgentProvider, ProviderSnapshotEntry } from "@getpaseo/protocol/ag import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { useRetainedPanelActive } from "@/components/retained-panel"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; -import { useSessionStore } from "@/stores/session-store"; +import { useServerFeature } from "@/stores/session-store-hooks"; import { useReplicaQuery } from "@/data/query"; import { queryClient as singletonQueryClient } from "@/data/query-client"; import { @@ -138,9 +138,7 @@ export function useProvidersSnapshot( const enabled = (options.enabled ?? true) && retainedPanelActive; const isConnected = useHostRuntimeIsConnected(serverId ?? ""); const cwd = normalizeProvidersSnapshotCwd(options.cwd); - const supportsSnapshot = useSessionStore( - (state) => state.sessions[serverId ?? ""]?.serverInfo?.features?.providersSnapshot === true, - ); + const supportsSnapshot = useServerFeature(serverId, "providersSnapshot"); const queryKey = useMemo(() => providersSnapshotQueryKey(serverId, cwd), [cwd, serverId]); diff --git a/packages/app/src/hooks/use-schedule-mutations.ts b/packages/app/src/hooks/use-schedule-mutations.ts index 9c0ff9d196..794e2ad656 100644 --- a/packages/app/src/hooks/use-schedule-mutations.ts +++ b/packages/app/src/hooks/use-schedule-mutations.ts @@ -17,7 +17,7 @@ import type { FetchAggregatedSchedulesState, } from "@/schedules/aggregated-schedules"; import { schedulesQueryBaseKey } from "@/schedules/aggregated-schedules"; -import { useSessionStore } from "@/stores/session-store"; +import { getHostClient } from "@/runtime/host-runtime"; export type CreateScheduleInput = Omit; export type UpdateScheduleInput = Omit; @@ -52,7 +52,7 @@ export function updateAggregatedSchedulesData( } function requireClient(serverId: string, unavailableMessage: string): DaemonClient { - const client = useSessionStore.getState().sessions[serverId]?.client ?? null; + const client = getHostClient(serverId); if (!client) { throw new Error(unavailableMessage); } diff --git a/packages/app/src/hooks/use-schedules.ts b/packages/app/src/hooks/use-schedules.ts index e83d87c1a9..5d2cd52d19 100644 --- a/packages/app/src/hooks/use-schedules.ts +++ b/packages/app/src/hooks/use-schedules.ts @@ -1,7 +1,7 @@ import { useMemo } from "react"; import { useFetchQuery } from "@/data/query"; import { - getHostRuntimeStore, + scheduleRuntime, useHostRuntimeConnectionStatuses, useHosts, } from "@/runtime/host-runtime"; @@ -35,7 +35,6 @@ export interface UseSchedulesResult { export function useSchedules(): UseSchedulesResult { const hosts = useHosts(); - const runtime = getHostRuntimeStore(); const hostInputs = useMemo( () => hosts.map((host) => ({ serverId: host.serverId, serverName: host.label })), [hosts], @@ -49,7 +48,7 @@ export function useSchedules(): UseSchedulesResult { const query = useFetchQuery({ queryKey: [...schedulesQueryKey(serverIds), connectionStatusKey], - queryFn: () => fetchAggregatedSchedules({ hosts: hostInputs, runtime }), + queryFn: () => fetchAggregatedSchedules({ hosts: hostInputs, runtime: scheduleRuntime }), dataShape: "list", staleTimeMs: 5_000, }); diff --git a/packages/app/src/hooks/use-sidebar-pins.ts b/packages/app/src/hooks/use-sidebar-pins.ts index 7b88990812..f9bc8e4908 100644 --- a/packages/app/src/hooks/use-sidebar-pins.ts +++ b/packages/app/src/hooks/use-sidebar-pins.ts @@ -1,12 +1,10 @@ import { useMemo, useRef } from "react"; -import { shallow } from "zustand/shallow"; -import { useStoreWithEqualityFn } from "zustand/traditional"; import type { SidebarProjectEntry, SidebarWorkspacePlacement, } from "@/hooks/use-sidebar-workspaces-list"; import { applyStoredOrdering } from "@/hooks/sidebar-workspaces-view-model"; -import { useSessionStore } from "@/stores/session-store"; +import { useWorkspaceDirectories } from "@/stores/session-store-hooks"; export interface PinnedSidebarKeys { pinnedWorkspaceKeys: string[]; @@ -83,30 +81,25 @@ export function usePinnedSidebarKeys(projects: SidebarProjectEntry[]): PinnedSid ), [projects], ); - const workspaceMaps = useStoreWithEqualityFn( - useSessionStore, - (state) => serverIds.map((serverId) => state.sessions[serverId]?.workspaces ?? null), - shallow, - ); - return useMemo(() => { - const workspaceMapByServerId = new Map< - string, - ReadonlyMap - >(); - for (let index = 0; index < serverIds.length; index += 1) { - const serverId = serverIds[index]; - const workspaceMap = workspaceMaps[index]; - if (serverId && workspaceMap) { - workspaceMapByServerId.set(serverId, workspaceMap); + return useWorkspaceDirectories( + serverIds, + (directories) => { + const workspaceMapByServerId = new Map< + string, + ReadonlyMap + >(); + for (const [serverId, directory] of directories) { + workspaceMapByServerId.set(serverId, directory.workspaces); } - } - const nextKeys = buildPinnedSidebarKeys(projects, workspaceMapByServerId); - if (arePinnedSidebarKeysEqual(previousKeysRef.current, nextKeys)) { - return previousKeysRef.current; - } - previousKeysRef.current = nextKeys; - return nextKeys; - }, [projects, serverIds, workspaceMaps]); + const nextKeys = buildPinnedSidebarKeys(projects, workspaceMapByServerId); + if (arePinnedSidebarKeysEqual(previousKeysRef.current, nextKeys)) { + return previousKeysRef.current; + } + previousKeysRef.current = nextKeys; + return nextKeys; + }, + arePinnedSidebarKeysEqual, + ); } // Splits the sidebar into a dedicated Pinned section (chats) and the regular list below. diff --git a/packages/app/src/hooks/use-sidebar-workspace-entries.ts b/packages/app/src/hooks/use-sidebar-workspace-entries.ts index c2eb65ac76..b3ddb30cbb 100644 --- a/packages/app/src/hooks/use-sidebar-workspace-entries.ts +++ b/packages/app/src/hooks/use-sidebar-workspace-entries.ts @@ -1,7 +1,6 @@ import { useMemo, useRef } from "react"; -import { useStoreWithEqualityFn } from "zustand/traditional"; import { useCreateFlowStore } from "@/stores/create-flow-store"; -import { useSessionStore } from "@/stores/session-store"; +import { useWorkspaceDirectories } from "@/stores/session-store-hooks"; import { areSidebarWorkspaceSessionsEqual, buildSidebarWorkspaceEntries, @@ -23,10 +22,26 @@ export function useSidebarWorkspaceEntries( () => Array.from(new Set(placements.map((placement) => placement.serverId))), [placements], ); - const sessions = useStoreWithEqualityFn( - useSessionStore, - (state) => - enabled ? selectSidebarWorkspaceSessions(state.sessions, serverIds) : EMPTY_SESSIONS, + const sessions = useWorkspaceDirectories( + serverIds, + (directories) => + enabled + ? selectSidebarWorkspaceSessions( + Object.fromEntries( + serverIds.map((serverId) => { + const directory = directories.get(serverId); + return [ + serverId, + { + workspaces: directory?.workspaces ?? new Map(), + workspaceAgentActivity: directory?.workspaceAgentActivity ?? new Map(), + }, + ]; + }), + ), + serverIds, + ) + : EMPTY_SESSIONS, areSidebarWorkspaceSessionsEqual, ); const pendingCreateAttempts = useCreateFlowStore((state) => diff --git a/packages/app/src/hooks/use-sidebar-workspace-pin.ts b/packages/app/src/hooks/use-sidebar-workspace-pin.ts index 9edb09f8b5..f131c62506 100644 --- a/packages/app/src/hooks/use-sidebar-workspace-pin.ts +++ b/packages/app/src/hooks/use-sidebar-workspace-pin.ts @@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next"; import { useMutation } from "@tanstack/react-query"; import { useToast } from "@/contexts/toast-context"; import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list"; -import { getHostRuntimeStore } from "@/runtime/host-runtime"; +import { getHostClient } from "@/runtime/host-runtime"; // Everything the pin toggle actually needs. Kept narrower than SidebarWorkspaceEntry so the // global keyboard handler can build one from the active route selection without a sidebar row. @@ -30,7 +30,7 @@ export function useSidebarWorkspacePinController(): ToggleSidebarWorkspacePin { workspace: PinnableWorkspace; pinned: boolean; }) => { - const client = getHostRuntimeStore().getClient(workspace.serverId); + const client = getHostClient(workspace.serverId); if (!client) { throw new Error(t("sidebar.workspace.toasts.hostDisconnected")); } diff --git a/packages/app/src/hooks/use-sidebar-workspaces-list.ts b/packages/app/src/hooks/use-sidebar-workspaces-list.ts index d8cc3383e5..c177653e67 100644 --- a/packages/app/src/hooks/use-sidebar-workspaces-list.ts +++ b/packages/app/src/hooks/use-sidebar-workspaces-list.ts @@ -1,11 +1,18 @@ import { useCallback, useEffect, useMemo } from "react"; import { useStoreWithEqualityFn } from "zustand/traditional"; import { useCreateFlowStore } from "@/stores/create-flow-store"; -import { useSessionStore } from "@/stores/session-store"; -import { useWorkspaceDirectoryServerIds } from "@/stores/session-store-hooks"; +import { + useWorkspaceDirectories, + useWorkspaceDirectoryServerIds, +} from "@/stores/session-store-hooks"; import { workspaceEqualityFns } from "@/stores/session-store-hooks/selectors"; import { useHostProjects } from "@/projects/host-projects"; -import { getHostRuntimeStore, useHostRegistryLoaded, useHosts } from "@/runtime/host-runtime"; +import { + acquireDirectoryDemand, + refreshHostDirectories, + useHostRegistryLoaded, + useHosts, +} from "@/runtime/host-runtime"; import { useSidebarOrderStore } from "@/stores/sidebar-order-store"; import { useSidebarViewStore } from "@/stores/sidebar-view-store"; import { @@ -64,19 +71,30 @@ export function useSidebarProjectStatusBucket(input: { workspaceEqualityFns.deep, ); - const selector = useCallback( - (state: { sessions: Record }) => { + const serverIds = useMemo( + () => Array.from(new Set(workspaces.map((workspace) => workspace.serverId))), + [workspaces], + ); + return useWorkspaceDirectories( + serverIds, + (directories) => { if (!enabled) return null; - return deriveProjectStatusBucket({ - workspaces, - sessions: state.sessions, - pendingCreateAttempts, - }); + const sessions = Object.fromEntries( + serverIds.map((serverId) => { + const directory = directories.get(serverId); + return [ + serverId, + { + workspaces: directory?.workspaces ?? new Map(), + workspaceAgentActivity: directory?.workspaceAgentActivity ?? new Map(), + } satisfies ProjectStatusSession, + ]; + }), + ); + return deriveProjectStatusBucket({ workspaces, sessions, pendingCreateAttempts }); }, - [enabled, pendingCreateAttempts, workspaces], + Object.is, ); - - return useStoreWithEqualityFn(useSessionStore, selector, Object.is); } const EMPTY_ORDER: string[] = []; @@ -98,7 +116,6 @@ export function useSidebarWorkspacesList(options?: { hostFilters?: readonly string[]; enabled?: boolean; }): SidebarWorkspacesListResult { - const runtime = getHostRuntimeStore(); const allHosts = useHosts(); const hostRegistryLoaded = useHostRegistryLoaded(); const allServerIds = useMemo(() => allHosts.map((h) => h.serverId), [allHosts]); @@ -123,9 +140,9 @@ export function useSidebarWorkspacesList(options?: { }, [allServerIds, hostFilters, hostRegistryLoaded]); useEffect(() => { if (!isActive) return; - const releases = serverIds.map((serverId) => runtime.acquireDirectoryDemand(serverId)); + const releases = serverIds.map(acquireDirectoryDemand); return () => releases.forEach((release) => release()); - }, [isActive, runtime, serverIds]); + }, [isActive, serverIds]); useEffect(() => { if (!hostRegistryLoaded) { @@ -176,14 +193,14 @@ export function useSidebarWorkspacesList(options?: { const refreshAll = useCallback(() => { if (!isActive) return; for (const serverId of serverIds) { - void runtime.refreshDirectories(serverId).catch((error) => { + void refreshHostDirectories(serverId).catch((error) => { console.error("[WorkspaceFetch][sidebar-refresh] failed", { serverId, error, }); }); } - }, [isActive, runtime, serverIds]); + }, [isActive, serverIds]); const loadingState = deriveSidebarLoadingState({ isActive, diff --git a/packages/app/src/navigation/host-runtime-bootstrap.ts b/packages/app/src/navigation/host-runtime-bootstrap.ts index 6fbceb8a21..ce97820aec 100644 --- a/packages/app/src/navigation/host-runtime-bootstrap.ts +++ b/packages/app/src/navigation/host-runtime-bootstrap.ts @@ -10,6 +10,8 @@ import { buildHostWorkspaceRoute, buildOpenProjectRoute, } from "@/utils/host-routes"; +import { getHostRuntimeStore } from "@/runtime/host-runtime"; +import { getManagedDaemonStartService } from "@/runtime/daemon-start-service"; export interface HostRuntimeBootstrapStore { boot: () => Promise; @@ -37,6 +39,14 @@ export function startHostRuntimeBootstrap(input: StartHostRuntimeBootstrapInput) }); } +export function startDefaultHostRuntimeBootstrap(shouldStartDaemon: DaemonStartCondition): void { + startHostRuntimeBootstrap({ + store: getHostRuntimeStore(), + daemonStartService: getManagedDaemonStartService(), + shouldStartDaemon, + }); +} + const WELCOME_ROUTE: Href = "/welcome"; export type StartupBlocker = diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index 254271aac3..f6e78c2ec8 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -18,8 +18,7 @@ import { StyleSheet as RNStyleSheet, Text, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import invariant from "tiny-invariant"; -import { shallow, useShallow } from "zustand/shallow"; -import { useStoreWithEqualityFn } from "zustand/traditional"; +import { useShallow } from "zustand/shallow"; import { AgentStreamView, type AgentStreamViewHandle } from "@/agent-stream/view"; import { ArchivedAgentCallout } from "@/components/archived-agent-callout"; import { KeyboardDock } from "@/components/keyboard-dock"; @@ -78,6 +77,12 @@ import { useHostRuntimeLastError, useHosts, } from "@/runtime/host-runtime"; +import { + acceptAgentSnapshot, + getAgentSnapshot, + publishAgentDetails, + replaceAgentPendingPermissions, +} from "@/runtime/session-data"; import { deriveRouteBottomAnchorIntent, deriveRouteBottomAnchorRequest, @@ -87,18 +92,19 @@ import { AgentTracks, hasAgentTracks } from "@/panels/agent-tracks"; import { useCreateFlowStore } from "@/stores/create-flow-store"; import { buildDraftStoreKey, generateDraftId } from "@/stores/draft-keys"; import { - selectAgentTimelineState, - selectAgentTurnPresentation, + useAgentFields, + useAgentConversation, + useAgentTurn, + useConnection, type Agent, - useSessionStore, -} from "@/stores/session-store"; +} from "@/stores/session-store-hooks"; import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model"; import { openWorkspaceChanges } from "@/workspace-tabs/open-supporting-view"; import { useSettings } from "@/hooks/use-settings"; import type { Theme } from "@/styles/theme"; import type { PendingPermission } from "@/types/shared"; -import type { StreamItem, TodoEntry } from "@/types/stream"; +import type { StreamItem } from "@/types/stream"; import type { ViewedTimelineStatus, ViewedTimelineUiBridge } from "@/timeline/viewed-timeline-sync"; import { useArchiveFinishedSubagents, useSubagentsForParent } from "@/subagents"; import { getInitDeferred, getInitKey } from "@/utils/agent-initialization"; @@ -134,16 +140,6 @@ interface ChatAgentSelectedState extends ChatAgentStateShape { attentionReason: Agent["attentionReason"] | null; } -function resolveChatAgentFromSession( - state: ReturnType, - serverId: string, - agentId: string | undefined, -): Agent | null { - if (!agentId) return null; - const session = state.sessions[serverId]; - return session?.agents?.get(agentId) ?? session?.agentDetails?.get(agentId) ?? null; -} - function readViewedTimelineError(input: { agentId: string | undefined; status: ViewedTimelineStatus; @@ -164,12 +160,7 @@ const EMPTY_CHAT_AGENT_STATE: ChatAgentSelectedState = { attentionReason: null, }; -function selectChatAgentState( - state: ReturnType, - serverId: string, - agentId: string | undefined, -): ChatAgentSelectedState { - const agent = resolveChatAgentFromSession(state, serverId, agentId); +function selectChatAgentState(agent: Agent | null): ChatAgentSelectedState { if (!agent) return EMPTY_CHAT_AGENT_STATE; return { serverId: agent.serverId, @@ -191,6 +182,26 @@ function selectChatAgentState( }; } +function selectAgentProjectPlacement(agent: Agent | null) { + return agent ? (agent.projectPlacement ?? null) : null; +} + +function selectReadyAgent(agent: Agent) { + return { + agentState: selectChatAgentState(agent), + projectPlacement: selectAgentProjectPlacement(agent), + }; +} + +const EMPTY_READY_AGENT = { + agentState: EMPTY_CHAT_AGENT_STATE, + projectPlacement: null, +}; + +function resolveReadyAgent(selected: ReturnType | null) { + return selected ?? EMPTY_READY_AGENT; +} + function buildChatAgentFromState( state: ChatAgentStateShape, projectPlacement: Agent["projectPlacement"] | null, @@ -307,35 +318,24 @@ function storeFetchedAgentDetail(input: { projectPlacement: input.result.project, }, }); - const store = useSessionStore.getState(); - if (shouldStoreFetchedAgentInActiveDirectory(hydrated)) { - store.setAgents(input.serverId, (previous) => { - const next = new Map(previous); - next.set(hydrated.id, hydrated); - return next; - }); + acceptAgentSnapshot(input.serverId, hydrated); } else { - store.setAgentDetails(input.serverId, (previous) => { + publishAgentDetails(input.serverId, (previous) => { const next = new Map(previous); next.set(hydrated.id, hydrated); return next; }); } - store.setPendingPermissions(input.serverId, (previous) => { - const next = new Map(previous); - for (const [key, pending] of next.entries()) { - if (pending.agentId === hydrated.id) { - next.delete(key); - } - } - for (const request of hydrated.pendingPermissions) { + replaceAgentPendingPermissions( + input.serverId, + hydrated.id, + hydrated.pendingPermissions.map((request) => { const key = derivePendingPermissionKey(hydrated.id, request); - next.set(key, { key, agentId: hydrated.id, request }); - } - return next; - }); + return { key, agentId: hydrated.id, request }; + }), + ); return hydrated; } @@ -344,24 +344,17 @@ function useAgentPanelDescriptor( target: { kind: "agent"; agentId: string }, context: { serverId: string }, ): PanelDescriptor { - const descriptorState = useSessionStore( - useShallow((state) => { - const session = state.sessions[context.serverId]; - const agent = - session?.agents?.get(target.agentId) ?? session?.agentDetails?.get(target.agentId) ?? null; - return { - provider: agent?.provider ?? "codex", - title: agent?.title ?? null, - status: agent?.status ?? null, - pendingPermissionCount: agent?.pendingPermissions.length ?? 0, - requiresAttention: agent?.requiresAttention ?? false, - attentionReason: agent?.attentionReason ?? null, - isTurnActive: selectAgentTurnPresentation(session, target.agentId).isActive, - }; - }), - ); - const provider = descriptorState.provider; - const label = resolveWorkspaceAgentTabLabel(descriptorState.title); + const descriptorState = useAgentFields(context.serverId, target.agentId, (agent) => ({ + provider: agent.provider, + title: agent.title, + status: agent.status, + pendingPermissionCount: agent.pendingPermissions.length, + requiresAttention: agent.requiresAttention ?? false, + attentionReason: agent.attentionReason ?? null, + })); + const isTurnActive = useAgentTurn(context.serverId, target.agentId, (turn) => turn.isActive); + const provider = descriptorState?.provider ?? "codex"; + const label = resolveWorkspaceAgentTabLabel(descriptorState?.title ?? null); const icon = getProviderIcon(provider, context.serverId); return { @@ -370,9 +363,9 @@ function useAgentPanelDescriptor( tooltip: label ?? `${formatProviderLabel(provider)} agent`, titleState: label ? "ready" : "loading", icon, - statusBucket: descriptorState.status + statusBucket: descriptorState?.status ? deriveSidebarStateBucket({ - status: descriptorState.isTurnActive ? "running" : descriptorState.status, + status: isTurnActive ? "running" : descriptorState.status, pendingPermissionCount: descriptorState.pendingPermissionCount, requiresAttention: descriptorState.requiresAttention, attentionReason: descriptorState.attentionReason, @@ -414,11 +407,7 @@ function DraftPanel() { (agentSnapshot: Parameters[0]) => { const normalized = normalizeAgentSnapshot(agentSnapshot, serverId); const agent = applyLegacyDaemonWorkspaceOwnership({ serverId, agent: normalized }); - useSessionStore.getState().setAgents(serverId, (prev) => { - const next = new Map(prev); - next.set(agentSnapshot.id, agent); - return next; - }); + acceptAgentSnapshot(serverId, agent); retargetCurrentTab({ kind: "agent", agentId: agentSnapshot.id }); }, [retargetCurrentTab, serverId], @@ -543,13 +532,14 @@ function AgentPanelContent({ const runtimeIsConnected = useHostRuntimeIsConnected(runtimeServerId); const runtimeConnectionStatus = useHostRuntimeConnectionStatus(runtimeServerId); const runtimeLastError = useHostRuntimeLastError(runtimeServerId); - const hasCachedAgent = useSessionStore((state) => { - if (!resolvedServerId || !resolvedAgentId) return false; - const session = state.sessions[resolvedServerId]; - return Boolean( - session?.agents.has(resolvedAgentId) || session?.agentDetails.has(resolvedAgentId), - ); - }); + const hasCachedAgent = Boolean( + useAgentFields( + resolvedServerId ?? null, + resolvedAgentId ?? null, + (agent) => agent.id, + Object.is, + ), + ); const connectionServerId = resolvedServerId ?? null; const daemon = connectionServerId @@ -611,25 +601,13 @@ function AgentPanelBody({ }) { const { t } = useTranslation(); const { isArchivingAgent: _isArchivingAgent } = useArchiveAgent(); - const hasSession = useSessionStore((state) => Boolean(state.sessions[serverId])); - const projectPlacement = useStoreWithEqualityFn( - useSessionStore, - (state) => { - if (!agentId) { - return null; - } - const session = state.sessions[serverId]; - return ( - session?.agents?.get(agentId)?.projectPlacement ?? - session?.agentDetails?.get(agentId)?.projectPlacement ?? - null - ); - }, - (a, b) => a === b || JSON.stringify(a) === JSON.stringify(b), - ); - const agentState = useSessionStore( - useShallow((state) => selectChatAgentState(state, serverId, agentId)), - ); + const hasSession = useConnection(serverId, (connection) => connection.exists); + const selected = useAgentFields(serverId, agentId, (agent) => ({ + projectPlacement: selectAgentProjectPlacement(agent), + agentState: selectChatAgentState(agent), + })); + const projectPlacement = selected?.projectPlacement ?? null; + const agentState = selected?.agentState ?? EMPTY_CHAT_AGENT_STATE; const [lookupState, setLookupState] = useState({ tag: "idle" }); const lookupAttemptTokenRef = useRef(0); const retryAgentLookup = useCallback(() => setLookupState({ tag: "idle" }), []); @@ -804,42 +782,22 @@ function ChatAgentContent({ routeKey: string; reason: "initial-entry" | "resume"; } | null>(null); - const agentState = useSessionStore( - useShallow((state) => selectChatAgentState(state, serverId, agentId)), - ); - const projectPlacement = useStoreWithEqualityFn( - useSessionStore, - (state) => { - if (!agentId) { - return null; - } - const session = state.sessions[serverId]; - return ( - session?.agents?.get(agentId)?.projectPlacement ?? - session?.agentDetails?.get(agentId)?.projectPlacement ?? - null - ); - }, - (a, b) => a === b || JSON.stringify(a) === JSON.stringify(b), - ); - const isInitializingFromMap = useSessionStore((state) => - agentId ? (state.sessions[serverId]?.initializingAgents?.get(agentId) ?? false) : false, - ); - const historySyncGeneration = useSessionStore( - (state) => state.sessions[serverId]?.historySyncGeneration ?? 0, - ); - const replicaTimelineStatus = useSessionStore((state) => - agentId - ? selectAgentTimelineState(state.sessions[serverId], agentId).status - : ("cold" as const), + const { agentState, projectPlacement } = resolveReadyAgent( + useAgentFields(serverId, agentId, selectReadyAgent), ); + const conversation = useAgentConversation(serverId, agentId, (value) => ({ + isInitializing: value.isInitializing, + historySyncGeneration: value.historySyncGeneration, + agentHistorySyncGeneration: value.agentHistorySyncGeneration, + timelineStatus: value.timeline.status, + viewedTimelineSync: value.viewedTimelineSync, + })); + const isInitializingFromMap = conversation.isInitializing; + const historySyncGeneration = conversation.historySyncGeneration; + const replicaTimelineStatus = conversation.timelineStatus; const hasAppliedAuthoritativeHistory = replicaTimelineStatus === "synced"; - const agentHistorySyncGeneration = useSessionStore((state) => - agentId ? (state.sessions[serverId]?.agentHistorySyncGeneration?.get(agentId) ?? -1) : -1, - ); - const viewedTimelineSync = useSessionStore( - (state) => state.sessions[serverId]?.viewedTimelineSync ?? null, - ); + const agentHistorySyncGeneration = conversation.agentHistorySyncGeneration; + const viewedTimelineSync = conversation.viewedTimelineSync; const subscribeToVisibilityCatchUp = useCallback( (listener: () => void) => viewedTimelineSync?.subscribe(listener) ?? (() => {}), [viewedTimelineSync], @@ -865,7 +823,7 @@ function ChatAgentContent({ const hasActiveCreateHandoff = useCreateFlowStore((state) => findActiveCreateHandoff({ pendingByDraftId: state.pendingByDraftId, serverId, agentId }), ); - const hasSession = useSessionStore((state) => Boolean(state.sessions[serverId])); + const hasSession = useConnection(serverId, (connection) => connection.exists); const [missingAgentState, setMissingAgentState] = useState({ kind: "idle", }); @@ -1117,9 +1075,7 @@ function ChatAgentContent({ if (attemptToken !== initAttemptTokenRef.current) { return; } - const currentSession = useSessionStore.getState().sessions[serverId]; - const currentAgent = - currentSession?.agents.get(agentId) ?? currentSession?.agentDetails.get(agentId); + const currentAgent = getAgentSnapshot(serverId, agentId); if (!currentAgent) { const result = await client.fetchAgent({ agentId }); if (attemptToken !== initAttemptTokenRef.current) { @@ -1270,9 +1226,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ }) { const { t } = useTranslation(); const subagentRows = useSubagentsForParent({ serverId, parentAgentId: agentId }); - const tasks = useSessionStore((state): TodoEntry[] | undefined => - state.sessions[serverId]?.agentTasks.get(agentId), - ); + const tasks = useAgentConversation(serverId, agentId, (value) => value.tasks, Object.is); const archiveFinishedSubagents = useArchiveFinishedSubagents({ serverId, parentAgentId: agentId, @@ -1485,44 +1439,21 @@ const AgentStreamSection = memo(function AgentStreamSection({ const bottomOverlayControlClearance = hasVisibleComposerTracks ? resolveComposerTrackControlClearance(isCompactFormFactor) : 0; - const streamItemsRaw = useSessionStore((state) => - agentId ? state.sessions[serverId]?.agentStreamTail?.get(agentId) : undefined, - ); - const pendingMessageSubmissions = useSessionStore( - useShallow((state) => - agentId - ? getActiveMessageSubmissions(state.sessions[serverId]?.messageSubmissions.get(agentId)) - : EMPTY_MESSAGE_SUBMISSIONS, - ), - ); - const turnPresentation = useSessionStore( - useShallow((state) => - agentId - ? selectAgentTurnPresentation(state.sessions[serverId], agentId) - : { isActive: false, isCancelling: false, startedAt: null, turnId: null }, - ), - ); + const conversation = useAgentConversation(serverId, agentId, (value) => ({ + streamItems: value.streamItems, + messageSubmissions: value.messageSubmissions, + pendingPermissions: value.pendingPermissions, + turn: value.turn, + })); + const streamItemsRaw = conversation.streamItems; + const pendingMessageSubmissions = agentId + ? getActiveMessageSubmissions(conversation.messageSubmissions) + : EMPTY_MESSAGE_SUBMISSIONS; + const turnPresentation = conversation.turn; const streamItems = streamItemsRaw ?? EMPTY_STREAM_ITEMS; - const pendingPermissionList = useStoreWithEqualityFn( - useSessionStore, - (state) => { - if (!agentId) { - return EMPTY_PENDING_PERMISSION_LIST; - } - const allPendingPermissions = state.sessions[serverId]?.pendingPermissions; - if (!allPendingPermissions) { - return EMPTY_PENDING_PERMISSION_LIST; - } - const filtered: PendingPermission[] = []; - for (const permission of allPendingPermissions.values()) { - if (permission.agentId === agentId) { - filtered.push(permission); - } - } - return filtered.length > 0 ? filtered : EMPTY_PENDING_PERMISSION_LIST; - }, - shallow, - ); + const pendingPermissionList = agentId + ? conversation.pendingPermissions + : EMPTY_PENDING_PERMISSION_LIST; const pendingPermissions = useMemo(() => { if (pendingPermissionList.length === 0) { return EMPTY_PENDING_PERMISSIONS; @@ -1665,7 +1596,7 @@ function ActiveAgentComposer({ const handleClientSlashCommand = useCallback( async (command: ClientSlashCommand) => { - const agent = resolveChatAgentFromSession(useSessionStore.getState(), serverId, agentId); + const agent = getAgentSnapshot(serverId, agentId); if (!agent) { throw new Error("Agent not found"); } diff --git a/packages/app/src/panels/agent-tracks.tsx b/packages/app/src/panels/agent-tracks.tsx index 962e095b89..2a918ea222 100644 --- a/packages/app/src/panels/agent-tracks.tsx +++ b/packages/app/src/panels/agent-tracks.tsx @@ -7,7 +7,8 @@ import { supportsDesktopPaneSplits, useIsCompactFormFactor } from "@/constants/l import { usePaneContext } from "@/panels/pane-context"; import { useSettings } from "@/hooks/use-settings"; import { PluginComposerPills } from "@/plugins"; -import { useSessionStore } from "@/stores/session-store"; +import { useServerFeature } from "@/stores/session-store-hooks"; +import { getAgentSnapshot } from "@/runtime/session-data"; import { type ArchiveFinishedStatus, useArchiveSubagent, @@ -55,15 +56,12 @@ export const AgentTracks = memo(function AgentTracks({ const canSplit = supportsDesktopPaneSplits() && !isCompact; const openInSidePane = useSettings((settings) => settings.openInSidePane); const workspaceKey = buildWorkspaceTabPersistenceKey({ serverId, workspaceId }); - const canDetachSubagents = useSessionStore( - (state) => state.sessions[serverId]?.serverInfo?.features?.agentDetach === true, - ); + const canDetachSubagents = useServerFeature(serverId, "agentDetach"); const archiveSubagent = useArchiveSubagent({ serverId }); const detachSubagent = useDetachSubagent({ serverId }); const handleOpenSubagent = useCallback( (subagentId: string) => { - const session = useSessionStore.getState().sessions[serverId]; - const agent = session?.agents.get(subagentId) ?? session?.agentDetails.get(subagentId); + const agent = getAgentSnapshot(serverId, subagentId); if (agent?.workspaceId && agent.workspaceId !== workspaceId) { navigateToAgent({ serverId, agentId: subagentId }); return; diff --git a/packages/app/src/panels/provider-subagent-panel.tsx b/packages/app/src/panels/provider-subagent-panel.tsx index 80323561ba..c93fd70032 100644 --- a/packages/app/src/panels/provider-subagent-panel.tsx +++ b/packages/app/src/panels/provider-subagent-panel.tsx @@ -14,7 +14,8 @@ import { useIsCompactFormFactor } from "@/constants/layout"; import type { AgentScreenAgent } from "@/hooks/use-agent-screen-state-machine"; import { usePaneContext } from "@/panels/pane-context"; import { definePanel, type PanelDescriptor } from "@/panels/panel-registry"; -import { useSessionStore } from "@/stores/session-store"; +import { useActiveAgentFields, useAgentFields, useConnection } from "@/stores/session-store-hooks"; +import { useHostRuntimeClient } from "@/runtime/host-runtime"; import { useSubagentsForParent } from "@/subagents/select"; import { SubagentsTrack } from "@/subagents/track"; import { @@ -82,8 +83,11 @@ function useProviderSubagentDescriptor( providerSubagentKey(context.serverId, target.parentAgentId, target.subagentId), ), ); - const parentProvider = useSessionStore( - (state) => state.sessions[context.serverId]?.agents.get(target.parentAgentId)?.provider, + const parentProvider = useActiveAgentFields( + context.serverId, + target.parentAgentId, + (agent) => agent.provider, + Object.is, ); const provider = descriptor?.provider ?? parentProvider ?? "agent"; // The task names the tab; the subagent type is supporting detail beside the provider. @@ -118,14 +122,14 @@ function ProviderSubagentPanel() { timeline: state.timelines.get(key) ?? null, })), ); - const parent = useSessionStore( - (state) => - state.sessions[serverId]?.agents.get(target.parentAgentId) ?? - state.sessions[serverId]?.agentDetails.get(target.parentAgentId) ?? - null, - ); - const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null); - const serverInfo = useSessionStore((state) => state.sessions[serverId]?.serverInfo ?? null); + const parent = useAgentFields(serverId, target.parentAgentId, (agent) => ({ + provider: agent.provider, + cwd: agent.cwd, + workspaceId: agent.workspaceId, + projectPlacement: agent.projectPlacement, + })); + const client = useHostRuntimeClient(serverId); + const serverInfo = useConnection(serverId, (connection) => connection.serverInfo); // COMPAT(providerSubagents): added in v0.2.11, remove after 2027-01-12. const supported = serverInfo?.features?.providerSubagents === true; const [isLoadingOlder, setIsLoadingOlder] = useState(false); diff --git a/packages/app/src/panels/terminal-panel.tsx b/packages/app/src/panels/terminal-panel.tsx index 32dd1f188d..0953b96215 100644 --- a/packages/app/src/panels/terminal-panel.tsx +++ b/packages/app/src/panels/terminal-panel.tsx @@ -12,8 +12,8 @@ import { definePanel, type PanelDescriptor } from "@/panels/panel-registry"; import { queryClient } from "@/data/query-client"; import { buildTerminalsQueryKey } from "@/screens/workspace/terminals/state"; import { usePanelStore } from "@/stores/panel-store"; -import { useSessionStore } from "@/stores/session-store"; import { useWorkspaceDirectory, useWorkspaceFields } from "@/stores/session-store-hooks"; +import { useHostRuntimeClient } from "@/runtime/host-runtime"; type ListTerminalsPayload = ListTerminalsResponse["payload"]; @@ -37,7 +37,7 @@ function useTerminalPanelDescriptor( context: { serverId: string; workspaceId: string }, ): PanelDescriptor { const { t } = useTranslation(); - const client = useSessionStore((state) => state.sessions[context.serverId]?.client ?? null); + const client = useHostRuntimeClient(context.serverId); const workspaceDirectory = useWorkspaceDirectory(context.serverId, context.workspaceId); const terminalsQuery = useQuery( { @@ -78,9 +78,9 @@ function useTerminalPanelDescriptor( function TerminalPanel() { const { serverId, workspaceId, target, openFileInWorkspace } = usePaneContext(); const { isWorkspaceFocused, isPaneFocused } = usePaneFocus(); - const workspaceFields = useWorkspaceFields(serverId, workspaceId, (w) => ({ - workspaceDirectory: w.workspaceDirectory, - isGitCheckout: w.projectKind === "git", + const workspaceFields = useWorkspaceFields(serverId, workspaceId, (workspace) => ({ + workspaceDirectory: workspace.workspaceDirectory, + isGitCheckout: workspace.projectKind === "git", })); const workspaceDirectory = workspaceFields?.workspaceDirectory || null; const isGitCheckout = workspaceFields?.isGitCheckout ?? false; diff --git a/packages/app/src/plugins/catalog-sync.tsx b/packages/app/src/plugins/catalog-sync.tsx index 3647334f73..5d40d2bdf7 100644 --- a/packages/app/src/plugins/catalog-sync.tsx +++ b/packages/app/src/plugins/catalog-sync.tsx @@ -11,6 +11,7 @@ export function PluginCatalogSync({ serverId: string; client: DaemonClient; }) { + const viewedTimeline = useViewedTimelineSync(serverId); const connected = useHostRuntimeIsConnected(serverId); const supported = useHostFeature(serverId, "plugins"); @@ -58,7 +59,7 @@ export function PluginCatalogSync({ cancelled = true; unsubscribe(); }; - }, [client, connected, serverId, supported]); + }, [client, connected, serverId, supported, viewedTimeline]); useEffect(() => () => pluginRegistry.removeHost(serverId), [serverId]); return null; diff --git a/packages/app/src/plugins/client-state/snapshots.test.ts b/packages/app/src/plugins/client-state/snapshots.test.ts index 26ac4069dc..cbbbaaed4b 100644 --- a/packages/app/src/plugins/client-state/snapshots.test.ts +++ b/packages/app/src/plugins/client-state/snapshots.test.ts @@ -25,7 +25,7 @@ const agent: Agent = { id: "agent-1", provider: "codex", status: "running", - activeTurn: null, + turn: { phase: "idle", cancellationRequestId: null }, createdAt: new Date("2026-08-16T10:01:00.000Z"), updatedAt: new Date("2026-08-16T10:02:00.000Z"), lastUserMessageAt: null, diff --git a/packages/app/src/plugins/command-center/registration.tsx b/packages/app/src/plugins/command-center/registration.tsx index 2ee0d24ff2..0248a07a46 100644 --- a/packages/app/src/plugins/command-center/registration.tsx +++ b/packages/app/src/plugins/command-center/registration.tsx @@ -4,10 +4,8 @@ import { useCommandCenterActions } from "@/command-center/provider"; import { useToast } from "@/contexts/toast-context"; import { useHostRuntimeClient } from "@/runtime/host-runtime"; import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; -import { useSessionStore } from "@/stores/session-store"; -import { useWorkspaceExists } from "@/stores/session-store-hooks"; +import { useAgentBelongsToWorkspace, useWorkspaceExists } from "@/stores/session-store-hooks"; import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; -import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity"; import { createPluginClientStateSource } from "../client-state/source"; import { hostIdFromPathname } from "../routes"; import { useInstalledPlugins } from "../registry"; @@ -27,17 +25,7 @@ export function PluginCommandCenterActions() { workspaceKey ? (state.layoutByWorkspace[workspaceKey] ?? null) : null, ); const focusedAgentId = getFocusedAgentId(layout); - const agentExists = useSessionStore((state) => { - if (!serverId || !focusedAgentId) return null; - const session = state.sessions[serverId]; - const agent = - session?.agents.get(focusedAgentId) ?? session?.agentDetails.get(focusedAgentId) ?? null; - return ( - Boolean(agent) && - Boolean(workspaceId) && - normalizeWorkspaceOpaqueId(agent?.workspaceId) === normalizeWorkspaceOpaqueId(workspaceId) - ); - }); + const agentExists = useAgentBelongsToWorkspace(serverId, focusedAgentId, workspaceId); const client = useHostRuntimeClient(serverId ?? ""); const installed = useInstalledPlugins(); const plugins = useMemo( diff --git a/packages/app/src/plugins/workspace-panels/panel.tsx b/packages/app/src/plugins/workspace-panels/panel.tsx index 2acca92bd2..8d31d6cb3d 100644 --- a/packages/app/src/plugins/workspace-panels/panel.tsx +++ b/packages/app/src/plugins/workspace-panels/panel.tsx @@ -14,10 +14,8 @@ import { useIsCompactFormFactor } from "@/constants/layout"; import { usePaneContext } from "@/panels/pane-context"; import { definePanel, type PanelDescriptor } from "@/panels/panel-registry"; import { useHostRuntimeClient, useHosts } from "@/runtime/host-runtime"; -import { useSessionStore } from "@/stores/session-store"; -import { useWorkspaceExists } from "@/stores/session-store-hooks"; +import { useAgentBelongsToWorkspace, useWorkspaceExists } from "@/stores/session-store-hooks"; import type { Theme } from "@/styles/theme"; -import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity"; import { usePluginHostNavigation } from "../host-navigation"; import { createPluginClientStateSource } from "../client-state/source"; import { toPluginTheme } from "../theme"; @@ -44,15 +42,11 @@ function PluginPanelBody({ theme }: { theme: PluginTheme }) { const plugin = useInstalledPlugin(serverId, target.pluginId); const contribution = resolvePluginWorkspacePanel(plugin, target); const workspaceExists = useWorkspaceExists(serverId, workspaceId); - const agentExists = useSessionStore((state) => { - if (target.context !== "agent") return null; - const session = state.sessions[serverId]; - const agent = session?.agents.get(target.agentId) ?? session?.agentDetails.get(target.agentId); - return ( - Boolean(agent) && - normalizeWorkspaceOpaqueId(agent?.workspaceId) === normalizeWorkspaceOpaqueId(workspaceId) - ); - }); + const agentExists = useAgentBelongsToWorkspace( + serverId, + target.context === "agent" ? target.agentId : null, + workspaceId, + ); const client = useHostRuntimeClient(serverId); const runtime = useMemo( () => createPluginSurfaceRuntime(client, target.pluginId), diff --git a/packages/app/src/projects/icons.ts b/packages/app/src/projects/icons.ts index ee52b2b57f..2b1042ff86 100644 --- a/packages/app/src/projects/icons.ts +++ b/packages/app/src/projects/icons.ts @@ -6,8 +6,8 @@ import { useHostFeatureAvailabilityMap } from "@/runtime/host-features"; import { projectIconCache } from "@/projects/icon-cache"; import type { ProjectIconTarget } from "@/projects/icon-target"; import { - getHostRuntimeStore, - isHostRuntimeConnected, + getHostClient, + useHostRuntimeConnectionStatuses, useHostRuntimeClient, useHostRuntimeIsConnected, } from "@/runtime/host-runtime"; @@ -80,6 +80,7 @@ export function useProjectIcons(input: { [input.projects], ); const supportsCustomIcons = useHostFeatureAvailabilityMap(serverIds, "projectCustomIcon"); + const connectionStatuses = useHostRuntimeConnectionStatuses(serverIds); const requests = useMemo(() => { const unique = new Map(); for (const project of input.projects) { @@ -95,8 +96,8 @@ export function useProjectIcons(input: { ...projectIconCache.query( request, supportsCustomIcons.get(request.serverId) ?? null, - () => getHostRuntimeStore().getClient(request.serverId), - isHostRuntimeConnected(getHostRuntimeStore().getSnapshot(request.serverId)), + () => getHostClient(request.serverId), + connectionStatuses.get(request.serverId) === "online", ), select: iconDataUri, }; diff --git a/packages/app/src/projects/project-remove.ts b/packages/app/src/projects/project-remove.ts index f80b068cf0..92744eb2ff 100644 --- a/packages/app/src/projects/project-remove.ts +++ b/packages/app/src/projects/project-remove.ts @@ -1,6 +1,5 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; -import { useSessionStore } from "@/stores/session-store"; -import { selectHostFeature } from "@/runtime/host-features"; +import { hostSupports } from "@/runtime/session-data"; interface ProjectRemoveHost { serverId: string; @@ -55,10 +54,9 @@ export function getProjectRemoveReadiness(input: { export function getCurrentProjectRemoveReadiness( project: ProjectRemoveProject, ): ProjectRemoveReadiness { - const sessionState = useSessionStore.getState(); return getProjectRemoveReadiness({ project, - supportsProjectRemove: (serverId) => selectHostFeature(sessionState, serverId, "projectRemove"), + supportsProjectRemove: (serverId) => hostSupports(serverId, "projectRemove"), }); } diff --git a/packages/app/src/provider-usage/use-provider-usage.ts b/packages/app/src/provider-usage/use-provider-usage.ts index ce133726c3..da5bad29cf 100644 --- a/packages/app/src/provider-usage/use-provider-usage.ts +++ b/packages/app/src/provider-usage/use-provider-usage.ts @@ -2,7 +2,7 @@ import { useCallback, useMemo } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; -import { useSessionStore } from "@/stores/session-store"; +import { useServerFeature } from "@/stores/session-store-hooks"; import { providerUsageCopy } from "./copy"; import type { ProviderUsageListPayload, ProviderUsageView } from "./types"; @@ -33,9 +33,7 @@ export function useProviderUsage( const queryClient = useQueryClient(); const client = useHostRuntimeClient(serverId ?? ""); const isConnected = useHostRuntimeIsConnected(serverId ?? ""); - const supportsProviderUsage = useSessionStore( - (state) => state.sessions[serverId ?? ""]?.serverInfo?.features?.providerUsageList === true, - ); + const supportsProviderUsage = useServerFeature(serverId, "providerUsageList"); const queryKey = useMemo(() => providerUsageQueryKey(serverId), [serverId]); const canFetch = Boolean(serverId && client && isConnected && supportsProviderUsage); const enabled = Boolean((options.enabled ?? true) && canFetch); diff --git a/packages/app/src/runtime/client-replica/index.ts b/packages/app/src/runtime/client-replica/index.ts new file mode 100644 index 0000000000..73344e3fad --- /dev/null +++ b/packages/app/src/runtime/client-replica/index.ts @@ -0,0 +1,85 @@ +import type { + Agent, + AgentTimelineCursorState, + ProjectDescriptor, + WorkspaceDescriptor, +} from "@/stores/session-store"; +import type { StreamItem } from "@/types/stream"; +import { ReplicaCache } from "./internal/cache"; +import { createReplicaRowStore } from "./internal/cache/row-store-factory"; + +export type DirectoryReplicaMutation = + | { kind: "agent"; type: "upsert"; id: string; value: Agent } + | { kind: "agent"; type: "delete"; id: string } + | { kind: "workspace"; type: "upsert"; id: string; value: WorkspaceDescriptor } + | { kind: "workspace"; type: "delete"; id: string } + | { kind: "project"; type: "upsert"; id: string; value: ProjectDescriptor } + | { kind: "project"; type: "delete"; id: string }; + +export interface CachedDirectory { + agents: Map; + workspaces: Map; + projects: Map; + checkpoint?: DirectoryCheckpoint; +} + +export interface CachedWorkspace { + workspace: WorkspaceDescriptor; + project?: ProjectDescriptor; +} + +export interface DirectoryCursor { + generation: string; + afterSeq: number; +} + +export interface DirectoryCheckpoint { + projects?: DirectoryCursor; + workspaces?: DirectoryCursor; + agents?: DirectoryCursor; +} + +export interface CachedTimeline { + agentId: string; + items: StreamItem[]; + range: AgentTimelineCursorState | null; + hasOlder: boolean; +} + +export interface ClientReplicaHostLifecycle { + setHosts(serverIds: readonly string[]): void; + reconcileServerId(oldServerId: string, newServerId: string): void; + flush(): Promise; +} + +export interface ClientReplicaDirectory { + readAgent(serverId: string, agentId: string): Promise; + readWorkspace(serverId: string, workspaceId: string): Promise; + readDirectory(serverId: string): Promise; + commitDirectoryMutations( + serverId: string, + mutations: readonly DirectoryReplicaMutation[], + checkpoint?: DirectoryCheckpoint, + ): void; + replaceDirectoryBaseline(serverId: string, directory: CachedDirectory): void; +} + +export interface ClientReplicaTimeline { + readTimeline(serverId: string, agentId: string): Promise; + commitTimeline(serverId: string, agentId: string, timeline: CachedTimeline): void; +} + +export interface ClientReplica { + hostLifecycle: ClientReplicaHostLifecycle; + directory: ClientReplicaDirectory; + timeline: ClientReplicaTimeline; +} + +export function createClientReplica(): ClientReplica { + const cache = new ReplicaCache(createReplicaRowStore()); + return { + hostLifecycle: cache, + directory: cache, + timeline: cache, + }; +} diff --git a/packages/app/src/runtime/replica-cache/index.test.ts b/packages/app/src/runtime/client-replica/internal/cache/index.test.ts similarity index 76% rename from packages/app/src/runtime/replica-cache/index.test.ts rename to packages/app/src/runtime/client-replica/internal/cache/index.test.ts index 56e4546507..ad5c24771b 100644 --- a/packages/app/src/runtime/replica-cache/index.test.ts +++ b/packages/app/src/runtime/client-replica/internal/cache/index.test.ts @@ -7,7 +7,8 @@ import { } from "@/stores/session-store"; import type { StreamItem } from "@/types/stream"; import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; -import { ReplicaCache, type DirectoryCheckpoint } from "."; +import { ReplicaCache } from "."; +import type { DirectoryCheckpoint } from "@/runtime/client-replica"; import type { ReplicaHostRows, ReplicaRow, ReplicaRowChanges, ReplicaRowStore } from "./row-store"; const SERVER_ID = "cached-host"; @@ -200,6 +201,45 @@ function timeline(text = "Cached") { }; } +function commitDirectory( + cache: ReplicaCache, + serverId: string, + value: ReturnType, +): void { + cache.commitDirectoryMutations( + serverId, + [ + ...Array.from(value.agents.values(), (cachedAgent) => ({ + kind: "agent" as const, + type: "upsert" as const, + id: cachedAgent.id, + value: cachedAgent, + })), + ...Array.from(value.workspaces.values(), (workspace) => ({ + kind: "workspace" as const, + type: "upsert" as const, + id: workspace.id, + value: workspace, + })), + ...Array.from(value.projects.values(), (project) => ({ + kind: "project" as const, + type: "upsert" as const, + id: project.projectId, + value: project, + })), + ], + value.checkpoint, + ); +} + +function deleteDirectory(cache: ReplicaCache, serverId: string): void { + cache.commitDirectoryMutations(serverId, [ + { kind: "agent", type: "delete", id: "agent-1" }, + { kind: "workspace", type: "delete", id: "workspace-1" }, + { kind: "project", type: "delete", id: "project-1" }, + ]); +} + describe("ReplicaCache", () => { it("does nothing until an owner explicitly commits data", async () => { const storage = new MemoryStorage(); @@ -214,7 +254,7 @@ describe("ReplicaCache", () => { it("round-trips explicit directory and timeline commits", async () => { const storage = new MemoryStorage(); const writer = createCache(storage); - writer.commitDirectory(SERVER_ID, directory()); + commitDirectory(writer, SERVER_ID, directory()); writer.commitTimeline(SERVER_ID, "agent-1", timeline()); await writer.flush(); @@ -229,17 +269,114 @@ describe("ReplicaCache", () => { expect(restoredTimeline).toEqual(timeline()); }); + it("serializes and writes only keyed directory mutations", async () => { + const storage = new MemoryStorage(); + const cache = createCache(storage); + const changed = agent("changed-agent"); + let titleReads = 0; + Object.defineProperty(changed, "title", { + configurable: true, + enumerable: true, + get: () => { + titleReads += 1; + return "Changed agent"; + }, + }); + + cache.commitDirectoryMutations( + SERVER_ID, + [{ kind: "agent", type: "upsert", id: changed.id, value: changed }], + { agents: { generation: "g", afterSeq: 13 } }, + ); + + expect(titleReads).toBe(0); + await cache.flush(); + expect(titleReads).toBe(1); + expect(storage.changes).toHaveLength(1); + expect(storage.changes[0]?.upserts.map(({ kind, id }) => ({ kind, id }))).toEqual([ + { kind: "agent", id: "changed-agent" }, + { kind: "checkpoint", id: "singleton" }, + ]); + expect(storage.changes[0]?.deletes).toEqual([]); + }); + + it("round-trips identified and anonymous open turns without changing protocol status", async () => { + const storage = new MemoryStorage(); + const writer = createCache(storage); + const identified = agent("identified"); + identified.turn = { + phase: "open", + turnId: "turn-1", + startedAt: new Date("2026-08-31T12:00:00.000Z"), + cancellationRequestId: null, + }; + const anonymous = agent("anonymous"); + anonymous.turn = { + phase: "open", + turnId: null, + startedAt: null, + cancellationRequestId: null, + }; + writer.commitDirectoryMutations(SERVER_ID, [ + { kind: "agent", type: "upsert", id: identified.id, value: identified }, + { kind: "agent", type: "upsert", id: anonymous.id, value: anonymous }, + ]); + await writer.flush(); + + const restored = await createCache(storage).readDirectory(SERVER_ID); + + expect(restored.agents.get("identified")).toMatchObject({ + status: "idle", + turn: { + phase: "open", + turnId: "turn-1", + startedAt: new Date("2026-08-31T12:00:00.000Z"), + cancellationRequestId: null, + }, + }); + expect(restored.agents.get("anonymous")).toMatchObject({ + status: "idle", + turn: { + phase: "open", + turnId: null, + startedAt: null, + cancellationRequestId: null, + }, + }); + }); + + it("coalesces timeline values before serialization", async () => { + const storage = new MemoryStorage(); + const cache = createCache(storage); + let textReads = 0; + const changedTimeline = timeline(); + Object.defineProperty(changedTimeline.items[0], "text", { + configurable: true, + enumerable: true, + get: () => { + textReads += 1; + return "Latest"; + }, + }); + + cache.commitTimeline(SERVER_ID, "agent-1", timeline("Old")); + cache.commitTimeline(SERVER_ID, "agent-1", changedTimeline); + + expect(textReads).toBe(0); + await cache.flush(); + expect(textReads).toBe(1); + expect(storage.changes[0]?.upserts.map(({ kind, id }) => ({ kind, id }))).toEqual([ + { kind: "timeline", id: "agent-1" }, + ]); + }); + it("never reads directory rows older than an accepted deferred deletion", async () => { const storage = new MemoryStorage(); const cache = createCache(storage); - cache.commitDirectory(SERVER_ID, directory()); + commitDirectory(cache, SERVER_ID, directory()); await cache.flush(); - cache.commitDirectory(SERVER_ID, { - agents: new Map(), - workspaces: new Map(), - projects: new Map(), - }); + deleteDirectory(cache, SERVER_ID); expect(await cache.readAgent(SERVER_ID, "agent-1")).toBeUndefined(); expect(await cache.readWorkspace(SERVER_ID, "workspace-1")).toBeUndefined(); @@ -249,15 +386,11 @@ describe("ReplicaCache", () => { it("fails closed when an accepted deletion cannot be persisted before a read", async () => { const storage = new MemoryStorage(); const cache = createCache(storage); - cache.commitDirectory(SERVER_ID, directory()); + commitDirectory(cache, SERVER_ID, directory()); await cache.flush(); storage.nextWriteFailure = new Error("disk busy"); - cache.commitDirectory(SERVER_ID, { - agents: new Map(), - workspaces: new Map(), - projects: new Map(), - }); + deleteDirectory(cache, SERVER_ID); expect(await cache.readWorkspace(SERVER_ID, "workspace-1")).toBeUndefined(); }); @@ -265,7 +398,7 @@ describe("ReplicaCache", () => { it("discards a durable read when the host changes while it is in flight", async () => { const storage = new MemoryStorage(); const cache = createCache(storage); - cache.commitDirectory(SERVER_ID, directory()); + commitDirectory(cache, SERVER_ID, directory()); await cache.flush(); const started = deferred(); const release = deferred(); @@ -274,11 +407,7 @@ describe("ReplicaCache", () => { const reading = cache.readAgent(SERVER_ID, "agent-1"); await started.promise; - cache.commitDirectory(SERVER_ID, { - agents: new Map(), - workspaces: new Map(), - projects: new Map(), - }); + deleteDirectory(cache, SERVER_ID); release.resolve(); expect(await reading).toBeUndefined(); @@ -356,7 +485,7 @@ describe("ReplicaCache", () => { it("reads one requested agent and the focused timeline without scanning directory rows", async () => { const storage = new MemoryStorage(); const writer = createCache(storage); - writer.commitDirectory(SERVER_ID, directory()); + commitDirectory(writer, SERVER_ID, directory()); writer.commitTimeline(SERVER_ID, "agent-1", timeline()); await writer.flush(); @@ -372,7 +501,7 @@ describe("ReplicaCache", () => { it("reads one requested workspace and its project without scanning the directory", async () => { const storage = new MemoryStorage(); const writer = createCache(storage); - writer.commitDirectory(SERVER_ID, directory()); + commitDirectory(writer, SERVER_ID, directory()); await writer.flush(); const reader = createCache(storage); @@ -422,9 +551,9 @@ describe("ReplicaCache", () => { const storage = new MemoryStorage(); const writer = new ReplicaCache(storage, noLegacyCleanup); writer.setHosts([SERVER_ID, otherServerId]); - writer.commitDirectory(SERVER_ID, directory()); + commitDirectory(writer, SERVER_ID, directory()); writer.commitTimeline(SERVER_ID, "agent-1", timeline()); - writer.commitDirectory(otherServerId, directory()); + commitDirectory(writer, otherServerId, directory()); await writer.flush(); storage.rows.set(`${SERVER_ID}:agent:agent-1`, { serverId: SERVER_ID, @@ -438,7 +567,7 @@ describe("ReplicaCache", () => { ); const cache = new ReplicaCache(storage, { ...noLegacyCleanup, maxBytes: initialBytes + 100 }); cache.setHosts([SERVER_ID, otherServerId]); - cache.commitDirectory(otherServerId, directory()); + commitDirectory(cache, otherServerId, directory()); await cache.flush(); expect(await cache.readAgent(SERVER_ID, "agent-1")).toBeUndefined(); @@ -451,7 +580,8 @@ describe("ReplicaCache", () => { it("atomically removes a targeted corrupt row and its matching persisted cursor", async () => { const storage = new MemoryStorage(); const writer = createCache(storage); - writer.commitDirectory( + commitDirectory( + writer, SERVER_ID, directory({ agents: { generation: "g", afterSeq: 12 }, @@ -480,7 +610,8 @@ describe("ReplicaCache", () => { it("drops only the cursor whose cached entity baseline is corrupt", async () => { const storage = new MemoryStorage(); const writer = createCache(storage); - writer.commitDirectory( + commitDirectory( + writer, SERVER_ID, directory({ agents: { generation: "g", afterSeq: 12 }, @@ -514,7 +645,7 @@ describe("ReplicaCache", () => { const storage = new MemoryStorage(); const cache = createCache(storage); - cache.commitDirectory(SERVER_ID, directory()); + commitDirectory(cache, SERVER_ID, directory()); await cache.flush(); expect(storage.changes).toHaveLength(1); @@ -548,7 +679,7 @@ describe("ReplicaCache", () => { await cache.flush(); storage.onRead = () => { storage.onRead = null; - cache.commitDirectory(SERVER_ID, directory()); + commitDirectory(cache, SERVER_ID, directory()); }; expect((await cache.readTimeline(SERVER_ID, "agent-1"))?.items).toEqual([ @@ -561,13 +692,23 @@ describe("ReplicaCache", () => { const cache = new ReplicaCache(storage, { ...noLegacyCleanup, maxBytes: 2_500 }); cache.setHosts([SERVER_ID, "other-host"]); const cachedDirectory = directory(); - cache.commitDirectory(SERVER_ID, cachedDirectory); + commitDirectory(cache, SERVER_ID, cachedDirectory); await cache.flush(); - cache.commitDirectory("other-host", cachedDirectory); + commitDirectory(cache, "other-host", cachedDirectory); await cache.flush(); expect([...storage.rows.values()].some((row) => row.serverId === SERVER_ID)).toBe(false); - cache.commitDirectory(SERVER_ID, cachedDirectory); + cache.commitDirectoryMutations( + SERVER_ID, + [{ kind: "agent", type: "upsert", id: "agent-1", value: agent() }], + { agents: { generation: "new-generation", afterSeq: 99 } }, + ); + await cache.flush(); + + expect([...storage.rows.values()].some((row) => row.serverId === SERVER_ID)).toBe(false); + expect((await cache.readDirectory(SERVER_ID)).checkpoint).toBeUndefined(); + + cache.replaceDirectoryBaseline(SERVER_ID, cachedDirectory); await cache.flush(); expect( @@ -588,7 +729,7 @@ describe("ReplicaCache", () => { cache.setHosts([SERVER_ID]); await cache.readAgent(SERVER_ID, "missing"); - cache.commitDirectory(SERVER_ID, directory()); + commitDirectory(cache, SERVER_ID, directory()); await cache.flush(); expect(storage.cleanups).toBe(1); diff --git a/packages/app/src/runtime/replica-cache/index.ts b/packages/app/src/runtime/client-replica/internal/cache/index.ts similarity index 87% rename from packages/app/src/runtime/replica-cache/index.ts rename to packages/app/src/runtime/client-replica/internal/cache/index.ts index 26c1f3b5d6..00e1923476 100644 --- a/packages/app/src/runtime/replica-cache/index.ts +++ b/packages/app/src/runtime/client-replica/internal/cache/index.ts @@ -10,7 +10,6 @@ import { normalizeProjectDescriptor, normalizeWorkspaceDescriptor, type Agent, - type AgentTimelineCursorState, type ProjectDescriptor, type WorkspaceDescriptor, } from "@/stores/session-store"; @@ -19,6 +18,13 @@ import { type AgentToolCallData, type StreamItem, } from "@/types/stream"; +import type { + CachedDirectory, + CachedTimeline, + CachedWorkspace, + DirectoryCheckpoint, + DirectoryReplicaMutation, +} from "@/runtime/client-replica"; import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; import { clearLegacyReplicaCache } from "./legacy-cleanup"; import { @@ -30,7 +36,7 @@ import { type ReplicaRowStore, } from "./row-store"; -const PERSIST_DELAY_MS = 5_000; +const PERSIST_DELAY_MS = 1_000; const MAX_TIMELINE_ITEMS = 50; const MAX_CACHE_BYTES = 32 * 1024 * 1024; const IsoDateSchema = z.iso.datetime(); @@ -217,6 +223,16 @@ const StoredAgentSnapshotSchema = z.strictObject({ const StoredAgentSchema = z.strictObject({ snapshot: StoredAgentSnapshotSchema, + turn: z + .discriminatedUnion("phase", [ + z.strictObject({ phase: z.literal("idle") }), + z.strictObject({ + phase: z.literal("open"), + turnId: z.string().nullable(), + startedAt: IsoDateSchema.nullable(), + }), + ]) + .optional(), projectPlacement: StoredProjectPlacementSchema.nullable(), lastActivityAt: IsoDateSchema, }); @@ -313,28 +329,12 @@ interface ReplicaCacheOptions { clearLegacyCache?: () => Promise; } -export interface CachedDirectory { - agents: Map; - workspaces: Map; - projects: Map; - checkpoint?: DirectoryCheckpoint; -} - -export interface CachedWorkspace { - workspace: WorkspaceDescriptor; - project?: ProjectDescriptor; -} - -export interface DirectoryCursor { - generation: string; - afterSeq: number; -} - -export interface DirectoryCheckpoint { - projects?: DirectoryCursor; - workspaces?: DirectoryCursor; - agents?: DirectoryCursor; -} +type StructuredReplicaUpsert = + | { serverId: string; kind: "agent"; id: string; value: Agent } + | { serverId: string; kind: "workspace"; id: string; value: WorkspaceDescriptor } + | { serverId: string; kind: "project"; id: string; value: ProjectDescriptor } + | { serverId: string; kind: "timeline"; id: string; value: CachedTimeline } + | { serverId: string; kind: "checkpoint"; id: string; value: DirectoryCheckpoint }; const DirectoryCursorSchema = z.strictObject({ generation: z.string(), @@ -347,13 +347,6 @@ const DirectoryCheckpointSchema = z.strictObject({ agents: DirectoryCursorSchema.optional(), }); -export interface CachedTimeline { - agentId: string; - items: StreamItem[]; - range: AgentTimelineCursorState | null; - hasOlder: boolean; -} - function deserializeTimeline(stored: StoredTimeline | null): CachedTimeline | null { if (!stored) { return null; @@ -560,6 +553,15 @@ function serializeProjectPlacement(agent: Agent): StoredAgent["projectPlacement" return agent.projectPlacement ?? null; } +function serializeAgentTurn(agent: Agent): NonNullable { + if (agent.turn.phase === "idle") return { phase: "idle" }; + return { + phase: "open", + turnId: agent.turn.turnId, + startedAt: agent.turn.startedAt?.toISOString() ?? null, + }; +} + function serializeAgent(agent: Agent): StoredAgent { const snapshot = { id: agent.id, @@ -572,11 +574,11 @@ function serializeAgent(agent: Agent): StoredAgent { updatedAt: agent.updatedAt.toISOString(), lastUserMessageAt: agent.lastUserMessageAt?.toISOString() ?? null, status: agent.status, - ...(agent.activeTurn?.turnId + ...(agent.turn.phase === "open" && agent.turn.turnId ? { activeTurn: { - turnId: agent.activeTurn.turnId, - startedAt: agent.activeTurn.startedAt?.toISOString() ?? null, + turnId: agent.turn.turnId, + startedAt: agent.turn.startedAt?.toISOString() ?? null, }, } : {}), @@ -614,14 +616,27 @@ function serializeAgent(agent: Agent): StoredAgent { }; return { snapshot, + turn: serializeAgentTurn(agent), projectPlacement: serializeProjectPlacement(agent), lastActivityAt: agent.lastActivityAt.toISOString(), }; } function deserializeAgent(serverId: string, stored: StoredAgent): Agent { + const normalized = normalizeAgentSnapshot(stored.snapshot, serverId); + let turn = normalized.turn; + if (stored.turn?.phase === "idle") turn = { phase: "idle", cancellationRequestId: null }; + if (stored.turn?.phase === "open") { + turn = { + phase: "open", + turnId: stored.turn.turnId, + startedAt: stored.turn.startedAt ? new Date(stored.turn.startedAt) : null, + cancellationRequestId: null, + }; + } return { - ...normalizeAgentSnapshot(stored.snapshot, serverId), + ...normalized, + turn, lastActivityAt: new Date(stored.lastActivityAt), projectPlacement: stored.projectPlacement, }; @@ -773,8 +788,10 @@ interface DirectoryReadAccumulator { checkpoint?: DirectoryCheckpoint; } -interface PendingReplicaChanges extends ReplicaRowChanges { - directoryReplacements: Map>; +interface PendingReplicaChanges { + upserts: StructuredReplicaUpsert[]; + deletes: ReplicaRowKey[]; + baselines: Set; } function applyDirectoryRow( @@ -825,9 +842,10 @@ export class ReplicaCache { private readonly storedRows = new Map>(); private readonly hostBytes = new Map(); private readonly hostWriteOrder = new Map(); - private pendingUpserts = new Map(); + private pendingUpserts = new Map(); private pendingDeletes = new Map(); - private pendingDirectoryReplacements = new Map>(); + private pendingBaselines = new Set(); + private readonly invalidatedHosts = new Set(); private readonly maxBytes: number; private totalBytes = 0; private persistTimer: ReturnType | null = null; @@ -1019,39 +1037,59 @@ export class ReplicaCache { }); } - commitDirectory( + commitDirectoryMutations( serverId: string, - directory: { - agents: Map; - workspaces: Map; - projects: Map; - checkpoint?: DirectoryCheckpoint; - }, + mutations: readonly DirectoryReplicaMutation[], + checkpoint?: DirectoryCheckpoint, ): void { if (!this.activeServerIds.has(serverId)) return; + if (this.invalidatedHosts.has(serverId)) return; this.advanceHostRevision(serverId); - this.clearPendingDirectoryChanges(serverId); - const desiredRows: ReplicaRow[] = []; - for (const agent of directory.agents.values()) { - desiredRows.push(this.entityRow(serverId, "agent", agent.id, serializeAgent(agent))); + for (const mutation of mutations) { + if (mutation.type === "delete") { + this.queueEntityDelete(serverId, mutation.kind, mutation.id); + } else { + this.queueUpsert({ + serverId, + kind: mutation.kind, + id: mutation.id, + value: mutation.value, + } as StructuredReplicaUpsert); + } } - for (const workspace of directory.workspaces.values()) { - desiredRows.push( - this.entityRow(serverId, "workspace", workspace.id, serializeWorkspace(workspace)), - ); + if (checkpoint) { + this.queueUpsert({ + serverId, + kind: "checkpoint", + id: REPLICA_SINGLETON_ROW_ID, + value: checkpoint, + }); } - for (const project of directory.projects.values()) { - desiredRows.push( - this.entityRow(serverId, "project", project.projectId, serializeProject(project)), - ); + this.schedulePersist(); + } + + replaceDirectoryBaseline(serverId: string, directory: CachedDirectory): void { + if (!this.activeServerIds.has(serverId)) return; + this.advanceHostRevision(serverId); + this.dropPendingHostChanges(serverId); + this.pendingBaselines.add(serverId); + for (const [id, value] of directory.agents) { + this.queueUpsert({ serverId, kind: "agent", id, value }); + } + for (const [id, value] of directory.workspaces) { + this.queueUpsert({ serverId, kind: "workspace", id, value }); + } + for (const [id, value] of directory.projects) { + this.queueUpsert({ serverId, kind: "project", id, value }); } if (directory.checkpoint) { - desiredRows.push( - this.entityRow(serverId, "checkpoint", REPLICA_SINGLETON_ROW_ID, directory.checkpoint), - ); + this.queueUpsert({ + serverId, + kind: "checkpoint", + id: REPLICA_SINGLETON_ROW_ID, + value: directory.checkpoint, + }); } - for (const row of desiredRows) this.queueUpsert(row); - this.pendingDirectoryReplacements.set(serverId, new Set(desiredRows.map((row) => rowKey(row)))); this.schedulePersist(); } @@ -1059,9 +1097,7 @@ export class ReplicaCache { if (!this.activeServerIds.has(serverId)) return; if (timeline.agentId !== agentId) throw new Error("Timeline cache key does not match payload"); this.advanceHostRevision(serverId); - const stored = serializeTimeline(timeline); - if (stored) this.queueEntityUpsert(serverId, "timeline", agentId, stored); - else this.queueEntityDelete(serverId, "timeline", agentId); + this.queueUpsert({ serverId, kind: "timeline", id: agentId, value: timeline }); this.schedulePersist(); } @@ -1075,6 +1111,7 @@ export class ReplicaCache { for (const serverId of removed) { this.removeStoredHost(serverId); this.dropPendingHostChanges(serverId); + this.invalidatedHosts.delete(serverId); this.queueOperation(() => this.rowStore.deleteHost(serverId)); } } @@ -1124,12 +1161,15 @@ export class ReplicaCache { try { await this.prepareStore(); await this.ensureStoredIndex(); - const changes = this.materializeDirectoryReplacements(pending); - const boundedChanges = await this.fitChangesToBudget(changes); + const changes = this.materializePendingChanges(pending); + const { changes: boundedChanges, evicted } = await this.fitChangesToBudget(changes); if (boundedChanges.upserts.length > 0 || boundedChanges.deletes.length > 0) { await this.rowStore.apply(boundedChanges); this.applyStoredChanges(boundedChanges); } + for (const serverId of pending.baselines) { + if (!evicted.has(serverId)) this.invalidatedHosts.delete(serverId); + } } catch { this.restorePendingChanges(pending); if (this.hasPendingChanges()) this.schedulePersist(); @@ -1140,39 +1180,11 @@ export class ReplicaCache { await write; } - private queueEntityUpsert( - serverId: string, - kind: ReplicaRowKind, - id: string, - value: unknown, - ): void { - this.queueUpsert(this.entityRow(serverId, kind, id, value)); - } - - private entityRow( - serverId: string, - kind: ReplicaRowKind, - id: string, - value: unknown, - ): ReplicaRow { - return { serverId, kind, id, payload: JSON.stringify(value) }; - } - - private clearPendingDirectoryChanges(serverId: string): void { - const isDirectoryRow = (row: ReplicaRowKey) => row.kind !== "timeline"; - for (const [key, row] of this.pendingUpserts) { - if (row.serverId === serverId && isDirectoryRow(row)) this.pendingUpserts.delete(key); - } - for (const [key, row] of this.pendingDeletes) { - if (row.serverId === serverId && isDirectoryRow(row)) this.pendingDeletes.delete(key); - } - } - private queueEntityDelete(serverId: string, kind: ReplicaRowKind, id: string): void { this.queueDelete({ serverId, kind, id }); } - private queueUpsert(row: ReplicaRow): void { + private queueUpsert(row: StructuredReplicaUpsert): void { const key = pendingRowKey(row); this.pendingDeletes.delete(key); this.pendingUpserts.set(key, row); @@ -1186,9 +1198,7 @@ export class ReplicaCache { private hasPendingChanges(): boolean { return ( - this.pendingUpserts.size > 0 || - this.pendingDeletes.size > 0 || - this.pendingDirectoryReplacements.size > 0 + this.pendingUpserts.size > 0 || this.pendingDeletes.size > 0 || this.pendingBaselines.size > 0 ); } @@ -1201,7 +1211,7 @@ export class ReplicaCache { } private hasPendingHostChanges(serverId: string): boolean { - if (this.pendingDirectoryReplacements.has(serverId)) return true; + if (this.pendingBaselines.has(serverId)) return true; for (const row of this.pendingUpserts.values()) { if (row.serverId === serverId) return true; } @@ -1219,31 +1229,66 @@ export class ReplicaCache { const changes = { upserts: [...this.pendingUpserts.values()], deletes: [...this.pendingDeletes.values()], - directoryReplacements: this.pendingDirectoryReplacements, + baselines: this.pendingBaselines, }; this.pendingUpserts = new Map(); this.pendingDeletes = new Map(); - this.pendingDirectoryReplacements = new Map(); + this.pendingBaselines = new Set(); return changes; } - private materializeDirectoryReplacements(pending: PendingReplicaChanges): ReplicaRowChanges { + private materializePendingChanges(pending: PendingReplicaChanges): ReplicaRowChanges { const deletes = new Map(pending.deletes.map((row) => [pendingRowKey(row), row])); - for (const [serverId, desiredRows] of pending.directoryReplacements) { + const upserts: ReplicaRow[] = []; + for (const upsert of pending.upserts) { + const row = this.materializeUpsert(upsert); + if (row) upserts.push(row); + else deletes.set(pendingRowKey(upsert), upsert); + } + for (const serverId of pending.baselines) { + const replacementKeys = new Set( + upserts.filter((row) => row.serverId === serverId && row.kind !== "timeline").map(rowKey), + ); for (const row of this.storedRows.get(serverId)?.values() ?? []) { - if (row.kind !== "timeline" && !desiredRows.has(rowKey(row))) { + if (row.kind !== "timeline" && !replacementKeys.has(rowKey(row))) { deletes.set(pendingRowKey(row), row); } } } - return { upserts: pending.upserts, deletes: [...deletes.values()] }; + return { upserts, deletes: [...deletes.values()] }; + } + + private materializeUpsert(upsert: StructuredReplicaUpsert): ReplicaRow | null { + let value: unknown; + switch (upsert.kind) { + case "agent": + value = serializeAgent(upsert.value); + break; + case "workspace": + value = serializeWorkspace(upsert.value); + break; + case "project": + value = serializeProject(upsert.value); + break; + case "timeline": + value = serializeTimeline(upsert.value); + if (!value) return null; + break; + case "checkpoint": + value = upsert.value; + break; + } + return { + serverId: upsert.serverId, + kind: upsert.kind, + id: upsert.id, + payload: JSON.stringify(value), + }; } private restorePendingChanges(changes: PendingReplicaChanges): void { - for (const [serverId, desiredRows] of changes.directoryReplacements) { - if (this.activeServerIds.has(serverId) && !this.pendingDirectoryReplacements.has(serverId)) { - this.pendingDirectoryReplacements.set(serverId, desiredRows); - } + for (const serverId of changes.baselines) { + if (this.activeServerIds.has(serverId)) this.pendingBaselines.add(serverId); } for (const key of changes.deletes) { const pendingKey = pendingRowKey(key); @@ -1306,7 +1351,9 @@ export class ReplicaCache { this.hostWriteOrder.set(serverId, true); } - private async fitChangesToBudget(changes: ReplicaRowChanges): Promise { + private async fitChangesToBudget( + changes: ReplicaRowChanges, + ): Promise<{ changes: ReplicaRowChanges; evicted: Set }> { const touchedServerIds = new Set(); for (const key of changes.deletes) touchedServerIds.add(key.serverId); for (const row of changes.upserts) touchedServerIds.add(row.serverId); @@ -1342,15 +1389,17 @@ export class ReplicaCache { projectedTotal -= bytes; projectedBytes.delete(serverId); evicted.add(serverId); + this.invalidatedHosts.add(serverId); if (this.storedRows.has(serverId)) await this.rowStore.deleteHost(serverId); this.removeStoredHost(serverId); - // The next accepted owner commit must rebuild the complete host replica. Retaining the - // directory replacement marker ensures its checkpoint and rows stay atomic. } return { - upserts: changes.upserts.filter((row) => !evicted.has(row.serverId)), - deletes: changes.deletes.filter((key) => !evicted.has(key.serverId)), + changes: { + upserts: changes.upserts.filter((row) => !evicted.has(row.serverId)), + deletes: changes.deletes.filter((key) => !evicted.has(key.serverId)), + }, + evicted, }; } @@ -1362,7 +1411,7 @@ export class ReplicaCache { } private dropPendingHostChanges(serverId: string): void { - this.pendingDirectoryReplacements.delete(serverId); + this.pendingBaselines.delete(serverId); for (const [key, row] of this.pendingUpserts) { if (row.serverId === serverId) this.pendingUpserts.delete(key); } @@ -1373,18 +1422,16 @@ export class ReplicaCache { private renamePendingHostChanges(oldServerId: string, newServerId: string): void { const changes = this.drainPendingChanges(); - for (const [serverId, desiredRows] of changes.directoryReplacements) { - this.pendingDirectoryReplacements.set( - serverId === oldServerId ? newServerId : serverId, - desiredRows, - ); - } for (const key of changes.deletes) { this.queueDelete(key.serverId === oldServerId ? { ...key, serverId: newServerId } : key); } for (const row of changes.upserts) { this.queueUpsert(row.serverId === oldServerId ? { ...row, serverId: newServerId } : row); } + for (const serverId of changes.baselines) { + this.pendingBaselines.add(serverId === oldServerId ? newServerId : serverId); + } + if (this.invalidatedHosts.delete(oldServerId)) this.invalidatedHosts.add(newServerId); } private prepareStore(): Promise { diff --git a/packages/app/src/runtime/replica-cache/legacy-cleanup.ts b/packages/app/src/runtime/client-replica/internal/cache/legacy-cleanup.ts similarity index 100% rename from packages/app/src/runtime/replica-cache/legacy-cleanup.ts rename to packages/app/src/runtime/client-replica/internal/cache/legacy-cleanup.ts diff --git a/packages/app/src/runtime/replica-cache/legacy-cleanup.web.test.ts b/packages/app/src/runtime/client-replica/internal/cache/legacy-cleanup.web.test.ts similarity index 100% rename from packages/app/src/runtime/replica-cache/legacy-cleanup.web.test.ts rename to packages/app/src/runtime/client-replica/internal/cache/legacy-cleanup.web.test.ts diff --git a/packages/app/src/runtime/replica-cache/legacy-cleanup.web.ts b/packages/app/src/runtime/client-replica/internal/cache/legacy-cleanup.web.ts similarity index 100% rename from packages/app/src/runtime/replica-cache/legacy-cleanup.web.ts rename to packages/app/src/runtime/client-replica/internal/cache/legacy-cleanup.web.ts diff --git a/packages/app/src/runtime/replica-cache/row-store-factory.native.ts b/packages/app/src/runtime/client-replica/internal/cache/row-store-factory.native.ts similarity index 100% rename from packages/app/src/runtime/replica-cache/row-store-factory.native.ts rename to packages/app/src/runtime/client-replica/internal/cache/row-store-factory.native.ts diff --git a/packages/app/src/runtime/replica-cache/row-store-factory.ts b/packages/app/src/runtime/client-replica/internal/cache/row-store-factory.ts similarity index 100% rename from packages/app/src/runtime/replica-cache/row-store-factory.ts rename to packages/app/src/runtime/client-replica/internal/cache/row-store-factory.ts diff --git a/packages/app/src/runtime/replica-cache/row-store-factory.web.ts b/packages/app/src/runtime/client-replica/internal/cache/row-store-factory.web.ts similarity index 100% rename from packages/app/src/runtime/replica-cache/row-store-factory.web.ts rename to packages/app/src/runtime/client-replica/internal/cache/row-store-factory.web.ts diff --git a/packages/app/src/runtime/replica-cache/row-store-schema.ts b/packages/app/src/runtime/client-replica/internal/cache/row-store-schema.ts similarity index 100% rename from packages/app/src/runtime/replica-cache/row-store-schema.ts rename to packages/app/src/runtime/client-replica/internal/cache/row-store-schema.ts diff --git a/packages/app/src/runtime/replica-cache/row-store-sqlite.ts b/packages/app/src/runtime/client-replica/internal/cache/row-store-sqlite.ts similarity index 100% rename from packages/app/src/runtime/replica-cache/row-store-sqlite.ts rename to packages/app/src/runtime/client-replica/internal/cache/row-store-sqlite.ts diff --git a/packages/app/src/runtime/replica-cache/row-store.contract.ts b/packages/app/src/runtime/client-replica/internal/cache/row-store.contract.ts similarity index 100% rename from packages/app/src/runtime/replica-cache/row-store.contract.ts rename to packages/app/src/runtime/client-replica/internal/cache/row-store.contract.ts diff --git a/packages/app/src/runtime/replica-cache/row-store.native.test.ts b/packages/app/src/runtime/client-replica/internal/cache/row-store.native.test.ts similarity index 100% rename from packages/app/src/runtime/replica-cache/row-store.native.test.ts rename to packages/app/src/runtime/client-replica/internal/cache/row-store.native.test.ts diff --git a/packages/app/src/runtime/replica-cache/row-store.native.ts b/packages/app/src/runtime/client-replica/internal/cache/row-store.native.ts similarity index 100% rename from packages/app/src/runtime/replica-cache/row-store.native.ts rename to packages/app/src/runtime/client-replica/internal/cache/row-store.native.ts diff --git a/packages/app/src/runtime/replica-cache/row-store.ts b/packages/app/src/runtime/client-replica/internal/cache/row-store.ts similarity index 100% rename from packages/app/src/runtime/replica-cache/row-store.ts rename to packages/app/src/runtime/client-replica/internal/cache/row-store.ts diff --git a/packages/app/src/runtime/replica-cache/row-store.web.test.ts b/packages/app/src/runtime/client-replica/internal/cache/row-store.web.test.ts similarity index 100% rename from packages/app/src/runtime/replica-cache/row-store.web.test.ts rename to packages/app/src/runtime/client-replica/internal/cache/row-store.web.test.ts diff --git a/packages/app/src/runtime/replica-cache/row-store.web.ts b/packages/app/src/runtime/client-replica/internal/cache/row-store.web.ts similarity index 100% rename from packages/app/src/runtime/replica-cache/row-store.web.ts rename to packages/app/src/runtime/client-replica/internal/cache/row-store.web.ts diff --git a/packages/app/src/runtime/daemon-start-service.ts b/packages/app/src/runtime/daemon-start-service.ts index 093158e9ea..f916a8c81c 100644 --- a/packages/app/src/runtime/daemon-start-service.ts +++ b/packages/app/src/runtime/daemon-start-service.ts @@ -1,6 +1,6 @@ import { startDesktopDaemon, type DesktopDaemonStatus } from "@/desktop/daemon/desktop-daemon"; import { connectionFromListen } from "@/types/host-connection"; -import type { HostRuntimeStore } from "@/runtime/host-runtime"; +import { getHostRuntimeStore, type HostRuntimeStore } from "@/runtime/host-runtime"; export type DaemonStartResult = { ok: true } | { ok: false; error: string }; export type DaemonStartCondition = boolean | (() => boolean | Promise); @@ -46,6 +46,12 @@ export async function upsertDesktopDaemonConnection( return { ok: true }; } +export function registerDesktopDaemonConnection( + daemon: DesktopDaemonStatus, +): Promise { + return upsertDesktopDaemonConnection(getHostRuntimeStore(), daemon); +} + export class DaemonStartService { private readonly store: DaemonConnectionStore; private readonly invokeStartDesktopDaemon: () => Promise; @@ -166,3 +172,7 @@ export function getDaemonStartService(deps: DaemonStartServiceDeps): DaemonStart runtimeGlobal[DAEMON_START_SERVICE_GLOBAL_KEY] = singletonDaemonStartService; return singletonDaemonStartService; } + +export function getManagedDaemonStartService(): DaemonStartService { + return getDaemonStartService({ store: getHostRuntimeStore() }); +} diff --git a/packages/app/src/runtime/directory-sync/agent-replica.test.ts b/packages/app/src/runtime/directory-sync/agent-replica.test.ts index c791949827..3a30f6b6c5 100644 --- a/packages/app/src/runtime/directory-sync/agent-replica.test.ts +++ b/packages/app/src/runtime/directory-sync/agent-replica.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from "vitest"; import type { DaemonClient, FetchAgentsEntry } from "@getpaseo/client/internal/daemon-client"; import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages"; -import { useSessionStore } from "@/stores/session-store"; +import { selectAgentTurnPresentation, useSessionStore } from "@/stores/session-store"; +import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; +import type { DirectoryReplicaMutation } from "@/runtime/client-replica"; import { AgentDirectoryReplica } from "./agent-replica"; function payload(title: string): AgentSnapshotPayload { @@ -51,11 +53,38 @@ function entry(agent: AgentSnapshotPayload): FetchAgentsEntry { } describe("AgentDirectoryReplica", () => { + it("does not let a late cache read replace newer live turn state", () => { + const serverId = "agent-replica-late-cache"; + const store = useSessionStore.getState(); + store.initializeSession(serverId, null as unknown as DaemonClient); + const replica = new AgentDirectoryReplica( + serverId, + () => undefined, + () => undefined, + ); + replica.commitSnapshot([entry(payload("live"))], []); + replica.applyTurnLiveness("agent", { + type: "stream_open", + turn: { turnId: "turn-live", startedAt: null }, + }); + + replica.commitCached(new Map([["agent", normalizeAgentSnapshot(payload("cached"), serverId)]])); + + expect(selectAgentTurnPresentation(store.getSession(serverId), "agent").turnId).toBe( + "turn-live", + ); + store.clearSession(serverId); + }); + it("accepts the requested agent from the authoritative timeline after a cache miss", () => { const serverId = "agent-replica-cache-miss"; const store = useSessionStore.getState(); store.initializeSession(serverId, null as unknown as DaemonClient); - const replica = new AgentDirectoryReplica(serverId, () => undefined); + const replica = new AgentDirectoryReplica( + serverId, + () => undefined, + () => undefined, + ); expect(replica.submitTimelineAgent(replica.captureTimeline("agent"), payload("network"))).toBe( true, @@ -66,11 +95,99 @@ describe("AgentDirectoryReplica", () => { store.clearSession(serverId); }); + it("persists stream-only turn transitions through the agent owner", () => { + const serverId = "agent-replica-stream-turn"; + const store = useSessionStore.getState(); + store.initializeSession(serverId, null as unknown as DaemonClient); + const commits: DirectoryReplicaMutation[][] = []; + const replica = new AgentDirectoryReplica( + serverId, + () => undefined, + (mutations) => commits.push([...mutations]), + ); + replica.commitSnapshot([entry(payload("agent"))], []); + commits.length = 0; + + replica.applyTurnLiveness("agent", { + type: "stream_open", + turn: { turnId: "turn-1", startedAt: new Date("2026-08-31T12:00:00.000Z") }, + }); + + expect(commits).toEqual([ + [ + expect.objectContaining({ + kind: "agent", + type: "upsert", + id: "agent", + value: expect.objectContaining({ + status: "idle", + turn: { + phase: "open", + cancellationRequestId: null, + turnId: "turn-1", + startedAt: new Date("2026-08-31T12:00:00.000Z"), + }, + }), + }), + ], + ]); + expect(selectAgentTurnPresentation(store.getSession(serverId), "agent").isActive).toBe(true); + store.clearSession(serverId); + }); + + it("uses turn liveness for stopped transitions even when protocol status disagrees", () => { + const serverId = "agent-replica-mismatched-status"; + const store = useSessionStore.getState(); + store.initializeSession(serverId, null as unknown as DaemonClient); + const stopped: string[] = []; + const replica = new AgentDirectoryReplica( + serverId, + (agentId) => stopped.push(agentId), + () => undefined, + ); + replica.commitSnapshot( + [ + entry({ + ...payload("open despite idle"), + activeTurn: { turnId: "turn-1", startedAt: null }, + }), + ], + [], + ); + + expect(useSessionStore.getState().sessions[serverId]?.agents.get("agent")).toMatchObject({ + status: "idle", + turn: { phase: "open", turnId: "turn-1" }, + }); + + replica.applyDelta({ + kind: "upsert", + agent: { ...payload("idle turn despite running"), status: "running", activeTurn: null }, + project: entry(payload("project")).project, + }); + replica.applyDelta({ + kind: "upsert", + agent: { ...payload("status-only update"), status: "idle", activeTurn: null }, + project: entry(payload("project")).project, + }); + + expect(useSessionStore.getState().sessions[serverId]?.agents.get("agent")).toMatchObject({ + status: "idle", + turn: { phase: "idle" }, + }); + expect(stopped).toEqual(["agent"]); + store.clearSession(serverId); + }); + it("preserves an unchanged running agent's turn identity during catch-up", () => { const serverId = "agent-replica-catch-up"; const store = useSessionStore.getState(); store.initializeSession(serverId, null as unknown as DaemonClient); - const replica = new AgentDirectoryReplica(serverId, () => undefined); + const replica = new AgentDirectoryReplica( + serverId, + () => undefined, + () => undefined, + ); replica.commitSnapshot( [ entry({ @@ -84,9 +201,11 @@ describe("AgentDirectoryReplica", () => { replica.commitChanges([], [], []); - expect(useSessionStore.getState().sessions[serverId]?.agents.get("agent")?.activeTurn).toEqual({ + expect(useSessionStore.getState().sessions[serverId]?.agents.get("agent")?.turn).toEqual({ + phase: "open", turnId: "turn-1", startedAt: new Date("2026-07-17T00:01:00.000Z"), + cancellationRequestId: null, }); store.clearSession(serverId); }); @@ -95,7 +214,11 @@ describe("AgentDirectoryReplica", () => { const serverId = "agent-replica"; const store = useSessionStore.getState(); store.initializeSession(serverId, null as unknown as DaemonClient); - const replica = new AgentDirectoryReplica(serverId, () => undefined); + const replica = new AgentDirectoryReplica( + serverId, + () => undefined, + () => undefined, + ); replica.commitSnapshot([entry(payload("directory"))], []); const directoryPlacement = useSessionStore .getState() diff --git a/packages/app/src/runtime/directory-sync/agent-replica.ts b/packages/app/src/runtime/directory-sync/agent-replica.ts index c9ff7424a4..38d1a0f6f6 100644 --- a/packages/app/src/runtime/directory-sync/agent-replica.ts +++ b/packages/app/src/runtime/directory-sync/agent-replica.ts @@ -2,18 +2,14 @@ import type { FetchAgentsEntry } from "@getpaseo/client/internal/daemon-client"; import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages"; import { clearArchiveAgentPending } from "@/hooks/use-archive-agent"; import { queryClient } from "@/data/query-client"; -import { useSessionStore, type Agent } from "@/stores/session-store"; +import type { Agent } from "@/stores/session-store"; import { normalizeAgentSnapshot, projectAgentSnapshot } from "@/utils/agent-snapshots"; -import { - applyAgentDirectoryDelta, - type AgentDirectoryDelta, - removeAgentDirectoryReplica, - replaceAgentPendingPermissions, - replaceFetchedAgentDirectory, - upsertAgentReplica, -} from "@/utils/agent-directory-sync"; +import { type AgentDirectoryDelta } from "@/utils/agent-directory-sync"; import { reconcileAgentDirectory } from "@/utils/agent-directory-reconciliation"; import { applyLegacyDaemonWorkspaceOwnership } from "@/workspace/legacy-daemon-workspaces"; +import type { DirectoryReplicaMutation } from "@/runtime/client-replica"; +import type { TurnLivenessTransition } from "@/timeline/turn-liveness"; +import { AgentStoreProjection } from "./internal/agent-store"; function projectAgentDirectoryEntry(agent: Agent): FetchAgentsEntry | null { return agent.projectPlacement @@ -30,28 +26,35 @@ export class AgentDirectoryReplica { private readonly lifecycleVersions = new Map(); private readonly members = new Set(); private readonly pendingCacheReads = new Set(); + private readonly storeProjection: AgentStoreProjection; constructor( private readonly serverId: string, private readonly onStoppedRunning: (agentId: string) => void, - ) {} + private readonly persist: (mutations: readonly DirectoryReplicaMutation[]) => void, + ) { + this.storeProjection = new AgentStoreProjection(serverId); + } captureTimeline(agentId: string): AgentLifecycleToken { return { agentId, version: this.lifecycleVersions.get(agentId) ?? 0 }; } + snapshot(): Map { + return this.storeProjection.snapshot(); + } + captureCache(agentId: string): AgentLifecycleToken { this.pendingCacheReads.add(agentId); return this.captureTimeline(agentId); } commitCached(agents: Map): void { + const merged = this.storeProjection.commitCached(agents); this.members.clear(); - for (const [agentId, agent] of agents) { + for (const agentId of merged.keys()) { this.members.add(agentId); - useSessionStore.getState().setAgentLastActivity(agentId, agent.lastActivityAt); } - useSessionStore.getState().setAgents(this.serverId, agents); } commitCachedAgent(token: AgentLifecycleToken, agent: Agent): boolean { @@ -59,13 +62,8 @@ export class AgentDirectoryReplica { if (token.version !== (this.lifecycleVersions.get(token.agentId) ?? 0)) return false; if (this.members.has(agent.id)) return false; this.members.add(agent.id); - useSessionStore.getState().setAgents(this.serverId, (current) => { - if (current.has(agent.id)) return current; - const next = new Map(current); - next.set(agent.id, agent); - return next; - }); - useSessionStore.getState().setAgentLastActivity(agent.id, agent.lastActivityAt); + this.storeProjection.accept(agent); + this.storeProjection.publishActivity(agent); return true; } @@ -73,7 +71,7 @@ export class AgentDirectoryReplica { if (token.version !== (this.lifecycleVersions.get(token.agentId) ?? 0)) { return false; } - const existing = useSessionStore.getState().sessions[this.serverId]?.agents.get(token.agentId); + const existing = this.storeProjection.get(token.agentId); const timelineAgent = applyLegacyDaemonWorkspaceOwnership({ serverId: this.serverId, agent: normalizeAgentSnapshot(payload, this.serverId), @@ -82,19 +80,20 @@ export class AgentDirectoryReplica { ...timelineAgent, projectPlacement: timelineAgent.projectPlacement ?? existing?.projectPlacement, }; - const accepted = upsertAgentReplica(this.serverId, normalized); + const accepted = this.storeProjection.accept(normalized); this.members.add(accepted.id); - replaceAgentPendingPermissions(this.serverId, accepted); - useSessionStore.getState().setAgentLastActivity(accepted.id, accepted.lastActivityAt); + this.storeProjection.replacePendingPermissions(accepted); + this.storeProjection.publishActivity(accepted); if (accepted.archivedAt) { clearArchiveAgentPending({ queryClient, serverId: this.serverId, agentId: accepted.id }); } + this.persist([this.agentUpsert(accepted)]); return true; } applyDelta(delta: AgentDirectoryDelta): void { const before = this.members.has(delta.kind === "remove" ? delta.agentId : delta.agent.id); - const result = applyAgentDirectoryDelta({ serverId: this.serverId, delta }); + const result = this.storeProjection.applyDelta(delta); if (delta.kind === "remove") { this.members.delete(delta.agentId); this.advance(delta.agentId); @@ -103,15 +102,28 @@ export class AgentDirectoryReplica { if (!before) this.advance(delta.agent.id); } if (result.stoppedRunning) this.onStoppedRunning(result.agentId); + this.persist( + result.agent + ? [this.agentUpsert(result.agent)] + : [{ kind: "agent", type: "delete", id: result.agentId }], + ); + } + + accept(agent: Agent): Agent { + const accepted = this.storeProjection.accept(agent); + this.members.add(accepted.id); + this.persist([this.agentUpsert(accepted)]); + return accepted; } commitSnapshot( entries: FetchAgentsEntry[], deltas: readonly AgentDirectoryDelta[], + persist = true, ): Map { - const previous = useSessionStore.getState().sessions[this.serverId]?.agents ?? new Map(); - const reconciled = reconcileAgentDirectory({ previous, snapshot: entries, deltas }); - const nextIds = new Set(reconciled.entries.map((entry) => entry.agent.id)); + const previous = this.storeProjection.snapshot(); + const reconciled = reconcileAgentDirectory({ snapshot: entries, deltas }); + const nextIds = new Set(reconciled.map((entry) => entry.agent.id)); for (const agentId of this.pendingCacheReads) { if (!nextIds.has(agentId)) this.advance(agentId); } @@ -122,16 +134,25 @@ export class AgentDirectoryReplica { if (!this.members.has(agentId)) this.advance(agentId); } for (const agentId of previous.keys()) { - if (!nextIds.has(agentId)) removeAgentDirectoryReplica(this.serverId, agentId); + if (!nextIds.has(agentId)) this.storeProjection.remove(agentId); } this.members.clear(); this.pendingCacheReads.clear(); for (const agentId of nextIds) this.members.add(agentId); - const { agents } = replaceFetchedAgentDirectory({ - serverId: this.serverId, - entries: reconciled.entries, - }); - for (const agentId of reconciled.stoppedRunningAgentIds) this.onStoppedRunning(agentId); + const agents = this.storeProjection.replaceFetched(reconciled); + for (const [agentId, previousAgent] of previous) { + if (previousAgent.turn.phase === "open" && agents.get(agentId)?.turn.phase === "idle") { + this.onStoppedRunning(agentId); + } + } + if (persist) { + this.persist([ + ...Array.from(previous.keys()) + .filter((agentId) => !agents.has(agentId)) + .map((id): DirectoryReplicaMutation => ({ kind: "agent", type: "delete", id })), + ...Array.from(agents.values(), (value) => this.agentUpsert(value)), + ]); + } return agents; } @@ -140,7 +161,7 @@ export class AgentDirectoryReplica { removals: readonly { id: string }[], deltas: readonly AgentDirectoryDelta[], ): Map { - const previous = useSessionStore.getState().sessions[this.serverId]?.agents ?? new Map(); + const previous = this.storeProjection.snapshot(); const merged = new Map(); for (const agent of previous.values()) { const entry = projectAgentDirectoryEntry(agent); @@ -151,25 +172,54 @@ export class AgentDirectoryReplica { kind: "remove", agentId: id, })); - return this.commitSnapshot(Array.from(merged.values()), [...removalsAsDeltas, ...deltas]); + const agents = this.commitSnapshot( + Array.from(merged.values()), + [...removalsAsDeltas, ...deltas], + false, + ); + const touchedIds = new Set([ + ...entries.map((entry) => entry.agent.id), + ...removals.map(({ id }) => id), + ...deltas.map((delta) => (delta.kind === "remove" ? delta.agentId : delta.agent.id)), + ]); + this.persist( + Array.from(touchedIds, (id): DirectoryReplicaMutation => { + const value = agents.get(id); + return value ? this.agentUpsert(value) : { kind: "agent", type: "delete", id }; + }), + ); + return agents; } archive(agentId: string, archivedAt: string): void { this.advance(agentId); - useSessionStore.getState().setAgents(this.serverId, (current) => { - const agent = current.get(agentId); - if (!agent) return current; - const next = new Map(current); - next.set(agentId, { ...agent, archivedAt: new Date(archivedAt) }); - return next; - }); + const archived = this.storeProjection.archive(agentId, archivedAt); + if (archived) this.persist([this.agentUpsert(archived)]); clearArchiveAgentPending({ queryClient, serverId: this.serverId, agentId }); } remove(agentId: string): void { this.members.delete(agentId); this.advance(agentId); - removeAgentDirectoryReplica(this.serverId, agentId); + this.storeProjection.remove(agentId); + this.persist([{ kind: "agent", type: "delete", id: agentId }]); + } + + applyTurnLiveness( + agentId: string, + transition: TurnLivenessTransition | readonly TurnLivenessTransition[], + ): void { + const accepted = this.storeProjection.applyTurn(agentId, transition); + if (accepted) this.persist([this.agentUpsert(accepted)]); + } + + private agentUpsert(agent: Agent): DirectoryReplicaMutation { + return { + kind: "agent", + type: "upsert", + id: agent.id, + value: agent, + }; } private advance(agentId: string): void { diff --git a/packages/app/src/runtime/directory-sync/index.test.ts b/packages/app/src/runtime/directory-sync/index.test.ts index 2008abb6a1..5fca8262d3 100644 --- a/packages/app/src/runtime/directory-sync/index.test.ts +++ b/packages/app/src/runtime/directory-sync/index.test.ts @@ -8,7 +8,7 @@ import { } from "@/stores/session-store"; import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; import { selectWorkspaceDirectoryServerIds } from "@/stores/session-store-hooks/selectors"; -import type { CachedDirectory } from "@/runtime/replica-cache"; +import type { DirectoryReplicaMutation } from "@/runtime/client-replica"; import { DirectoryRefreshSupersededError, DirectorySync, @@ -182,7 +182,7 @@ afterEach(() => { }); describe("DirectorySync session readiness", () => { - it("paints a demanded cached directory while offline", async () => { + it("restores the cached directory before network demand", async () => { const serverId = "offline-cached-directory"; serverIds.add(serverId); const client = new FakeDirectoryClient(); @@ -224,12 +224,12 @@ describe("DirectorySync session readiness", () => { workspaces: new Map([[cachedWorkspace.id, cachedWorkspace]]), projects: new Map([[cachedProject.projectId, cachedProject]]), }), - commitDirectory: () => undefined, + commitDirectoryMutations: () => undefined, }, ); useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient, 1); - await directory.refreshAll(); + await directory.restoreCachedDirectory(); expect( useSessionStore.getState().sessions[serverId]?.workspaces.get(cachedWorkspace.id), @@ -280,7 +280,7 @@ describe("DirectorySync session readiness", () => { workspaces: new Map(), projects: new Map(), }), - commitDirectory: () => undefined, + commitDirectoryMutations: () => undefined, }, ); useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient, 1); @@ -301,7 +301,7 @@ describe("DirectorySync session readiness", () => { const serverId = "script-status-owner"; serverIds.add(serverId); const client = new FakeDirectoryClient(); - const commits: CachedDirectory[] = []; + const commits: DirectoryReplicaMutation[][] = []; const directory = new DirectorySync( serverId, { @@ -318,7 +318,7 @@ describe("DirectorySync session readiness", () => { workspaces: new Map(), projects: new Map(), }), - commitDirectory: (_serverId, value) => commits.push(value), + commitDirectoryMutations: (_serverId, mutations) => commits.push([...mutations]), }, ); directory.connectionChanged({ @@ -344,7 +344,8 @@ describe("DirectorySync session readiness", () => { }); const store = useSessionStore.getState(); store.initializeSession(serverId, client as unknown as DaemonClient, 1); - store.setWorkspaces(serverId, new Map([[workspace.id, workspace]])); + directory.acceptWorkspaces([workspace]); + commits.length = 0; client.emit({ type: "script_status_update", @@ -370,7 +371,17 @@ describe("DirectorySync session readiness", () => { useSessionStore.getState().sessions[serverId]?.workspaces.get(workspace.id)?.scripts[0] ?.lifecycle, ).toBe("running"); - expect(commits.at(-1)?.workspaces.get(workspace.id)?.scripts[0]?.lifecycle).toBe("running"); + const persisted = commits + .flat() + .find( + ( + mutation, + ): mutation is Extract => + mutation.kind === "workspace" && + mutation.type === "upsert" && + mutation.id === workspace.id, + ); + expect(persisted?.value.scripts[0]?.lifecycle).toBe("running"); directory.dispose(); }); @@ -476,7 +487,7 @@ describe("DirectorySync session readiness", () => { projects: new Map([[cachedProject.projectId, cachedProject]]), }; }, - commitDirectory: () => undefined, + commitDirectoryMutations: () => undefined, }, ); directory.connectionChanged({ @@ -556,7 +567,7 @@ describe("DirectorySync session readiness", () => { projects: new Map([[cachedProject.projectId, cachedProject]]), checkpoint: { workspaces: { generation: "g", afterSeq: 7 } }, }), - commitDirectory: () => undefined, + commitDirectoryMutations: () => undefined, }, ); directory.connectionChanged({ @@ -617,7 +628,7 @@ describe("DirectorySync session readiness", () => { readAgent: async () => undefined, readWorkspace: async () => undefined, readDirectory: () => cacheRead, - commitDirectory: () => undefined, + commitDirectoryMutations: () => undefined, }, ); directory.connectionChanged({ @@ -706,7 +717,7 @@ describe("DirectorySync session readiness", () => { workspaces: new Map(), projects: new Map(), }), - commitDirectory: () => undefined, + commitDirectoryMutations: () => undefined, }, ); directory.connectionChanged({ @@ -754,7 +765,7 @@ describe("DirectorySync session readiness", () => { workspaces: new Map(), projects: new Map(), }), - commitDirectory: () => undefined, + commitDirectoryMutations: () => undefined, }, ); directory.connectionChanged({ @@ -807,7 +818,7 @@ describe("DirectorySync session readiness", () => { projects: new Map(), checkpoint: { agents: { generation: "generation", afterSeq: 12 } }, }), - commitDirectory: () => undefined, + commitDirectoryMutations: () => undefined, }, ); directory.connectionChanged({ @@ -891,7 +902,10 @@ describe("DirectorySync session readiness", () => { const serverId = "project-list-sequence"; serverIds.add(serverId); const client = new FakeDirectoryClient(); - const writes: unknown[] = []; + const writes: Array<{ + mutations: readonly DirectoryReplicaMutation[]; + checkpoint: unknown; + }> = []; const cachedProjects = [ normalizeProjectDescriptor({ projectId: "project-1", @@ -905,6 +919,12 @@ describe("DirectorySync session readiness", () => { projectRootPath: "/repo/two", projectKind: "git", }), + normalizeProjectDescriptor({ + projectId: "untouched-project", + projectDisplayName: "Untouched", + projectRootPath: "/repo/untouched", + projectKind: "git", + }), ]; const directory = new DirectorySync( serverId, @@ -923,7 +943,8 @@ describe("DirectorySync session readiness", () => { projects: new Map(cachedProjects.map((project) => [project.projectId, project])), checkpoint: { projects: { generation: "generation", afterSeq: 4 } }, }), - commitDirectory: (_serverId, value) => writes.push(value.checkpoint), + commitDirectoryMutations: (_serverId, mutations, checkpoint) => + writes.push({ mutations: [...mutations], checkpoint }), }, ); directory.connectionChanged({ @@ -964,9 +985,17 @@ describe("DirectorySync session readiness", () => { sync: { generation: "generation", afterSeq: 4 }, }); const projects = useSessionStore.getState().sessions[serverId]?.projects; - expect(Array.from(projects?.keys() ?? [])).toEqual(["project-1"]); + expect(Array.from(projects?.keys() ?? [])).toEqual(["project-1", "untouched-project"]); expect(projects?.get("project-1")?.projectDisplayName).toBe("New name"); - expect(writes).toContainEqual({ projects: { generation: "generation", afterSeq: 6 } }); + expect(writes.map(({ checkpoint }) => checkpoint)).toContainEqual({ + projects: { generation: "generation", afterSeq: 6 }, + }); + const writtenProjectIds = writes + .flatMap(({ mutations }) => mutations) + .filter((mutation) => mutation.kind === "project") + .map((mutation) => mutation.id); + expect(writtenProjectIds).toEqual(["project-1", "project-2"]); + expect(writtenProjectIds).not.toContain("untouched-project"); directory.dispose(); }); diff --git a/packages/app/src/runtime/directory-sync/index.ts b/packages/app/src/runtime/directory-sync/index.ts index 90648ffd38..c39f8b716e 100644 --- a/packages/app/src/runtime/directory-sync/index.ts +++ b/packages/app/src/runtime/directory-sync/index.ts @@ -36,7 +36,9 @@ import type { CachedWorkspace, DirectoryCheckpoint, DirectoryCursor, -} from "@/runtime/replica-cache"; + DirectoryReplicaMutation, +} from "@/runtime/client-replica"; +import type { TurnLivenessTransition } from "@/timeline/turn-liveness"; const PAGE_LIMIT = 200; const AGENT_SORT: NonNullable = [ @@ -69,6 +71,36 @@ interface AgentPageInfo { afterCursor?: string | null; } +function applyWorkspaceSnapshotPage( + snapshot: WorkspaceDirectorySnapshot, + payload: Awaited>, + firstPage: boolean, +): void { + if (firstPage && payload.sync?.mode !== "changes") snapshot.workspaces.clear(); + if (firstPage) (snapshot.syncModes ??= {}).workspaces = payload.sync?.mode ?? "snapshot"; + for (const entry of payload.entries) { + const workspace = normalizeWorkspaceDescriptor(entry); + snapshot.workspaces.set(workspace.id, workspace); + (snapshot.touchedWorkspaceIds ??= new Set()).add(workspace.id); + (snapshot.touchedProjectIds ??= new Set()).add(workspace.projectId); + } + for (const removal of payload.sync?.removals ?? []) { + snapshot.workspaces.delete(removal.id); + (snapshot.touchedWorkspaceIds ??= new Set()).add(removal.id); + } + if (payload.sync) { + (snapshot.syncCursors ??= {}).workspaces = { + generation: payload.sync.generation, + afterSeq: payload.sync.headSeq, + }; + } + for (const entry of payload.emptyProjects ?? []) { + const project = normalizeProjectDescriptor(entry); + snapshot.projects.set(project.projectId, project); + (snapshot.touchedProjectIds ??= new Set()).add(project.projectId); + } +} + export interface DirectoryConnection { client: DaemonClient | null; status: "online" | "offline"; @@ -79,15 +111,12 @@ export interface DirectoryCheckpointStorage { readAgent(serverId: string, agentId: string): Promise; readWorkspace(serverId: string, workspaceId: string): Promise; readDirectory(serverId: string): Promise; - commitDirectory( + commitDirectoryMutations( serverId: string, - directory: { - agents: Map; - workspaces: Map; - projects: Map; - checkpoint?: DirectoryCheckpoint; - }, + mutations: readonly DirectoryReplicaMutation[], + checkpoint?: DirectoryCheckpoint, ): void; + replaceDirectoryBaseline?(serverId: string, directory: CachedDirectory): void; } export interface RefreshAgentDirectoryInput { @@ -139,7 +168,9 @@ export class DirectorySync { }, private readonly checkpoints?: DirectoryCheckpointStorage, ) { - this.agents = new AgentDirectoryReplica(serverId, callbacks.onAgentStoppedRunning); + const persist = (mutations: readonly DirectoryReplicaMutation[]) => + this.checkpoints?.commitDirectoryMutations(this.serverId, mutations); + this.agents = new AgentDirectoryReplica(serverId, callbacks.onAgentStoppedRunning, persist); this.workspaces = new WorkspaceDirectoryReplica(serverId); } @@ -170,7 +201,7 @@ export class DirectorySync { if (!recorded) { this.agents.applyDelta(message.payload); this.noteLiveCursor("agents", message.payload); - this.persistDirectory(); + this.persistCheckpoint(); } }), client.on("workspace_update", (message) => { @@ -179,9 +210,9 @@ export class DirectorySync { this.workspaceRevision += 1; const recorded = this.workspaceTransactions.record(source, message.payload); if (!recorded) { - this.workspaces.applyDelta(message.payload); + this.applyWorkspaceDelta(message.payload); this.noteLiveCursor("workspaces", message.payload); - this.persistDirectory(); + this.persistCheckpoint(); } }), client.on("project.update", (message) => { @@ -190,9 +221,9 @@ export class DirectorySync { this.workspaceRevision += 1; const recorded = this.workspaceTransactions.record(source, message.payload); if (!recorded) { - this.workspaces.applyDelta(message.payload); + this.applyWorkspaceDelta(message.payload); this.noteLiveCursor("projects", message.payload); - this.persistDirectory(); + this.persistCheckpoint(); } }), client.on("script_status_update", (message) => { @@ -205,22 +236,22 @@ export class DirectorySync { }; const recorded = this.workspaceTransactions.record(source, delta); if (!recorded) { - this.workspaces.applyDelta(delta); - this.persistDirectory(); + this.applyWorkspaceDelta(delta); + this.persistCheckpoint(); } }), client.on("agent_deleted", (message) => { if (message.type === "agent_deleted" && this.isCurrent(client, source)) { this.revision += 1; this.agents.remove(message.payload.agentId); - this.persistDirectory(); + this.persistCheckpoint(); } }), client.on("agent_archived", (message) => { if (message.type === "agent_archived" && this.isCurrent(client, source)) { this.revision += 1; this.agents.archive(message.payload.agentId, message.payload.archivedAt); - this.persistDirectory(); + this.persistCheckpoint(); } }), ]; @@ -352,24 +383,9 @@ export class DirectorySync { if (!checkpoints) return Promise.resolve(); this.cacheLoad ??= (async () => { const revision = this.revision; - const initial = useSessionStore.getState().sessions[this.serverId]; - if (!initial) return; - const initialAgents = initial.agents; - const initialWorkspaces = initial.workspaces; - const initialProjects = initial.projects; - const pristine = - initialAgents.size === 0 && initialWorkspaces.size === 0 && initialProjects.size === 0; const cached = await checkpoints.readDirectory(this.serverId); - if (this.cacheAccepted || !pristine || this.revision !== revision) return; - const session = useSessionStore.getState().sessions[this.serverId]; - if ( - !session || - session.agents !== initialAgents || - session.workspaces !== initialWorkspaces || - session.projects !== initialProjects - ) { - return; - } + if (this.cacheAccepted || this.revision !== revision) return; + if (!useSessionStore.getState().sessions[this.serverId]) return; this.agents.commitCached(cached.agents); this.workspaces.commitCached(cached); this.cursors = cached.checkpoint ?? {}; @@ -378,6 +394,21 @@ export class DirectorySync { return this.cacheLoad; } + restoreCachedDirectory(): Promise { + return this.loadCachedDirectory(); + } + + applyAgentTurnLiveness( + agentId: string, + transition: TurnLivenessTransition | readonly TurnLivenessTransition[], + ): void { + this.agents.applyTurnLiveness(agentId, transition); + } + + acceptAgent(agent: Agent): Agent { + return this.agents.accept(agent); + } + async fetchTimeline( agentId: string, request: Parameters[1], @@ -387,7 +418,7 @@ export class DirectorySync { const page = await fetchAgentTimelineOnce(client, agentId, request); if (page.agent && this.agents.submitTimelineAgent(token, page.agent)) { this.revision += 1; - this.persistDirectory(); + this.persistCheckpoint(); } return page; } @@ -452,14 +483,25 @@ export class DirectorySync { throw new DirectoryRefreshSupersededError("agent completion was superseded"); } if (completion.snapshot.legacy) { - const store = useSessionStore.getState(); const workspaces = buildLegacyWorkspaces(completion.snapshot.entries); - store.setWorkspaces(this.serverId, workspaces); - store.setProjects( - this.serverId, - Array.from(workspaces.values(), legacyProjectDescriptorFromWorkspace), + const previous = this.readWorkspaceState(); + this.workspaces.commitSnapshot( + { + workspaces, + projects: new Map( + Array.from(workspaces.values(), legacyProjectDescriptorFromWorkspace).map( + (project) => [project.projectId, project], + ), + ), + }, + [], + ); + const next = this.workspaces.snapshot(); + this.persistWorkspaceChanges( + previous, + new Set([...previous.workspaces.keys(), ...next.workspaces.keys()]), + new Set([...previous.projects.keys(), ...next.projects.keys()]), ); - store.setHasHydratedWorkspaces(this.serverId, true); } const deltas = completion.snapshot.legacy ? completion.deltas.map((delta) => @@ -471,7 +513,7 @@ export class DirectorySync { this.revision += 1; const agents = this.commitAgentSnapshot(completion.snapshot, deltas); this.persistAgentCursors(completion.snapshot, completion.deltas); - this.persistDirectory(); + this.persistCheckpoint(); this.callbacks.markAgentReady(); return { agents, subscriptionId: completion.snapshot.subscriptionId }; } catch (error) { @@ -500,13 +542,16 @@ export class DirectorySync { workspaces: new Map(useSessionStore.getState().sessions[this.serverId]?.workspaces), projects: new Map(useSessionStore.getState().sessions[this.serverId]?.projects), syncCursors: {}, + syncModes: {}, + touchedWorkspaceIds: new Set(), + touchedProjectIds: new Set(), })); try { await this.waitForSessionMetadata(client, source); const serverInfo = useSessionStore.getState().sessions[this.serverId]?.serverInfo; if (serverInfo?.features?.workspaceMultiplicity !== true) { const deltas = this.workspaceTransactions.fail(transaction); - if (deltas) for (const delta of deltas) this.workspaces.applyDelta(delta); + if (deltas) for (const delta of deltas) this.applyWorkspaceDelta(delta); return; } const supportsProjectList = serverInfo.features?.projectList === true; @@ -521,11 +566,15 @@ export class DirectorySync { input?.subscribe === true, supportsDirectorySync, ); - if (!supportsProjectList) this.buildLegacyProjectSnapshot(transaction.snapshot); + if (!supportsProjectList) { + this.buildLegacyProjectSnapshot(transaction.snapshot); + (transaction.snapshot.syncModes ??= {}).projects = + transaction.snapshot.syncModes?.workspaces; + } this.completeWorkspaceRefresh(client, source, transaction); } catch (error) { const deltas = this.workspaceTransactions.fail(transaction); - if (deltas) for (const delta of deltas) this.workspaces.applyDelta(delta); + if (deltas) for (const delta of deltas) this.applyWorkspaceDelta(delta); throw error; } } @@ -547,26 +596,7 @@ export class DirectorySync { ...(supportsDirectorySync ? { sync: this.readCursors().workspaces ?? {} } : {}), }); this.assertWorkspaceTransactionCurrent(client, source, transaction); - if (cursor === null && payload.sync?.mode !== "changes") { - transaction.snapshot.workspaces.clear(); - } - for (const entry of payload.entries) { - const workspace = normalizeWorkspaceDescriptor(entry); - transaction.snapshot.workspaces.set(workspace.id, workspace); - } - for (const removal of payload.sync?.removals ?? []) { - transaction.snapshot.workspaces.delete(removal.id); - } - if (payload.sync) { - (transaction.snapshot.syncCursors ??= {}).workspaces = { - generation: payload.sync.generation, - afterSeq: payload.sync.headSeq, - }; - } - for (const entry of payload.emptyProjects ?? []) { - const project = normalizeProjectDescriptor(entry); - transaction.snapshot.projects.set(project.projectId, project); - } + applyWorkspaceSnapshotPage(transaction.snapshot, payload, cursor === null); if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) return; cursor = payload.pageInfo.nextCursor; subscribe = false; @@ -587,6 +617,11 @@ export class DirectorySync { this.refreshAgents({ subscribe: {} }), this.refreshWorkspaces({ subscribe: true }), ]); + this.checkpoints?.replaceDirectoryBaseline?.(this.serverId, { + agents: this.agents.snapshot(), + ...this.workspaces.snapshot(), + checkpoint: this.cursors, + }); if (this.getOnlineConnection()) await this.connectWorkspaceLabels(); } @@ -600,6 +635,39 @@ export class DirectorySync { }); } + acceptWorkspaces(workspaces: readonly WorkspaceDescriptor[]): void { + this.checkpoints?.commitDirectoryMutations( + this.serverId, + this.workspaces.acceptWorkspaces(workspaces), + ); + } + + archiveAgent(agentId: string, archivedAt: string): void { + this.agents.archive(agentId, archivedAt); + } + + removeAgent(agentId: string): void { + this.agents.remove(agentId); + } + + acceptProject(project: ProjectDescriptor): void { + this.checkpoints?.commitDirectoryMutations( + this.serverId, + this.workspaces.acceptProject(project), + ); + } + + removeWorkspace(workspaceId: string): void { + this.checkpoints?.commitDirectoryMutations( + this.serverId, + this.workspaces.removeWorkspaceSnapshot(workspaceId), + ); + } + + markWorkspacesHydrated(hydrated: boolean): void { + useSessionStore.getState().setHasHydratedWorkspaces(this.serverId, hydrated); + } + private async fetchAgents( client: DaemonClient, source: DirectorySourceToken, @@ -709,12 +777,15 @@ export class DirectorySync { ); this.assertWorkspaceTransactionCurrent(client, source, transaction); if (payload.sync?.mode !== "changes") transaction.snapshot.projects.clear(); + (transaction.snapshot.syncModes ??= {}).projects = payload.sync?.mode ?? "snapshot"; for (const entry of payload.projects) { const project = normalizeProjectDescriptor(entry); transaction.snapshot.projects.set(project.projectId, project); + (transaction.snapshot.touchedProjectIds ??= new Set()).add(project.projectId); } for (const removal of payload.sync?.removals ?? []) { transaction.snapshot.projects.delete(removal.id); + (transaction.snapshot.touchedProjectIds ??= new Set()).add(removal.id); } if (payload.sync) { (transaction.snapshot.syncCursors ??= {}).projects = { @@ -738,7 +809,22 @@ export class DirectorySync { } this.revision += 1; this.workspaceRevision += 1; - this.workspaces.commitSnapshot(completion.snapshot, completion.deltas); + const previous = this.readWorkspaceState(); + const deltaMutations = this.workspaces.commitSnapshot(completion.snapshot, completion.deltas); + const next = this.workspaces.snapshot(); + const workspaceIds = + completion.snapshot.syncModes?.workspaces === "changes" + ? new Set(completion.snapshot.touchedWorkspaceIds) + : new Set([...previous.workspaces.keys(), ...next.workspaces.keys()]); + const projectIds = + completion.snapshot.syncModes?.projects === "changes" + ? new Set(completion.snapshot.touchedProjectIds) + : new Set([...previous.projects.keys(), ...next.projects.keys()]); + for (const mutation of deltaMutations) { + if (mutation.kind === "workspace") workspaceIds.add(mutation.id); + if (mutation.kind === "project") projectIds.add(mutation.id); + } + this.persistWorkspaceChanges(previous, workspaceIds, projectIds); for (const [entity, cursor] of Object.entries(completion.snapshot.syncCursors ?? {})) { if (cursor) this.writeCursor(entity as "projects" | "workspaces", cursor); } @@ -747,19 +833,41 @@ export class DirectorySync { const entity = "projectId" in delta || "project" in delta ? "projects" : "workspaces"; this.noteLiveCursor(entity, delta); } - this.persistDirectory(); + this.persistCheckpoint(); } - private persistDirectory(): void { - if (!this.checkpoints) return; - const session = useSessionStore.getState().sessions[this.serverId]; - if (!session) return; - this.checkpoints.commitDirectory(this.serverId, { - agents: session.agents, - workspaces: session.workspaces, - projects: session.projects, - checkpoint: this.cursors, - }); + private persistCheckpoint(): void { + this.checkpoints?.commitDirectoryMutations(this.serverId, [], this.cursors); + } + + private readWorkspaceState(): WorkspaceDirectorySnapshot { + return this.workspaces.snapshot(); + } + + private applyWorkspaceDelta(delta: WorkspaceDirectoryDelta): void { + const mutations = this.workspaces.applyDelta(delta); + this.checkpoints?.commitDirectoryMutations(this.serverId, mutations); + } + + private persistWorkspaceChanges( + previous: WorkspaceDirectorySnapshot, + workspaceIds: Set, + projectIds: Set, + ): void { + const next = this.workspaces.snapshot(); + const mutations: DirectoryReplicaMutation[] = []; + for (const id of workspaceIds) { + const value = next.workspaces.get(id); + if (value) mutations.push({ kind: "workspace", type: "upsert", id, value }); + else if (previous.workspaces.has(id)) + mutations.push({ kind: "workspace", type: "delete", id }); + } + for (const id of projectIds) { + const value = next.projects.get(id); + if (value) mutations.push({ kind: "project", type: "upsert", id, value }); + else if (previous.projects.has(id)) mutations.push({ kind: "project", type: "delete", id }); + } + this.checkpoints?.commitDirectoryMutations(this.serverId, mutations); } private recordAgentSync( @@ -858,7 +966,7 @@ export class DirectorySync { private flushAbortedTransactions(): void { for (const delta of this.agentTransactions.abort()) this.agents.applyDelta(delta); - for (const delta of this.workspaceTransactions.abort()) this.workspaces.applyDelta(delta); + for (const delta of this.workspaceTransactions.abort()) this.applyWorkspaceDelta(delta); } private abortPendingSessionWaits(): void { diff --git a/packages/app/src/runtime/directory-sync/internal/agent-store.ts b/packages/app/src/runtime/directory-sync/internal/agent-store.ts new file mode 100644 index 0000000000..9be2192824 --- /dev/null +++ b/packages/app/src/runtime/directory-sync/internal/agent-store.ts @@ -0,0 +1,205 @@ +import equal from "fast-deep-equal"; +import type { FetchAgentsEntry } from "@getpaseo/client/internal/daemon-client"; +import { useSessionStore, type Agent } from "@/stores/session-store"; +import { acceptAgentDirectoryUpdate } from "@/utils/agent-directory-update-policy"; +import { buildAgentDirectoryState, type AgentDirectoryDelta } from "@/utils/agent-directory-sync"; +import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agent-snapshots"; +import { resolveProjectPlacement } from "@/utils/project-placement"; +import { clearArchiveAgentPending } from "@/hooks/use-archive-agent"; +import { queryClient } from "@/data/query-client"; +import { buildDraftStoreKey } from "@/stores/draft-keys"; +import { useDraftStore } from "@/stores/draft-store"; +import { getInitDeferred, getInitKey, rejectInitDeferred } from "@/utils/agent-initialization"; +import { reduceTurnLiveness, type TurnLivenessTransition } from "@/timeline/turn-liveness"; + +export class AgentStoreProjection { + constructor(private readonly serverId: string) {} + + snapshot(): Map { + return useSessionStore.getState().sessions[this.serverId]?.agents ?? new Map(); + } + + get(agentId: string): Agent | undefined { + const session = useSessionStore.getState().sessions[this.serverId]; + return session?.agents.get(agentId) ?? session?.agentDetails.get(agentId); + } + + commitCached(agents: Map): Map { + const merged = new Map(agents); + for (const [agentId, agent] of this.snapshot()) merged.set(agentId, agent); + for (const agent of merged.values()) this.publishActivity(agent); + useSessionStore.getState().setAgents(this.serverId, merged); + return merged; + } + + publishActivity(agent: Agent): void { + useSessionStore.getState().setAgentLastActivity(agent.id, agent.lastActivityAt); + } + + accept(agent: Agent): Agent { + let accepted = agent; + useSessionStore.getState().setAgents(this.serverId, (current) => { + const previous = current.get(agent.id); + accepted = acceptAgentDirectoryUpdate(previous, agent); + if (accepted === previous) return current; + const next = new Map(current); + next.set(agent.id, accepted); + return next; + }); + return accepted; + } + + applyDelta(delta: AgentDirectoryDelta): { + agentId: string; + stoppedRunning: boolean; + agent?: Agent; + } { + if (delta.kind === "remove") { + this.remove(delta.agentId); + return { agentId: delta.agentId, stoppedRunning: false }; + } + const normalized = normalizeAgentSnapshot(delta.agent, this.serverId); + const session = useSessionStore.getState().sessions[this.serverId]; + const previous = session?.agents.get(normalized.id) ?? session?.agentDetails.get(normalized.id); + const legacyWorkspaceId = + previous?.workspaceId ?? + Array.from(session?.workspaces.values() ?? []).find( + (workspace) => + session?.serverInfo?.features?.workspaceMultiplicity !== true && + workspace.workspaceDirectory === normalized.cwd, + )?.id; + const accepted = this.accept({ + ...normalized, + workspaceId: normalized.workspaceId ?? legacyWorkspaceId, + projectPlacement: + resolveProjectPlacement({ projectPlacement: delta.project, cwd: normalized.cwd }) ?? + previous?.projectPlacement, + }); + if (accepted.archivedAt) { + clearArchiveAgentPending({ queryClient, serverId: this.serverId, agentId: accepted.id }); + } + this.replacePendingPermissions(accepted); + useSessionStore.getState().setAgentLastActivity(accepted.id, accepted.lastActivityAt); + return { + agentId: accepted.id, + stoppedRunning: previous?.turn.phase === "open" && accepted.turn.phase === "idle", + agent: accepted, + }; + } + + replaceFetched(entries: FetchAgentsEntry[]): Map { + const { agents: fetched, pendingPermissions } = buildAgentDirectoryState({ + serverId: this.serverId, + entries, + }); + const store = useSessionStore.getState(); + const current = store.sessions[this.serverId]?.agents ?? new Map(); + const agents = new Map(); + for (const [agentId, fetchedAgent] of fetched) { + const existing = current.get(agentId); + agents.set(agentId, existing && equal(existing, fetchedAgent) ? existing : fetchedAgent); + if (fetchedAgent.archivedAt) { + clearArchiveAgentPending({ queryClient, serverId: this.serverId, agentId }); + } + } + store.setAgents(this.serverId, agents); + store.setAgentDetails(this.serverId, (previous) => { + let next: Map | null = null; + for (const agentId of agents.keys()) { + if (!previous.has(agentId)) continue; + next ??= new Map(previous); + next.delete(agentId); + } + return next ?? previous; + }); + store.setAgentLastActivityBatch( + new Map(Array.from(agents.values(), (agent) => [agent.id, agent.lastActivityAt])), + ); + store.setPendingPermissions(this.serverId, new Map(pendingPermissions)); + store.setHasHydratedAgents(this.serverId, true); + return agents; + } + + replacePendingPermissions(agent: Agent): void { + const pending = new Map(useSessionStore.getState().sessions[this.serverId]?.pendingPermissions); + for (const [key, entry] of pending) if (entry.agentId === agent.id) pending.delete(key); + for (const request of agent.pendingPermissions) { + const key = derivePendingPermissionKey(agent.id, request); + pending.set(key, { key, agentId: agent.id, request }); + } + useSessionStore.getState().setPendingPermissions(this.serverId, pending); + } + + remove(agentId: string): void { + const store = useSessionStore.getState(); + const removeKey = (current: Map): Map => { + if (!current.has(agentId)) return current; + const next = new Map(current); + next.delete(agentId); + return next; + }; + clearArchiveAgentPending({ queryClient, serverId: this.serverId, agentId }); + store.setAgents(this.serverId, removeKey); + store.setAgentDetails(this.serverId, removeKey); + store.setQueuedMessages(this.serverId, removeKey); + store.setAgentTimelineCursor(this.serverId, removeKey); + store.setInitializingAgents(this.serverId, removeKey); + store.setPendingPermissions(this.serverId, (current) => { + const next = new Map(current); + for (const [key, pending] of next) if (pending.agentId === agentId) next.delete(key); + return next.size === current.size ? current : next; + }); + store.setAgentAuthoritativeHistoryApplied(this.serverId, agentId, false); + store.setAgentStreamTail(this.serverId, removeKey); + store.clearAgentStreamHead(this.serverId, agentId); + useSessionStore.setState((state) => { + if (!state.agentLastActivity.has(agentId)) return state; + const agentLastActivity = new Map(state.agentLastActivity); + agentLastActivity.delete(agentId); + return { ...state, agentLastActivity }; + }); + useDraftStore.getState().clearDraftInput({ + draftKey: buildDraftStoreKey({ serverId: this.serverId, agentId }), + }); + const initKey = getInitKey(this.serverId, agentId); + if (getInitDeferred(initKey)) { + rejectInitDeferred(initKey, new Error("Agent was removed during initialization")); + } + } + + archive(agentId: string, archivedAt: string): Agent | null { + let archived: Agent | null = null; + useSessionStore.getState().setAgents(this.serverId, (current) => { + const agent = current.get(agentId); + if (!agent) return current; + archived = { + ...agent, + turn: { phase: "idle", cancellationRequestId: null }, + archivedAt: new Date(archivedAt), + }; + const next = new Map(current); + next.set(agentId, archived); + return next; + }); + return archived; + } + + applyTurn( + agentId: string, + transition: TurnLivenessTransition | readonly TurnLivenessTransition[], + ): Agent | null { + const transitions = Array.isArray(transition) ? transition : [transition]; + let accepted: Agent | null = null; + useSessionStore.getState().setAgents(this.serverId, (current) => { + const agent = current.get(agentId); + if (!agent) return current; + const turn = transitions.reduce(reduceTurnLiveness, agent.turn); + if (turn === agent.turn) return current; + accepted = { ...agent, turn }; + const next = new Map(current); + next.set(agentId, accepted); + return next; + }); + return accepted; + } +} diff --git a/packages/app/src/runtime/directory-sync/workspace-replica.ts b/packages/app/src/runtime/directory-sync/workspace-replica.ts index bc3a36fd56..4d35c765db 100644 --- a/packages/app/src/runtime/directory-sync/workspace-replica.ts +++ b/packages/app/src/runtime/directory-sync/workspace-replica.ts @@ -16,11 +16,11 @@ import { shouldSuppressWorkspaceForLocalArchive, } from "@/contexts/session-workspace-upserts"; import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity"; +import type { DirectoryReplicaMutation } from "@/runtime/client-replica"; export type WorkspaceDirectoryDelta = | Extract["payload"] | { kind: "script_status"; update: ScriptStatusUpdateMessage["payload"] }; -type ProjectDirectoryDelta = Extract["payload"]; export interface WorkspaceDirectorySnapshot { workspaces: Map; @@ -28,67 +28,33 @@ export interface WorkspaceDirectorySnapshot { syncCursors?: Partial< Record<"projects" | "workspaces", { generation: string; afterSeq: number }> >; -} - -function patchWorkspaceScripts( - workspaces: Map, - update: ScriptStatusUpdateMessage["payload"], -): Map { - const workspaceKey = resolveWorkspaceMapKeyByIdentity({ - workspaces, - workspaceId: update.workspaceId, - }); - if (!workspaceKey) return workspaces; - const existing = workspaces.get(workspaceKey); - if (!existing || equal(existing.scripts, update.scripts)) return workspaces; - const next = new Map(workspaces); - next.set(workspaceKey, { - ...existing, - scripts: update.scripts.map((script) => ({ ...script })), - }); - return next; -} - -function applyProjectDelta( - snapshot: WorkspaceDirectorySnapshot, - delta: ProjectDirectoryDelta, -): void { - if (delta.kind === "remove") { - snapshot.projects.delete(delta.projectId); - for (const [workspaceId, workspace] of snapshot.workspaces) { - if (workspace.projectId === delta.projectId) snapshot.workspaces.delete(workspaceId); - } - return; - } - - const project = normalizeProjectDescriptor(delta.project); - snapshot.projects.set(project.projectId, project); - for (const [workspaceId, workspace] of snapshot.workspaces) { - if (workspace.projectId !== project.projectId) continue; - snapshot.workspaces.set(workspaceId, { - ...workspace, - projectDisplayName: project.projectDisplayName, - projectCustomName: project.projectCustomName, - projectCustomIconRevision: project.projectCustomIconRevision, - projectRootPath: project.projectRootPath, - projectKind: project.projectKind, - }); - } + syncModes?: Partial>; + touchedWorkspaceIds?: Set; + touchedProjectIds?: Set; } export class WorkspaceDirectoryReplica { + private workspaces = new Map(); + private projects = new Map(); + private workspaceIdsByProject = new Map>(); + constructor(private readonly serverId: string) {} - applyDelta(delta: WorkspaceDirectoryDelta): void { - const state = this.reconcile(this.read(), [delta]); - this.commit(state, delta.kind === "remove" && "id" in delta ? [delta.id] : []); + applyDelta(delta: WorkspaceDirectoryDelta): DirectoryReplicaMutation[] { + if (delta.kind === "script_status") return this.applyScriptStatus(delta.update); + if ("projectId" in delta || "project" in delta) return this.applyProjectDelta(delta); + if (delta.kind === "remove") return this.removeWorkspace(delta); + return this.upsertWorkspace(normalizeWorkspaceDescriptor(delta.workspace)); } commitCached(input: { workspaces: Map; projects: Map; }): void { - this.commit(input, []); + this.replace({ + workspaces: new Map([...input.workspaces, ...this.workspaces]), + projects: new Map([...input.projects, ...this.projects]), + }); useSessionStore.getState().setHasWorkspaceDirectorySnapshot(this.serverId, true); } @@ -97,79 +63,171 @@ export class WorkspaceDirectoryReplica { project: ProjectDescriptor | undefined, ): void { if (shouldSuppressWorkspaceForLocalArchive({ serverId: this.serverId, workspace })) return; - const snapshot = this.read(); - snapshot.workspaces.set(workspace.id, workspace); - if (project) snapshot.projects.set(project.projectId, project); - this.commit(snapshot, []); + if (project) this.setProject(project); + this.setWorkspace(workspace); } commitSnapshot( snapshot: WorkspaceDirectorySnapshot, deltas: readonly WorkspaceDirectoryDelta[], - ): void { - const removedWorkspaceIds = deltas.flatMap((delta) => - delta.kind === "remove" && "id" in delta ? [delta.id] : [], - ); - this.commit(this.reconcile(snapshot, deltas), removedWorkspaceIds); + ): DirectoryReplicaMutation[] { + this.replace(snapshot); + const mutations = deltas.flatMap((delta) => this.applyDelta(delta)); useSessionStore.getState().setHasHydratedWorkspaces(this.serverId, true); + return mutations; } - private read(): WorkspaceDirectorySnapshot { - const session = useSessionStore.getState().sessions[this.serverId]; - return { - workspaces: new Map(session?.workspaces), - projects: new Map(session?.projects), - }; + snapshot(): WorkspaceDirectorySnapshot { + return { workspaces: this.workspaces, projects: this.projects }; } - private reconcile( - snapshot: WorkspaceDirectorySnapshot, - deltas: readonly WorkspaceDirectoryDelta[], - ): WorkspaceDirectorySnapshot { - let workspaces = new Map(snapshot.workspaces); - const projects = new Map(snapshot.projects); - for (const [workspaceId, workspace] of workspaces) { - if (shouldSuppressWorkspaceForLocalArchive({ serverId: this.serverId, workspace })) { - workspaces.delete(workspaceId); + acceptWorkspaces(workspaces: readonly WorkspaceDescriptor[]): DirectoryReplicaMutation[] { + return workspaces.flatMap((workspace) => this.upsertWorkspace(workspace)); + } + + acceptProject(project: ProjectDescriptor): DirectoryReplicaMutation[] { + this.setProject(project); + return [{ kind: "project", type: "upsert", id: project.projectId, value: project }]; + } + + removeWorkspaceSnapshot(workspaceId: string): DirectoryReplicaMutation[] { + this.deleteWorkspace(workspaceId); + return [{ kind: "workspace", type: "delete", id: workspaceId }]; + } + + private replace(snapshot: WorkspaceDirectorySnapshot): void { + const workspaces = new Map(); + for (const [workspaceId, workspace] of snapshot.workspaces) { + if (!shouldSuppressWorkspaceForLocalArchive({ serverId: this.serverId, workspace })) { + workspaces.set(workspaceId, workspace); } } - for (const delta of deltas) { - if (delta.kind === "script_status") { - workspaces = patchWorkspaceScripts(workspaces, delta.update); - continue; - } - if ("projectId" in delta || "project" in delta) { - applyProjectDelta({ workspaces, projects }, delta); - continue; - } - if (delta.kind === "remove") { - workspaces.delete(delta.id); - if (delta.emptyProject) { - const project = normalizeProjectDescriptor(delta.emptyProject); - projects.set(project.projectId, project); - } - if (delta.removedProjectId) { - projects.delete(delta.removedProjectId); - } - continue; - } - const workspace = normalizeWorkspaceDescriptor(delta.workspace); - if (shouldSuppressWorkspaceForLocalArchive({ serverId: this.serverId, workspace })) { - workspaces.delete(workspace.id); - } else { - workspaces.set(workspace.id, workspace); - } - } - return { workspaces, projects }; - } - - private commit(snapshot: WorkspaceDirectorySnapshot, removedWorkspaceIds: string[]): void { + this.workspaces = workspaces; + this.projects = new Map(snapshot.projects); + this.rebuildProjectIndex(); const store = useSessionStore.getState(); - store.setWorkspaces(this.serverId, snapshot.workspaces); - store.setProjects(this.serverId, snapshot.projects.values()); - for (const workspaceId of removedWorkspaceIds) { - clearWorkspaceArchivePending({ serverId: this.serverId, workspaceId }); - useWorkspaceSetupStore.getState().removeWorkspace({ serverId: this.serverId, workspaceId }); + store.setWorkspaces(this.serverId, this.workspaces); + store.setProjects(this.serverId, this.projects.values()); + } + + private applyScriptStatus( + update: ScriptStatusUpdateMessage["payload"], + ): DirectoryReplicaMutation[] { + const workspaceId = resolveWorkspaceMapKeyByIdentity({ + workspaces: this.workspaces, + workspaceId: update.workspaceId, + }); + const workspace = workspaceId ? this.workspaces.get(workspaceId) : undefined; + if (!workspace || equal(workspace.scripts, update.scripts)) return []; + const next = { ...workspace, scripts: update.scripts.map((script) => ({ ...script })) }; + this.setWorkspace(next); + return [{ kind: "workspace", type: "upsert", id: next.id, value: next }]; + } + + private applyProjectDelta( + delta: Extract["payload"], + ): DirectoryReplicaMutation[] { + if (delta.kind === "remove") { + const mutations: DirectoryReplicaMutation[] = [ + { kind: "project", type: "delete", id: delta.projectId }, + ]; + this.projects.delete(delta.projectId); + useSessionStore.getState().removeProject(this.serverId, delta.projectId); + for (const workspaceId of this.workspaceIdsByProject.get(delta.projectId) ?? []) { + this.deleteWorkspace(workspaceId); + mutations.push({ kind: "workspace", type: "delete", id: workspaceId }); + } + return mutations; + } + + const project = normalizeProjectDescriptor(delta.project); + this.setProject(project); + const mutations: DirectoryReplicaMutation[] = [ + { kind: "project", type: "upsert", id: project.projectId, value: project }, + ]; + for (const workspaceId of this.workspaceIdsByProject.get(project.projectId) ?? []) { + const workspace = this.workspaces.get(workspaceId); + if (!workspace) continue; + const next = { + ...workspace, + projectDisplayName: project.projectDisplayName, + projectCustomName: project.projectCustomName, + projectCustomIconRevision: project.projectCustomIconRevision, + projectRootPath: project.projectRootPath, + projectKind: project.projectKind, + }; + this.setWorkspace(next); + mutations.push({ kind: "workspace", type: "upsert", id: next.id, value: next }); + } + return mutations; + } + + private removeWorkspace( + delta: Extract["payload"] & { + kind: "remove"; + }, + ): DirectoryReplicaMutation[] { + this.deleteWorkspace(delta.id); + const mutations: DirectoryReplicaMutation[] = [ + { kind: "workspace", type: "delete", id: delta.id }, + ]; + if (delta.emptyProject) { + const project = normalizeProjectDescriptor(delta.emptyProject); + this.setProject(project); + mutations.push({ kind: "project", type: "upsert", id: project.projectId, value: project }); + } + if (delta.removedProjectId) { + this.projects.delete(delta.removedProjectId); + useSessionStore.getState().removeProject(this.serverId, delta.removedProjectId); + mutations.push({ kind: "project", type: "delete", id: delta.removedProjectId }); + } + clearWorkspaceArchivePending({ serverId: this.serverId, workspaceId: delta.id }); + useWorkspaceSetupStore + .getState() + .removeWorkspace({ serverId: this.serverId, workspaceId: delta.id }); + return mutations; + } + + private upsertWorkspace(workspace: WorkspaceDescriptor): DirectoryReplicaMutation[] { + if (shouldSuppressWorkspaceForLocalArchive({ serverId: this.serverId, workspace })) { + if (!this.workspaces.has(workspace.id)) return []; + this.deleteWorkspace(workspace.id); + return [{ kind: "workspace", type: "delete", id: workspace.id }]; + } + this.setWorkspace(workspace); + return [{ kind: "workspace", type: "upsert", id: workspace.id, value: workspace }]; + } + + private setWorkspace(workspace: WorkspaceDescriptor): void { + const previous = this.workspaces.get(workspace.id); + if (previous?.projectId !== workspace.projectId) { + this.workspaceIdsByProject.get(previous?.projectId ?? "")?.delete(workspace.id); + } + this.workspaces.set(workspace.id, workspace); + const projectWorkspaces = this.workspaceIdsByProject.get(workspace.projectId) ?? new Set(); + projectWorkspaces.add(workspace.id); + this.workspaceIdsByProject.set(workspace.projectId, projectWorkspaces); + useSessionStore.getState().mergeWorkspaces(this.serverId, [workspace]); + } + + private deleteWorkspace(workspaceId: string): void { + const workspace = this.workspaces.get(workspaceId); + if (workspace) this.workspaceIdsByProject.get(workspace.projectId)?.delete(workspaceId); + this.workspaces.delete(workspaceId); + useSessionStore.getState().removeWorkspace(this.serverId, workspaceId); + } + + private setProject(project: ProjectDescriptor): void { + this.projects.set(project.projectId, project); + useSessionStore.getState().upsertProject(this.serverId, project); + } + + private rebuildProjectIndex(): void { + this.workspaceIdsByProject = new Map(); + for (const workspace of this.workspaces.values()) { + const workspaceIds = this.workspaceIdsByProject.get(workspace.projectId) ?? new Set(); + workspaceIds.add(workspace.id); + this.workspaceIdsByProject.set(workspace.projectId, workspaceIds); } } } diff --git a/packages/app/src/runtime/host-runtime.test.ts b/packages/app/src/runtime/host-runtime.test.ts index 431b806aae..26acf6f22b 100644 --- a/packages/app/src/runtime/host-runtime.test.ts +++ b/packages/app/src/runtime/host-runtime.test.ts @@ -21,7 +21,8 @@ import { type HostRuntimeControllerDeps, type HostRuntimeStorage, } from "./host-runtime"; -import type { ReplicaRow, ReplicaRowStore } from "./replica-cache/row-store"; +import type { ClientReplica } from "./client-replica"; +import { drainQueuedAgentMessage } from "./session-data"; class FakeDaemonClient { private state: ConnectionState = { status: "idle" }; @@ -31,6 +32,8 @@ class FakeDaemonClient { private latencyMeasurementFailure: Error | null = null; private latencyMeasurementsRequested: Array<{ timeoutMs?: number }> = []; public connectCalls = 0; + public ensureConnectedCalls = 0; + public reconnectEnabledChanges: boolean[] = []; public fetchAgentsCalls: FetchAgentsOptions[] = []; public fetchAgentsResponses: Array< Awaited> | ReturnType @@ -105,6 +108,7 @@ class FakeDaemonClient { } ensureConnected(): void { + this.ensureConnectedCalls += 1; if (this.state.status !== "connected") { this.setConnectionState({ status: "connected" }); } @@ -170,7 +174,9 @@ class FakeDaemonClient { return result.rttMs; } - setReconnectEnabled(_enabled: boolean): void {} + setReconnectEnabled(enabled: boolean): void { + this.reconnectEnabledChanges.push(enabled); + } getLastLivenessRttMs(): number | null { return this.heartbeatRttMs; @@ -455,50 +461,6 @@ function createMemoryHostRuntimeStorage(entries: Record = {}): H }; } -function createMemoryReplicaRowStore(): ReplicaRowStore { - const rows = new Map(); - const keyOf = (row: Pick) => - `${row.serverId}:${row.kind}:${row.id}`; - return { - open: async () => undefined, - read: async (serverId, kinds, ids) => { - const acceptedKinds = new Set(kinds); - const acceptedIds = ids ? new Set(ids) : null; - return [...rows.values()].filter( - (row) => - row.serverId === serverId && - acceptedKinds.has(row.kind) && - (!acceptedIds || acceptedIds.has(row.id)), - ); - }, - readAll: async () => { - const hosts = new Map(); - for (const row of rows.values()) { - const hostRows = hosts.get(row.serverId) ?? []; - hostRows.push(row); - hosts.set(row.serverId, hostRows); - } - return [...hosts].map(([serverId, hostRows]) => ({ serverId, rows: hostRows })); - }, - apply: async (changes) => { - for (const key of changes.deletes) rows.delete(keyOf(key)); - for (const row of changes.upserts) rows.set(keyOf(row), row); - }, - deleteHost: async (serverId) => { - for (const [key, row] of rows) if (row.serverId === serverId) rows.delete(key); - }, - renameHost: async (oldServerId, newServerId) => { - for (const [key, row] of rows) { - if (row.serverId !== oldServerId) continue; - rows.delete(key); - const renamed = { ...row, serverId: newServerId }; - rows.set(keyOf(renamed), renamed); - } - }, - clear: async () => rows.clear(), - }; -} - function createAppearanceStore(storage: HostRuntimeStorage): HostRuntimeStore { return new HostRuntimeStore({ storage, @@ -815,6 +777,52 @@ describe("HostRuntimeController", () => { expect(activeClient.latencyMeasurements()).toEqual([]); }); + it("does not create a probe client for the selected connection while it reconnects", async () => { + useHostRuntimeClock(); + const relay: HostConnection = { + id: "relay:relay.paseo.sh:443", + type: "relay", + relayEndpoint: "relay.paseo.sh:443", + daemonPublicKeyB64: "pk_test", + }; + const host = makeHost({ connections: [relay], preferredConnectionId: relay.id }); + const activeClient = new FakeDaemonClient(); + activeClient.setConnectionState({ status: "connected" }); + const probeAttempts: string[] = []; + const controller = new HostRuntimeController({ + host, + deps: { + createClient: () => { + throw new Error("the existing client owns the selected connection"); + }, + connectToDaemon: async ({ connection }) => { + probeAttempts.push(connection.id); + return { + client: makeConnectedProbeClient(10) as unknown as DaemonClient, + serverId: host.serverId, + hostname: host.label ?? null, + }; + }, + getClientId: async () => "cid_test_runtime", + }, + }); + + await controller.start({ + autoProbe: false, + initialConnection: { + connectionId: relay.id, + existingClient: activeClient as unknown as DaemonClient, + }, + }); + activeClient.setConnectionState({ status: "disconnected", reason: "network lost" }); + await vi.advanceTimersByTimeAsync(10_000); + + await controller.runProbeCycleNow(); + + expect(probeAttempts).toEqual([]); + expect(controller.getSnapshot().client).toBe(activeClient as unknown as DaemonClient); + }); + it("rejects probes that resolve to a different server id", async () => { const host = makeHost({ serverId: "srv_old", @@ -1435,6 +1443,71 @@ describe("HostRuntimeController", () => { }); describe("HostRuntimeStore", () => { + it("reconnects every registered host immediately on app resume", async () => { + const relay = (suffix: string): HostConnection => ({ + id: `relay:relay-${suffix}.paseo.sh:443`, + type: "relay", + relayEndpoint: `relay-${suffix}.paseo.sh:443`, + daemonPublicKeyB64: `pk_${suffix}`, + }); + const hostAConnection = relay("a"); + const hostBConnection = relay("b"); + const hostA = makeHost({ + serverId: "srv_a", + connections: [hostAConnection], + preferredConnectionId: hostAConnection.id, + }); + const hostB = makeHost({ + serverId: "srv_b", + connections: [hostBConnection], + preferredConnectionId: hostBConnection.id, + }); + const clientA = new FakeDaemonClient(); + const clientB = new FakeDaemonClient(); + clientA.setConnectionState({ status: "connected" }); + clientB.setConnectionState({ status: "connected" }); + const store = new HostRuntimeStore({ + storage: createMemoryHostRuntimeStorage(), + deps: { + createClient: () => { + throw new Error("initial clients are supplied"); + }, + connectToDaemon: async () => { + throw new Error("single-connection hosts reuse their active clients"); + }, + getClientId: async () => "cid_test_runtime", + }, + }); + + store.syncHosts([hostA, hostB], { + initialConnectionByServerId: new Map([ + [ + hostA.serverId, + { connectionId: hostAConnection.id, existingClient: clientA as unknown as DaemonClient }, + ], + [ + hostB.serverId, + { connectionId: hostBConnection.id, existingClient: clientB as unknown as DaemonClient }, + ], + ]), + }); + await waitForHostOnline(store, hostA.serverId); + await waitForHostOnline(store, hostB.serverId); + store.setAppVisible(false, null); + expect(clientA.reconnectEnabledChanges.at(-1)).toBe(false); + expect(clientB.reconnectEnabledChanges.at(-1)).toBe(false); + clientA.setConnectionState({ status: "disconnected", reason: "backgrounded" }); + clientB.setConnectionState({ status: "disconnected", reason: "backgrounded" }); + + store.setAppVisible(true, hostA.serverId); + expect(clientA.reconnectEnabledChanges.at(-1)).toBe(true); + expect(clientB.reconnectEnabledChanges.at(-1)).toBe(true); + expect(clientA.ensureConnectedCalls).toBe(1); + expect(clientB.ensureConnectedCalls).toBe(1); + + store.syncHosts([]); + }); + it("revokes push notifications before removing a host", async () => { const host = makeHost({ connections: [makeHost().connections[0]!] }); const revocation = createDeferred(); @@ -1483,16 +1556,29 @@ describe("HostRuntimeStore", () => { expect(store.getHosts()).toEqual([]); }); - it("loads the host registry without scanning or installing replica rows", async () => { + it("loads the host registry and restores its cached directory", async () => { const host = makeHost(); const storage = createMemoryHostRuntimeStorage(); - const backingStore = createMemoryReplicaRowStore(); let fullScans = 0; - const replicaRowStore: ReplicaRowStore = { - ...backingStore, - readAll: async () => { - fullScans += 1; - return backingStore.readAll(); + const clientReplica: ClientReplica = { + hostLifecycle: { + setHosts: () => undefined, + reconcileServerId: () => undefined, + flush: async () => undefined, + }, + directory: { + readAgent: async () => undefined, + readWorkspace: async () => undefined, + readDirectory: async () => { + fullScans += 1; + return { agents: new Map(), workspaces: new Map(), projects: new Map() }; + }, + commitDirectoryMutations: () => undefined, + replaceDirectoryBaseline: () => undefined, + }, + timeline: { + readTimeline: async () => undefined, + commitTimeline: () => undefined, }, }; await storage.setItem("@paseo:daemon-registry", JSON.stringify([host])); @@ -1501,7 +1587,7 @@ describe("HostRuntimeStore", () => { const store = new HostRuntimeStore({ storage, - replicaRowStore, + clientReplica, deps: { createClient: () => { throw new Error("createClient should not be called"); @@ -1516,7 +1602,7 @@ describe("HostRuntimeStore", () => { store.boot(); await registryLoaded; - expect(fullScans).toBe(0); + expect(fullScans).toBe(1); expect(useSessionStore.getState().sessions[host.serverId]).toMatchObject({ client: null, hasHydratedAgents: false, @@ -2006,11 +2092,27 @@ describe("HostRuntimeStore", () => { new Map([ [ "legacy-snapshot", - { ...replicaAgent(snapshotAgent.agent, host.serverId), status: "running" }, + { + ...replicaAgent(snapshotAgent.agent, host.serverId), + turn: { + phase: "open", + turnId: null, + startedAt: null, + cancellationRequestId: null, + }, + }, ], [ "legacy-buffered", - { ...replicaAgent(bufferedAgent.agent, host.serverId), status: "running" }, + { + ...replicaAgent(bufferedAgent.agent, host.serverId), + turn: { + phase: "open", + turnId: null, + startedAt: null, + cancellationRequestId: null, + }, + }, ], ]), ); @@ -2545,11 +2647,27 @@ describe("HostRuntimeStore", () => { new Map([ [ "snapshot-transition", - { ...replicaAgent(snapshotAgent.agent, host.serverId), status: "running" }, + { + ...replicaAgent(snapshotAgent.agent, host.serverId), + turn: { + phase: "open", + turnId: null, + startedAt: null, + cancellationRequestId: null, + }, + }, ], [ "buffered-transition", - { ...replicaAgent(bufferedAgent.agent, host.serverId), status: "running" }, + { + ...replicaAgent(bufferedAgent.agent, host.serverId), + turn: { + phase: "open", + turnId: null, + startedAt: null, + cancellationRequestId: null, + }, + }, ], ]), ); @@ -2596,7 +2714,7 @@ describe("HostRuntimeStore", () => { const fakeClient = new FakeDaemonClient(); const send = new Deferred(); fakeClient.sendAgentMessageResponses.push(send.promise); - const store = new HostRuntimeStore({ + const _store = new HostRuntimeStore({ deps: { createClient: () => fakeClient as unknown as DaemonClient, connectToDaemon: async () => ({ @@ -2637,7 +2755,7 @@ describe("HostRuntimeStore", () => { ]), ); - store.drainQueuedAgentMessage(host.serverId, "agent"); + drainQueuedAgentMessage(host.serverId, "agent"); await fakeClient.waitForSentMessages(1); // The row and the pending submission must exist while the RPC is still in flight — @@ -2661,7 +2779,7 @@ describe("HostRuntimeStore", () => { const host = makeHost({ serverId: "srv_failed_queue_drain" }); const fakeClient = new FakeDaemonClient(); fakeClient.sendAgentMessageFailures.push(new Error("connection lost")); - const store = new HostRuntimeStore({ + const _store = new HostRuntimeStore({ deps: { createClient: () => fakeClient as unknown as DaemonClient, connectToDaemon: async () => ({ @@ -2687,7 +2805,7 @@ describe("HostRuntimeStore", () => { ]), ); - store.drainQueuedAgentMessage(host.serverId, "agent"); + drainQueuedAgentMessage(host.serverId, "agent"); await vi.waitFor(() => { expect(fakeClient.sentAgentMessages).toHaveLength(1); @@ -2707,7 +2825,7 @@ describe("HostRuntimeStore", () => { const fakeClient = new FakeDaemonClient(); const send = new Deferred(); fakeClient.sendAgentMessageResponses.push(send.promise); - const store = new HostRuntimeStore({ + const _store = new HostRuntimeStore({ deps: { createClient: () => fakeClient as unknown as DaemonClient, connectToDaemon: async () => ({ @@ -2725,8 +2843,8 @@ describe("HostRuntimeStore", () => { new Map([["agent", [{ id: "first", text: "send once", attachments: [] }]]]), ); - store.drainQueuedAgentMessage(host.serverId, "agent"); - store.drainQueuedAgentMessage(host.serverId, "agent"); + drainQueuedAgentMessage(host.serverId, "agent"); + drainQueuedAgentMessage(host.serverId, "agent"); await fakeClient.waitForSentMessages(1); expect(fakeClient.sentAgentMessages).toHaveLength(1); @@ -2742,7 +2860,7 @@ describe("HostRuntimeStore", () => { it("uses legacy GitHub attachments when draining a queue for an old daemon", async () => { const host = makeHost({ serverId: "srv_legacy_queue_attachment" }); const fakeClient = new FakeDaemonClient(); - const store = new HostRuntimeStore({ + const _store = new HostRuntimeStore({ deps: { createClient: () => fakeClient as unknown as DaemonClient, connectToDaemon: async () => ({ @@ -2792,7 +2910,7 @@ describe("HostRuntimeStore", () => { ]), ); - store.drainQueuedAgentMessage(host.serverId, "agent"); + drainQueuedAgentMessage(host.serverId, "agent"); await fakeClient.waitForSentMessages(1); expect(fakeClient.sentAgentMessages[0]?.[2]?.attachments).toEqual([ @@ -3037,12 +3155,14 @@ describe("HostRuntimeStore", () => { }).agent; const staleAgent: Agent = { ...stale, - activeTurn: stale.activeTurn + turn: stale.activeTurn ? { + phase: "open", turnId: stale.activeTurn.turnId, startedAt: stale.activeTurn.startedAt ? new Date(stale.activeTurn.startedAt) : null, + cancellationRequestId: null, } - : null, + : { phase: "idle", cancellationRequestId: null }, serverId: host.serverId, createdAt: new Date(stale.createdAt), updatedAt: new Date(stale.updatedAt), diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index fc434365dd..d8f7f7f0fc 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -55,14 +55,9 @@ import { } from "@/data/push-router"; import { mountBrowserAutomationDaemonClientHandler } from "@/desktop/browser/automation/handler"; import { schedulesQueryBaseKey } from "@/schedules/aggregated-schedules"; -import { dispatchComposerAgentMessage, sendQueuedComposerMessageNow } from "@/composer/actions"; -import { createMessageSubmissionWriter } from "@/composer/submission/writer"; -import { resolveComposerAttachmentSubmitFormat } from "@/composer/attachments/submit"; -import { encodeImages } from "@/utils/encode-images"; import { DirectorySync, type RefreshAgentDirectoryResult } from "@/runtime/directory-sync"; -import { ReplicaCache } from "@/runtime/replica-cache"; -import type { ReplicaRowStore } from "@/runtime/replica-cache/row-store"; -import { createReplicaRowStore } from "@/runtime/replica-cache/row-store-factory"; +import { createClientReplica, type ClientReplica } from "@/runtime/client-replica"; +import { registerDefaultSessionDataOwner, SessionDataOwner } from "@/runtime/session-data"; import { createTimelineReplica, createViewedTimelineOwner, @@ -612,6 +607,7 @@ export class HostRuntimeController { private switchRequestVersion = 0; private probeRequestVersion = 0; private probeCycleInFlight: Promise | null = null; + private reconnectEnabled = true; constructor(input: { host: HostProfile; @@ -721,6 +717,11 @@ export class HostRuntimeController { this.activeClient?.ensureConnected(); } + setReconnectEnabled(enabled: boolean): void { + this.reconnectEnabled = enabled; + this.activeClient?.setReconnectEnabled(enabled); + } + markAgentDirectorySyncLoading(): void { const status = this.snapshot.hasEverLoadedAgentDirectory ? "revalidating" : "initial_loading"; this.updateSnapshot({ @@ -970,10 +971,7 @@ export class HostRuntimeController { let shouldCloseClient = false; try { const activeClient = - this.snapshot.connectionStatus === "online" && - this.snapshot.activeConnectionId === connection.id - ? this.snapshot.client - : null; + this.snapshot.activeConnectionId === connection.id ? this.snapshot.client : null; if (activeClient) { connectedClient = activeClient; @@ -1226,9 +1224,6 @@ export class HostRuntimeController { } const nextGeneration = this.snapshot.clientGeneration + 1; - if (existingClient) { - existingClient.setReconnectEnabled(true); - } const client = existingClient ?? this.deps.createClient({ @@ -1237,6 +1232,7 @@ export class HostRuntimeController { clientId, runtimeGeneration: nextGeneration, }); + client.setReconnectEnabled(this.reconnectEnabled); if (!this.isSwitchStillValid(requestVersion, expectedProbeVersion)) { await client.close().catch(() => undefined); @@ -1392,24 +1388,27 @@ export class HostRuntimeStore { private deps: HostRuntimeControllerDeps; private lastConnectionStatusByServer = new Map(); private connectionStatusStartedAtByServer = new Map(); - private queuedAgentDrainInFlight = new Set(); - private directorySyncByServer = new Map(); + private readonly sessionData: SessionDataOwner; private timelineReplicaByServer = new Map(); private configuredOverrideBootstrapInFlight: Promise | null = null; private bootPromise: Promise | null = null; + private appVisible = true; private storage: HostRuntimeStorage; - private replicaCache: ReplicaCache; + private clientReplica: ClientReplica; private readonly revokePushNotifications: typeof revokePushNotifications; constructor(input?: { deps?: HostRuntimeControllerDeps; storage?: HostRuntimeStorage; - replicaRowStore?: ReplicaRowStore; + clientReplica?: ClientReplica; + sessionData?: SessionDataOwner; revokePushNotifications?: typeof revokePushNotifications; }) { this.deps = input?.deps ?? createDefaultDeps(); this.storage = input?.storage ?? AsyncStorage; - this.replicaCache = new ReplicaCache(input?.replicaRowStore ?? createReplicaRowStore()); + this.clientReplica = input?.clientReplica ?? createClientReplica(); + this.sessionData = input?.sessionData ?? new SessionDataOwner(); + registerDefaultSessionDataOwner(this.sessionData); this.revokePushNotifications = input?.revokePushNotifications ?? revokePushNotifications; } @@ -1507,10 +1506,16 @@ export class HostRuntimeStore { } } this.hosts = profiles; - this.replicaCache.setHosts(profiles.map((profile) => profile.serverId)); + this.clientReplica.hostLifecycle.setHosts(profiles.map((profile) => profile.serverId)); projectIconCache.setHosts(profiles.map((profile) => profile.serverId)); await projectIconCache.restore(); this.syncHosts(profiles); + for (const profile of profiles) { + void this.sessionData + .directory(profile.serverId) + ?.restoreCachedDirectory() + .catch(() => undefined); + } } catch (error) { console.error("[HostRuntime] Failed to load host registry from storage", error); } finally { @@ -1643,27 +1648,27 @@ export class HostRuntimeStore { rekeyMap(this.controllers, oldServerId, newServerId); rekeyMap(this.lastConnectionStatusByServer, oldServerId, newServerId); rekeyMap(this.connectionStatusStartedAtByServer, oldServerId, newServerId); - this.replicaCache.reconcileServerId(oldServerId, newServerId); + this.clientReplica.hostLifecycle.reconcileServerId(oldServerId, newServerId); projectIconCache.reconcileServerId(oldServerId, newServerId); - this.directorySyncByServer.get(oldServerId)?.dispose(); - this.directorySyncByServer.delete(oldServerId); + this.sessionData.removeDirectory(oldServerId); this.timelineReplicaByServer.delete(oldServerId); const directory = new DirectorySync( newServerId, { - onAgentStoppedRunning: (agentId) => this.drainQueuedAgentMessage(newServerId, agentId), + onAgentStoppedRunning: (agentId) => + this.sessionData.drainQueuedAgentMessage(newServerId, agentId), markAgentLoading: () => controller.markAgentDirectorySyncLoading(), markAgentReady: () => controller.markAgentDirectorySyncReady(), markAgentError: (error) => controller.markAgentDirectorySyncError(error), }, - this.replicaCache, + this.clientReplica.directory, ); - this.directorySyncByServer.set(newServerId, directory); + this.sessionData.registerDirectory(newServerId, directory); this.timelineReplicaByServer.set( newServerId, createTimelineReplica({ serverId: newServerId, - storage: this.replicaCache, + storage: this.clientReplica.timeline, prepareAgent: (agentId) => directory.prepareAgentRoute(agentId), }), ); @@ -2029,7 +2034,7 @@ export class HostRuntimeStore { >; }, ): void { - this.replicaCache.setHosts(hosts.map((host) => host.serverId)); + this.clientReplica.hostLifecycle.setHosts(hosts.map((host) => host.serverId)); projectIconCache.setHosts(hosts.map((host) => host.serverId)); const nextIds = new Set(hosts.map((host) => host.serverId)); for (const [serverId, controller] of this.controllers) { @@ -2039,8 +2044,7 @@ export class HostRuntimeStore { this.controllers.delete(serverId); this.lastConnectionStatusByServer.delete(serverId); this.connectionStatusStartedAtByServer.delete(serverId); - this.directorySyncByServer.get(serverId)?.dispose(); - this.directorySyncByServer.delete(serverId); + this.sessionData.removeDirectory(serverId); this.timelineReplicaByServer.delete(serverId); this.clearHostReplica(serverId); void controller.stop(); @@ -2064,24 +2068,26 @@ export class HostRuntimeStore { deps: this.deps, onReconcileServerId: (oldId, newId) => this.reconcileServerId(oldId, newId), }); + controller.setReconnectEnabled(this.appVisible); this.controllers.set(host.serverId, controller); useSessionStore.getState().initializeSession(host.serverId, null); const directory = new DirectorySync( host.serverId, { - onAgentStoppedRunning: (agentId) => this.drainQueuedAgentMessage(host.serverId, agentId), + onAgentStoppedRunning: (agentId) => + this.sessionData.drainQueuedAgentMessage(host.serverId, agentId), markAgentLoading: () => controller.markAgentDirectorySyncLoading(), markAgentReady: () => controller.markAgentDirectorySyncReady(), markAgentError: (error) => controller.markAgentDirectorySyncError(error), }, - this.replicaCache, + this.clientReplica.directory, ); - this.directorySyncByServer.set(host.serverId, directory); + this.sessionData.registerDirectory(host.serverId, directory); this.timelineReplicaByServer.set( host.serverId, createTimelineReplica({ serverId: host.serverId, - storage: this.replicaCache, + storage: this.clientReplica.timeline, prepareAgent: (agentId) => directory.prepareAgentRoute(agentId), }), ); @@ -2132,7 +2138,7 @@ export class HostRuntimeStore { return; } const snapshot = controller.getSnapshot(); - const directory = this.directorySyncByServer.get(serverId); + const directory = this.sessionData.directory(serverId); directory?.connectionChanged({ client: snapshot.client, status: snapshot.connectionStatus === "online" ? "online" : "offline", @@ -2164,57 +2170,6 @@ export class HostRuntimeStore { } } - drainQueuedAgentMessage(serverId: string, agentId: string): void { - const drainKey = `${serverId}:${agentId}`; - if (this.queuedAgentDrainInFlight.has(drainKey)) return; - const store = useSessionStore.getState(); - const session = store.sessions[serverId]; - const queue = session?.queuedMessages.get(agentId); - const client = session?.client; - if (!client || !queue?.length || session.initializingAgents.get(agentId) === true) { - return; - } - this.queuedAgentDrainInFlight.add(drainKey); - const next = queue[0]; - void sendQueuedComposerMessageNow({ - agentId, - messageId: next.id, - queue: { - read: (queuedAgentId) => - useSessionStore.getState().sessions[serverId]?.queuedMessages.get(queuedAgentId) ?? [], - write: (update) => useSessionStore.getState().setQueuedMessages(serverId, update), - }, - submitMessage: async ({ text, attachments }) => { - const supportsForgeAttachments = - useSessionStore.getState().sessions[serverId]?.serverInfo?.features?.forgeSearch === true; - await dispatchComposerAgentMessage({ - client, - agentId, - text, - attachments, - attachmentSubmitFormat: resolveComposerAttachmentSubmitFormat({ - supportsForgeAttachments, - }), - encodeImages, - submission: createMessageSubmissionWriter(serverId), - }); - }, - }) - .then((result) => { - if (result.status === "failed") { - console.error("[HostRuntime] failed to drain queued agent message", { - serverId, - agentId, - error: result.errorMessage, - }); - } - return result; - }) - .finally(() => { - this.queuedAgentDrainInFlight.delete(drainKey); - }); - } - getSnapshot(serverId: string): HostRuntimeSnapshot | null { return this.controllers.get(serverId)?.getSnapshot() ?? null; } @@ -2274,6 +2229,20 @@ export class HostRuntimeStore { } } + setAppVisible(visible: boolean, preferredServerId: string | null): void { + void preferredServerId; + this.appVisible = visible; + for (const controller of this.controllers.values()) { + controller.setReconnectEnabled(visible); + } + if (!visible) { + void this.clientReplica.hostLifecycle.flush(); + return; + } + + this.ensureConnectedAll(); + } + runProbeCycleNow(serverId?: string): Promise { if (serverId) { return this.controllers.get(serverId)?.runProbeCycleNow() ?? Promise.resolve(); @@ -2286,25 +2255,25 @@ export class HostRuntimeStore { async refreshAgentDirectory( input: AgentDirectoryRefreshInput, ): Promise { - const directory = this.directorySyncByServer.get(input.serverId); + const directory = this.sessionData.directory(input.serverId); if (!directory) throw new Error(`Unknown host runtime for serverId ${input.serverId}`); return directory.refreshAgents(input); } async refreshWorkspaceDirectory(input: { serverId: string; subscribe?: boolean }): Promise { - const directory = this.directorySyncByServer.get(input.serverId); + const directory = this.sessionData.directory(input.serverId); if (!directory) throw new Error(`Unknown host runtime for serverId ${input.serverId}`); await directory.refreshWorkspaces({ subscribe: input.subscribe }); } async refreshDirectories(serverId: string): Promise { - const directory = this.directorySyncByServer.get(serverId); + const directory = this.sessionData.directory(serverId); if (!directory) throw new Error(`Unknown host runtime for serverId ${serverId}`); await directory.refreshDemand(); } acquireDirectoryDemand(serverId: string): () => void { - const directory = this.directorySyncByServer.get(serverId); + const directory = this.sessionData.directory(serverId); if (!directory) return () => undefined; const source = {}; directory.setDemand(source, true); @@ -2312,13 +2281,13 @@ export class HostRuntimeStore { } async prepareAgentRoute(serverId: string, agentId: string): Promise { - const directory = this.directorySyncByServer.get(serverId); + const directory = this.sessionData.directory(serverId); if (!directory) throw new Error(`Unknown host runtime for serverId ${serverId}`); await directory.prepareAgentRoute(agentId); } async prepareWorkspaceRoute(serverId: string, workspaceId: string): Promise { - const directory = this.directorySyncByServer.get(serverId); + const directory = this.sessionData.directory(serverId); if (!directory) throw new Error(`Unknown host runtime for serverId ${serverId}`); await directory.prepareWorkspaceRoute(workspaceId); } @@ -2334,7 +2303,7 @@ export class HostRuntimeStore { agentId: string, request: Parameters[1], ): Promise>> { - const directory = this.directorySyncByServer.get(serverId); + const directory = this.sessionData.directory(serverId); if (!directory) throw new Error(`Unknown host runtime for serverId ${serverId}`); return directory.fetchTimeline(agentId, request); } @@ -2343,7 +2312,7 @@ export class HostRuntimeStore { serverId: string, ports: ViewedTimelineOwnerPorts, ): ViewedTimelineOwner { - const directory = this.directorySyncByServer.get(serverId); + const directory = this.sessionData.directory(serverId); if (!directory) throw new Error(`Unknown host runtime for serverId ${serverId}`); const replica = this.timelineReplicaByServer.get(serverId); if (!replica) throw new Error(`Unknown host runtime for serverId ${serverId}`); @@ -2351,7 +2320,8 @@ export class HostRuntimeStore { serverId, replica, replaceDemandedAgentIds: (agentIds) => directory.setAgentRouteDemand(agentIds), - drainQueuedAgentMessage: (agentId) => this.drainQueuedAgentMessage(serverId, agentId), + drainQueuedAgentMessage: (agentId) => + this.sessionData.drainQueuedAgentMessage(serverId, agentId), ports, }); } @@ -2376,6 +2346,7 @@ export class HostRuntimeStore { let singletonHostRuntimeStore: HostRuntimeStore | null = null; const HOST_RUNTIME_STORE_GLOBAL_KEY = "__paseoHostRuntimeStore"; +const SESSION_DATA_OWNER_GLOBAL_KEY = "__paseoSessionDataOwner"; export function getHostRuntimeStore(): HostRuntimeStore { if (singletonHostRuntimeStore) { @@ -2384,12 +2355,19 @@ export function getHostRuntimeStore(): HostRuntimeStore { const existing = Reflect.get(globalThis, HOST_RUNTIME_STORE_GLOBAL_KEY); if (existing instanceof HostRuntimeStore) { + const existingSessionData = Reflect.get(globalThis, SESSION_DATA_OWNER_GLOBAL_KEY); + if (existingSessionData instanceof SessionDataOwner) { + registerDefaultSessionDataOwner(existingSessionData); + } singletonHostRuntimeStore = existing; return existing; } - singletonHostRuntimeStore = new HostRuntimeStore(); + const sessionData = new SessionDataOwner(); + registerDefaultSessionDataOwner(sessionData); + singletonHostRuntimeStore = new HostRuntimeStore({ sessionData }); Reflect.set(globalThis, HOST_RUNTIME_STORE_GLOBAL_KEY, singletonHostRuntimeStore); + Reflect.set(globalThis, SESSION_DATA_OWNER_GLOBAL_KEY, sessionData); return singletonHostRuntimeStore; } @@ -2510,6 +2488,82 @@ export function useHostRegistryLoaded(): boolean { ); } +export function getHostClient(serverId: string): DaemonClient | null { + return getHostRuntimeStore().getClient(serverId); +} + +export function probeHostConnections(serverId?: string): Promise { + return getHostRuntimeStore().runProbeCycleNow(serverId); +} + +export function prepareAgentRoute(serverId: string, agentId: string): Promise { + return getHostRuntimeStore().prepareAgentRoute(serverId, agentId); +} + +export function prepareWorkspaceRoute(serverId: string, workspaceId: string): Promise { + return getHostRuntimeStore().prepareWorkspaceRoute(serverId, workspaceId); +} + +export function prepareAgentTimeline(serverId: string, agentId: string): Promise { + return getHostRuntimeStore().prepareAgentTimeline(serverId, agentId); +} + +export function createViewedTimelineOwnerForHost( + serverId: string, + ports: ViewedTimelineOwnerPorts, +): ViewedTimelineOwner { + return getHostRuntimeStore().createViewedTimelineOwner(serverId, ports); +} + +export function fetchAgentTimeline( + serverId: string, + agentId: string, + request: Parameters[1], +): Promise>> { + return getHostRuntimeStore().fetchAgentTimeline(serverId, agentId, request); +} + +export const agentTimelineRuntime = { fetchAgentTimeline }; + +export function setAppVisible(visible: boolean, preferredServerId: string | null): void { + getHostRuntimeStore().setAppVisible(visible, preferredServerId); +} + +export function useHostRuntimeVersion(): number { + const store = getHostRuntimeStore(); + return useSyncExternalStore( + (listener) => store.subscribeAll(listener), + () => store.getVersion(), + () => store.getVersion(), + ); +} + +export function readHostRuntimeSnapshot(serverId: string): HostRuntimeSnapshot | null { + return getHostRuntimeStore().getSnapshot(serverId); +} + +export function acquireDirectoryDemand(serverId: string): () => void { + return getHostRuntimeStore().acquireDirectoryDemand(serverId); +} + +export function refreshHostDirectories(serverId: string): Promise { + return getHostRuntimeStore().refreshDirectories(serverId); +} + +export const scheduleRuntime = { + getClient: getHostClient, + getSnapshot: readHostRuntimeSnapshot, +}; + +export function useEarliestOnlineHostServerId(): string | null { + const store = getHostRuntimeStore(); + return useSyncExternalStore( + (listener) => store.subscribeAll(listener), + () => store.getEarliestOnlineHostServerId(), + () => store.getEarliestOnlineHostServerId(), + ); +} + export interface HostMutations { upsertDirectConnection: (input: { serverId: string; diff --git a/packages/app/src/runtime/session-data/index.ts b/packages/app/src/runtime/session-data/index.ts new file mode 100644 index 0000000000..51ba4a636e --- /dev/null +++ b/packages/app/src/runtime/session-data/index.ts @@ -0,0 +1,308 @@ +import { + useSessionStore, + selectAgentTimelineState, + selectAgentTurnPresentation, + type Agent, + type DaemonServerInfo, + type ProjectDescriptor, + type WorkspaceDescriptor, +} from "@/stores/session-store"; +import type { DirectorySync } from "@/runtime/directory-sync"; +import type { TurnLivenessTransition } from "@/timeline/turn-liveness"; +import type { PendingPermission } from "@/types/shared"; +import { QueuedMessageDrainer } from "./internal/queued-message-drainer"; + +export class SessionDataOwner { + private readonly directories = new Map(); + private readonly queuedMessages = new QueuedMessageDrainer(); + private nextCancellationRequestId = 0; + + registerDirectory(serverId: string, directory: DirectorySync): void { + this.directories.get(serverId)?.dispose(); + this.directories.set(serverId, directory); + } + + removeDirectory(serverId: string): void { + this.directories.get(serverId)?.dispose(); + this.directories.delete(serverId); + } + + directory(serverId: string): DirectorySync | null { + return this.directories.get(serverId) ?? null; + } + + acceptAgent(serverId: string, agent: Agent): Agent { + const directory = this.requireDirectory(serverId); + return directory.acceptAgent(agent); + } + + applyTurn( + serverId: string, + agentId: string, + transition: TurnLivenessTransition | readonly TurnLivenessTransition[], + ): void { + this.requireDirectory(serverId).applyAgentTurnLiveness(agentId, transition); + } + + beginCancellation(serverId: string, agentId: string): number { + this.nextCancellationRequestId += 1; + const requestId = this.nextCancellationRequestId; + this.applyTurn(serverId, agentId, { type: "cancellation_started", requestId }); + return requestId; + } + + settleCancellation(serverId: string, agentId: string, requestId: number): void { + this.applyTurn(serverId, agentId, { type: "cancellation_settled", requestId }); + } + + acceptWorkspaces(serverId: string, workspaces: readonly WorkspaceDescriptor[]): void { + this.requireDirectory(serverId).acceptWorkspaces(workspaces); + } + + acceptProject(serverId: string, project: ProjectDescriptor): void { + this.requireDirectory(serverId).acceptProject(project); + } + + removeWorkspace(serverId: string, workspaceId: string): void { + this.requireDirectory(serverId).removeWorkspace(workspaceId); + } + + markWorkspacesHydrated(serverId: string, hydrated: boolean): void { + this.requireDirectory(serverId).markWorkspacesHydrated(hydrated); + } + + archiveAgent(serverId: string, agentId: string, archivedAt: string): void { + this.requireDirectory(serverId).archiveAgent(agentId, archivedAt); + } + + restoreAgent(serverId: string, agentId: string, agent: Agent | null | undefined): void { + const directory = this.requireDirectory(serverId); + if (agent) directory.acceptAgent(agent); + else directory.removeAgent(agentId); + } + + drainQueuedAgentMessage(serverId: string, agentId: string): void { + this.queuedMessages.drain(serverId, agentId); + } + + private requireDirectory(serverId: string): DirectorySync { + const directory = this.directories.get(serverId); + if (!directory) throw new Error(`Session data is unavailable for host ${serverId}`); + return directory; + } +} + +let defaultOwner: SessionDataOwner | null = null; + +export function registerDefaultSessionDataOwner(owner: SessionDataOwner): void { + defaultOwner = owner; +} + +function getDefaultOwner(): SessionDataOwner { + if (!defaultOwner) throw new Error("Session data owner is unavailable"); + return defaultOwner; +} + +export function acceptAgentSnapshot(serverId: string, agent: Agent): Agent { + return getDefaultOwner().acceptAgent(serverId, agent); +} + +export function applyAgentTurn( + serverId: string, + agentId: string, + transition: TurnLivenessTransition | readonly TurnLivenessTransition[], +): void { + getDefaultOwner().applyTurn(serverId, agentId, transition); +} + +export function beginAgentCancellation(serverId: string, agentId: string): number { + return getDefaultOwner().beginCancellation(serverId, agentId); +} + +export function settleAgentCancellation( + serverId: string, + agentId: string, + requestId: number, +): void { + getDefaultOwner().settleCancellation(serverId, agentId, requestId); +} + +export function drainQueuedAgentMessage(serverId: string, agentId: string): void { + getDefaultOwner().drainQueuedAgentMessage(serverId, agentId); +} + +const store = () => useSessionStore.getState(); + +export function readServerInfo(serverId: string): DaemonServerInfo | null { + return useSessionStore.getState().sessions[serverId]?.serverInfo ?? null; +} + +export function hostSupports( + serverId: string, + feature: keyof NonNullable, +): boolean { + return store().sessions[serverId]?.serverInfo?.features?.[feature] === true; +} + +export function getAgentSnapshot(serverId: string, agentId: string): Agent | null { + const session = store().sessions[serverId]; + return session?.agents.get(agentId) ?? session?.agentDetails.get(agentId) ?? null; +} + +export function getActiveAgentSnapshot(serverId: string, agentId: string): Agent | null { + return store().sessions[serverId]?.agents.get(agentId) ?? null; +} + +export function getWorkspaceDirectorySnapshot(serverId: string) { + const session = store().sessions[serverId]; + return { + workspaces: session?.workspaces ?? new Map(), + projects: session?.projects ?? new Map(), + }; +} + +export function getAgentTimelineSnapshot(serverId: string, agentId: string) { + return selectAgentTimelineState(store().sessions[serverId], agentId); +} + +export function getAgentTimelineLoadSnapshot(serverId: string, agentId: string) { + const session = store().sessions[serverId]; + return { + timeline: selectAgentTimelineState(session, agentId), + isLoadingOlder: session?.agentTimelineOlderFetchInFlight.get(agentId) === true, + }; +} + +export function getAgentStreamSnapshot(serverId: string, agentId: string) { + const session = store().sessions[serverId]; + return { + tail: session?.agentStreamTail.get(agentId) ?? [], + head: session?.agentStreamHead.get(agentId) ?? [], + }; +} + +export function getAgentConversationSnapshot(serverId: string, agentId: string) { + const session = store().sessions[serverId]; + return { + pendingPermissions: Array.from(session?.pendingPermissions.values() ?? []).filter( + (permission) => permission.agentId === agentId, + ), + }; +} + +export function getQueuedMessagesSnapshot(serverId: string, agentId: string) { + return store().sessions[serverId]?.queuedMessages.get(agentId) ?? []; +} + +export function getAgentTurnSnapshot(serverId: string, agentId: string) { + return selectAgentTurnPresentation(store().sessions[serverId], agentId); +} + +export function getSessionFocusSnapshot(serverId: string) { + const session = store().sessions[serverId]; + return { + focusedAgentId: session?.focusedAgentId ?? null, + focusedTerminalId: session?.focusedTerminalId ?? null, + }; +} + +export function archiveAgentSnapshot(serverId: string, agentId: string, archivedAt: string): void { + getDefaultOwner().archiveAgent(serverId, agentId, archivedAt); +} + +export function restoreAgentSnapshot( + serverId: string, + agentId: string, + agent: Agent | null | undefined, +): void { + getDefaultOwner().restoreAgent(serverId, agentId, agent); +} + +export const publishAudioPlayback = ( + ...args: Parameters["setIsPlayingAudio"]> +) => store().setIsPlayingAudio(...args); +export const publishAgentInitialization = ( + ...args: Parameters["setInitializingAgents"]> +) => store().setInitializingAgents(...args); +export const advanceHistorySyncGeneration = ( + ...args: Parameters["bumpHistorySyncGeneration"]> +) => store().bumpHistorySyncGeneration(...args); +export const flushAgentActivity = ( + ...args: Parameters["flushAgentLastActivity"]> +) => store().flushAgentLastActivity(...args); +export const publishPendingPermissions = ( + ...args: Parameters["setPendingPermissions"]> +) => store().setPendingPermissions(...args); +export const publishServerInfo = ( + ...args: Parameters["updateSessionServerInfo"]> +) => store().updateSessionServerInfo(...args); +export const publishViewedTimeline = ( + ...args: Parameters["setViewedTimelineSync"]> +) => store().setViewedTimelineSync(...args); + +export function publishAgentDetails( + ...args: Parameters["setAgentDetails"]> +): void { + store().setAgentDetails(...args); +} + +export function replaceAgentPendingPermissions( + serverId: string, + agentId: string, + pendingPermissions: readonly PendingPermission[], +): void { + store().setPendingPermissions(serverId, (previous) => { + const next = new Map(previous); + for (const [key, pending] of next) if (pending.agentId === agentId) next.delete(key); + for (const permission of pendingPermissions) next.set(permission.key, permission); + return next; + }); +} + +export const publishAgentStreamState = ( + ...args: Parameters["setAgentStreamState"]> +) => store().setAgentStreamState(...args); +export const beginMessageSubmission = ( + ...args: Parameters["beginAgentMessageSubmission"]> +) => store().beginAgentMessageSubmission(...args); +export const acceptMessageSubmission = ( + ...args: Parameters["acceptAgentMessageSubmission"]> +) => store().acceptAgentMessageSubmission(...args); +export const rejectMessageSubmission = ( + ...args: Parameters["rejectAgentMessageSubmission"]> +) => store().rejectAgentMessageSubmission(...args); +export const handoffCreatedAgentSubmission = ( + ...args: Parameters["handoffCreatedAgentUserMessage"]> +) => store().handoffCreatedAgentUserMessage(...args); + +export const publishFocusedAgent = ( + ...args: Parameters["setFocusedAgentId"]> +) => store().setFocusedAgentId(...args); +export const publishFocusedTerminal = ( + ...args: Parameters["setFocusedTerminalId"]> +) => store().setFocusedTerminalId(...args); +export const publishOlderTimelineFetch = ( + ...args: Parameters["setAgentTimelineOlderFetchInFlight"]> +) => store().setAgentTimelineOlderFetchInFlight(...args); +export const publishFileExplorer = ( + ...args: Parameters["setFileExplorer"]> +) => store().setFileExplorer(...args); +export const publishQueuedMessages = ( + ...args: Parameters["setQueuedMessages"]> +) => store().setQueuedMessages(...args); + +export function acceptWorkspaceSnapshots( + serverId: string, + workspaces: readonly WorkspaceDescriptor[], +): void { + getDefaultOwner().acceptWorkspaces(serverId, workspaces); +} +export function removeWorkspaceSnapshot(serverId: string, workspaceId: string): void { + getDefaultOwner().removeWorkspace(serverId, workspaceId); +} +export function acceptProjectSnapshot(serverId: string, project: ProjectDescriptor): void { + getDefaultOwner().acceptProject(serverId, project); +} +export function publishWorkspaceHydration(serverId: string, hydrated: boolean): void { + getDefaultOwner().markWorkspacesHydrated(serverId, hydrated); +} diff --git a/packages/app/src/runtime/session-data/internal/queued-message-drainer.ts b/packages/app/src/runtime/session-data/internal/queued-message-drainer.ts new file mode 100644 index 0000000000..73235a130e --- /dev/null +++ b/packages/app/src/runtime/session-data/internal/queued-message-drainer.ts @@ -0,0 +1,56 @@ +import { dispatchComposerAgentMessage, sendQueuedComposerMessageNow } from "@/composer/actions"; +import { resolveComposerAttachmentSubmitFormat } from "@/composer/attachments/submit"; +import { createMessageSubmissionWriter } from "@/composer/submission/writer"; +import { useSessionStore } from "@/stores/session-store"; +import { encodeImages } from "@/utils/encode-images"; + +export class QueuedMessageDrainer { + private readonly inFlight = new Set(); + + drain(serverId: string, agentId: string): void { + const drainKey = `${serverId}:${agentId}`; + if (this.inFlight.has(drainKey)) return; + const session = useSessionStore.getState().sessions[serverId]; + const queue = session?.queuedMessages.get(agentId); + const client = session?.client; + if (!client || !queue?.length || session.initializingAgents.get(agentId) === true) return; + + this.inFlight.add(drainKey); + const next = queue[0]; + void sendQueuedComposerMessageNow({ + agentId, + messageId: next.id, + queue: { + read: (queuedAgentId) => + useSessionStore.getState().sessions[serverId]?.queuedMessages.get(queuedAgentId) ?? [], + write: (update) => useSessionStore.getState().setQueuedMessages(serverId, update), + }, + submitMessage: async ({ text, attachments }) => { + const supportsForgeAttachments = + useSessionStore.getState().sessions[serverId]?.serverInfo?.features?.forgeSearch === true; + await dispatchComposerAgentMessage({ + client, + agentId, + text, + attachments, + attachmentSubmitFormat: resolveComposerAttachmentSubmitFormat({ + supportsForgeAttachments, + }), + encodeImages, + submission: createMessageSubmissionWriter(serverId), + }); + }, + }) + .then((result) => { + if (result.status === "failed") { + console.error("[SessionData] failed to drain queued agent message", { + serverId, + agentId, + error: result.errorMessage, + }); + } + return result; + }) + .finally(() => this.inFlight.delete(drainKey)); + } +} diff --git a/packages/app/src/runtime/session-data/queued-message-drainer.test.ts b/packages/app/src/runtime/session-data/queued-message-drainer.test.ts new file mode 100644 index 0000000000..8b88cbf9de --- /dev/null +++ b/packages/app/src/runtime/session-data/queued-message-drainer.test.ts @@ -0,0 +1,128 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { useSessionStore } from "@/stores/session-store"; +import { SessionDataOwner } from "."; + +const SERVER_ID = "queued-drainer"; + +class FakeClient { + sent: Array> = []; + responses: Promise[] = []; + failures: Error[] = []; + + async sendAgentMessage( + ...args: Parameters + ): ReturnType { + this.sent.push(args); + const response = this.responses.shift(); + if (response) await response; + const failure = this.failures.shift(); + if (failure) throw failure; + } +} + +function initialize(client: FakeClient): void { + useSessionStore.getState().initializeSession(SERVER_ID, client as unknown as DaemonClient, 1); +} + +afterEach(() => useSessionStore.getState().clearSession(SERVER_ID)); + +describe("SessionDataOwner queued-message draining", () => { + it("does not send without a queue", async () => { + const client = new FakeClient(); + initialize(client); + const owner = new SessionDataOwner(); + owner.drainQueuedAgentMessage(SERVER_ID, "agent"); + await Promise.resolve(); + expect(client.sent).toEqual([]); + }); + + it("does not send while the agent is initializing", async () => { + const client = new FakeClient(); + initialize(client); + const owner = new SessionDataOwner(); + useSessionStore + .getState() + .setQueuedMessages( + SERVER_ID, + new Map([["agent", [{ id: "queued", text: "later", attachments: [] }]]]), + ); + useSessionStore.getState().setInitializingAgents(SERVER_ID, new Map([["agent", true]])); + owner.drainQueuedAgentMessage(SERVER_ID, "agent"); + await Promise.resolve(); + expect(client.sent).toEqual([]); + }); + + it("sends attachments through the composer submission path", async () => { + const client = new FakeClient(); + initialize(client); + useSessionStore.getState().setQueuedMessages( + SERVER_ID, + new Map([ + [ + "agent", + [ + { + id: "queued", + text: "review", + attachments: [ + { + kind: "workspace_file" as const, + path: "src/main.ts", + selection: { kind: "whole_file" as const }, + }, + ], + }, + ], + ], + ]), + ); + + new SessionDataOwner().drainQueuedAgentMessage(SERVER_ID, "agent"); + await vi.waitFor(() => expect(client.sent).toHaveLength(1)); + + expect(client.sent[0]?.[2]?.attachments).toEqual([ + expect.objectContaining({ type: "text", title: "main.ts" }), + ]); + }); + + it("retains the queue when sending fails", async () => { + const client = new FakeClient(); + client.failures.push(new Error("offline")); + initialize(client); + const queued = [{ id: "queued", text: "retry", attachments: [] }]; + useSessionStore.getState().setQueuedMessages(SERVER_ID, new Map([["agent", queued]])); + + new SessionDataOwner().drainQueuedAgentMessage(SERVER_ID, "agent"); + await vi.waitFor(() => expect(client.sent).toHaveLength(1)); + await vi.waitFor(() => + expect(useSessionStore.getState().sessions[SERVER_ID]?.queuedMessages.get("agent")).toEqual( + queued, + ), + ); + }); + + it("serializes two requests for one agent into one in-flight drain", async () => { + const client = new FakeClient(); + let release!: () => void; + client.responses.push(new Promise((resolve) => (release = resolve))); + initialize(client); + useSessionStore + .getState() + .setQueuedMessages( + SERVER_ID, + new Map([["agent", [{ id: "queued", text: "once", attachments: [] }]]]), + ); + const owner = new SessionDataOwner(); + + owner.drainQueuedAgentMessage(SERVER_ID, "agent"); + owner.drainQueuedAgentMessage(SERVER_ID, "agent"); + await vi.waitFor(() => expect(client.sent).toHaveLength(1)); + release(); + await vi.waitFor(() => + expect(useSessionStore.getState().sessions[SERVER_ID]?.queuedMessages.get("agent")).toEqual( + [], + ), + ); + }); +}); diff --git a/packages/app/src/screens/new-workspace-empty.ts b/packages/app/src/screens/new-workspace-empty.ts index 1c1566b6de..618241a981 100644 --- a/packages/app/src/screens/new-workspace-empty.ts +++ b/packages/app/src/screens/new-workspace-empty.ts @@ -1,4 +1,4 @@ -import type { normalizeWorkspaceDescriptor } from "@/stores/session-store"; +import type { normalizeWorkspaceDescriptor } from "@/stores/session-store-hooks"; import type { MessagePayload } from "@/composer/types"; import type { AgentAttachment } from "@getpaseo/protocol/messages"; diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index ac84be35f8..55f1ccff30 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -53,7 +53,8 @@ import { navigateToWorkspace, useLastWorkspaceSelection, } from "@/stores/navigation-active-workspace-store"; -import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store"; +import { normalizeWorkspaceDescriptor } from "@/stores/session-store-hooks"; +import { acceptWorkspaceSnapshots as mergeWorkspaces } from "@/runtime/session-data"; import { useWorkspace } from "@/stores/session-store-hooks"; import { buildNewWorkspaceDraftKey, generateDraftId } from "@/stores/draft-keys"; import { useOpenAddProject } from "@/hooks/use-open-add-project"; @@ -1555,7 +1556,6 @@ export function NewWorkspaceScreen({ const insets = useSafeAreaInsets(); const isCompact = useIsCompactFormFactor(); const toast = useToast(); - const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces); const { allHosts, selectedServerId, @@ -2025,7 +2025,6 @@ export function NewWorkspaceScreen({ buildCreateWorktreeInput, createdWorkspace, effectiveIsolation, - mergeWorkspaces, queryClient, selectedItem, selectedProject, diff --git a/packages/app/src/screens/new-workspace-terminal.ts b/packages/app/src/screens/new-workspace-terminal.ts index 1cd8707f3c..b169a2fcb2 100644 --- a/packages/app/src/screens/new-workspace-terminal.ts +++ b/packages/app/src/screens/new-workspace-terminal.ts @@ -1,4 +1,4 @@ -import type { normalizeWorkspaceDescriptor } from "@/stores/session-store"; +import type { normalizeWorkspaceDescriptor } from "@/stores/session-store-hooks"; import type { AgentAttachment } from "@getpaseo/protocol/messages"; import type { WorkspaceTabTarget } from "@/workspace-tabs/model"; import { diff --git a/packages/app/src/screens/schedules-screen.tsx b/packages/app/src/screens/schedules-screen.tsx index ce2f8b9dfd..57911d72cd 100644 --- a/packages/app/src/screens/schedules-screen.tsx +++ b/packages/app/src/screens/schedules-screen.tsx @@ -1,11 +1,4 @@ -import { - useCallback, - useEffect, - useMemo, - useState, - useSyncExternalStore, - type ReactElement, -} from "react"; +import { useCallback, useEffect, useMemo, useState, type ReactElement } from "react"; import { ScrollView, Text, View } from "react-native"; import { useIsFocused } from "@react-navigation/native"; import { CalendarClock, Plus } from "lucide-react-native"; @@ -27,7 +20,7 @@ import { type AggregatedSchedule, type ScheduleHostError, } from "@/hooks/use-schedules"; -import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime"; +import { readHostRuntimeSnapshot, useHostRuntimeVersion, useHosts } from "@/runtime/host-runtime"; import { resolveSchedule, type ScheduleBucket, @@ -68,12 +61,7 @@ function SchedulesScreenContent(): ReactElement { const { agents } = useAggregatedAgents({ includeArchived: true }); const { projects } = useProjects(); const hosts = useHosts(); - const runtime = getHostRuntimeStore(); - const runtimeVersion = useSyncExternalStore( - (onStoreChange) => runtime.subscribeAll(onStoreChange), - () => runtime.getVersion(), - () => runtime.getVersion(), - ); + const runtimeVersion = useHostRuntimeVersion(); // Per-host agent-directory readiness from the runtime, not the aggregate agent // flag: the aggregate `isInitialLoad` flips false as soon as *any* host has @@ -84,12 +72,12 @@ function SchedulesScreenContent(): ReactElement { void runtimeVersion; const ready = new Set(); for (const host of hosts) { - if (runtime.getSnapshot(host.serverId)?.hasEverLoadedAgentDirectory) { + if (readHostRuntimeSnapshot(host.serverId)?.hasEverLoadedAgentDirectory) { ready.add(host.serverId); } } return ready; - }, [hosts, runtime, runtimeVersion]); + }, [hosts, runtimeVersion]); const [form, setForm] = useState({ mode: "closed" }); const [selectedHost, setSelectedHost] = useState(ALL_HOSTS_OPTION_ID); diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index 5e5e9be474..82e85b7901 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -61,7 +61,7 @@ import { type Settings as EffectiveSettings, } from "@/hooks/use-settings"; import { useHostRuntimeIsConnected, useHosts } from "@/runtime/host-runtime"; -import { useSessionStore } from "@/stores/session-store"; +import { useConnection } from "@/stores/session-store-hooks"; import { orderHostsLocalFirst, resolveActiveHostServerId, @@ -654,8 +654,9 @@ function HostVersionRow({ }) { const { t } = useTranslation(); const isConnected = useHostRuntimeIsConnected(host.serverId); - const daemonVersion = useSessionStore( - (state) => state.sessions[host.serverId]?.serverInfo?.version ?? null, + const daemonVersion = useConnection( + host.serverId, + ({ serverInfo }) => serverInfo?.version ?? null, ); const rowStyle = useMemo( diff --git a/packages/app/src/screens/settings/host-page.tsx b/packages/app/src/screens/settings/host-page.tsx index 40eeb4fc77..f47aff27f3 100644 --- a/packages/app/src/screens/settings/host-page.tsx +++ b/packages/app/src/screens/settings/host-page.tsx @@ -47,8 +47,8 @@ import { PairDeviceModal } from "@/desktop/components/pair-device-modal"; import { useDaemonConfig } from "@/hooks/use-daemon-config"; import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon"; import { - getHostRuntimeStore, isHostRuntimeConnected, + readHostRuntimeSnapshot, useHostMutations, useHostRuntimeClient, useHostRuntimeIsConnected, @@ -60,7 +60,7 @@ import { ProviderUsageSettingsSection } from "@/provider-usage/settings-section" import { useProviderUsage } from "@/provider-usage/use-provider-usage"; import { HostAppearanceSection } from "@/screens/settings/host-appearance-section"; import { SettingsSection } from "@/screens/settings/settings-section"; -import { useSessionStore } from "@/stores/session-store"; +import { useConnection, useServerFeature } from "@/stores/session-store-hooks"; import { settingsStyles } from "@/styles/settings"; import type { HostConnection, HostProfile } from "@/types/host-connection"; import { confirmDialog } from "@/utils/confirm-dialog"; @@ -173,9 +173,7 @@ function HostStatusBadges({ serverId }: { serverId: string }) { const { t } = useTranslation(); const { theme } = useUnistyles(); const snapshot = useHostRuntimeSnapshot(serverId); - const daemonVersion = useSessionStore( - (state) => state.sessions[serverId]?.serverInfo?.version ?? null, - ); + const daemonVersion = useConnection(serverId, ({ serverInfo }) => serverInfo?.version ?? null); const connectionStatus = snapshot?.connectionStatus ?? "connecting"; const activeConnection = snapshot?.activeConnection ?? null; @@ -563,7 +561,6 @@ function RestartDaemonCard({ host }: { host: HostProfile }) { const { theme } = useUnistyles(); const daemonClient = useHostRuntimeClient(host.serverId); const isConnected = useHostRuntimeIsConnected(host.serverId); - const runtime = getHostRuntimeStore(); const [isRestarting, setIsRestarting] = useState(false); const isMountedRef = useRef(true); @@ -574,8 +571,8 @@ function RestartDaemonCard({ host }: { host: HostProfile }) { }, []); const isHostConnected = useCallback( - () => isHostRuntimeConnected(runtime.getSnapshot(host.serverId)), - [host.serverId, runtime], + () => isHostRuntimeConnected(readHostRuntimeSnapshot(host.serverId)), + [host.serverId], ); const waitForCondition = useCallback( @@ -730,19 +727,18 @@ function UpdateDaemonCard({ host }: { host: HostProfile }) { const { theme } = useUnistyles(); const daemonClient = useHostRuntimeClient(host.serverId); const isConnected = useHostRuntimeIsConnected(host.serverId); - const runtime = getHostRuntimeStore(); const [updateState, setUpdateState] = useState({ status: "idle" }); const isMountedRef = useRef(true); const unsubscribeRef = useRef<(() => void) | null>(null); - const daemonVersion = useSessionStore( - (state) => state.sessions[host.serverId]?.serverInfo?.version ?? null, + const daemonVersion = useConnection( + host.serverId, + ({ serverInfo }) => serverInfo?.version ?? null, ); - const supportsSelfUpdate = useSessionStore( - (state) => state.sessions[host.serverId]?.serverInfo?.features?.daemonSelfUpdate === true, - ); - const desktopManaged = useSessionStore( - (state) => state.sessions[host.serverId]?.serverInfo?.desktopManaged === true, + const supportsSelfUpdate = useServerFeature(host.serverId, "daemonSelfUpdate"); + const desktopManaged = useConnection( + host.serverId, + ({ serverInfo }) => serverInfo?.desktopManaged === true, ); const appVersion = resolveAppVersion(); @@ -756,13 +752,13 @@ function UpdateDaemonCard({ host }: { host: HostProfile }) { }, []); const isHostConnected = useCallback( - () => isHostRuntimeConnected(runtime.getSnapshot(host.serverId)), - [host.serverId, runtime], + () => isHostRuntimeConnected(readHostRuntimeSnapshot(host.serverId)), + [host.serverId], ); const hasReconnectedAfter = useCallback( (startMarker: DaemonConnectionMarker | null) => - hasDaemonReconnectedAfter(runtime.getSnapshot(host.serverId), startMarker), - [host.serverId, runtime], + hasDaemonReconnectedAfter(readHostRuntimeSnapshot(host.serverId), startMarker), + [host.serverId], ); const waitForCondition = useCallback( @@ -835,7 +831,7 @@ function UpdateDaemonCard({ host }: { host: HostProfile }) { }) .then((confirmed) => { if (!confirmed || !isMountedRef.current) return; - const startSnapshot = runtime.getSnapshot(host.serverId); + const startSnapshot = readHostRuntimeSnapshot(host.serverId); const startMarker = startSnapshot ? { clientGeneration: startSnapshot.clientGeneration, @@ -919,7 +915,7 @@ function UpdateDaemonCard({ host }: { host: HostProfile }) { message: t("settings.host.daemon.update.dialogFailedMessage"), }); }); - }, [daemonClient, host.label, host.serverId, isHostConnected, runtime, t, waitForDaemonRestart]); + }, [daemonClient, host.label, host.serverId, isHostConnected, t, waitForDaemonRestart]); const updateIcon = useMemo( () => , diff --git a/packages/app/src/screens/workspace/terminals/use-workspace-terminals.ts b/packages/app/src/screens/workspace/terminals/use-workspace-terminals.ts index e35e9d44a4..e9a616fda2 100644 --- a/packages/app/src/screens/workspace/terminals/use-workspace-terminals.ts +++ b/packages/app/src/screens/workspace/terminals/use-workspace-terminals.ts @@ -3,7 +3,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { TerminalProfile } from "@getpaseo/protocol/messages"; import { resolveTerminalProfileLaunch } from "@getpaseo/protocol/terminal-profiles"; -import type { WorkspaceDescriptor } from "@/stores/session-store"; +import type { WorkspaceDescriptor } from "@/stores/session-store-hooks"; import { useTranslation } from "react-i18next"; import { useReplicaQuery } from "@/data/query"; import { workspaceTerminalsPushRoute } from "@/data/push-router"; diff --git a/packages/app/src/screens/workspace/use-workspace-tab-rename.tsx b/packages/app/src/screens/workspace/use-workspace-tab-rename.tsx index 8141d508f4..9ad3840138 100644 --- a/packages/app/src/screens/workspace/use-workspace-tab-rename.tsx +++ b/packages/app/src/screens/workspace/use-workspace-tab-rename.tsx @@ -4,7 +4,7 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { ListTerminalsResponse } from "@getpaseo/protocol/messages"; import { useTranslation } from "react-i18next"; import { AdaptiveRenameModal } from "@/components/rename-modal"; -import { useSessionStore } from "@/stores/session-store"; +import { getActiveAgentSnapshot } from "@/runtime/session-data"; import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; interface RenamingTabState { @@ -46,8 +46,7 @@ export function useWorkspaceTabRename( } if (tab.target.kind === "agent") { const { agentId } = tab.target; - const agent = - useSessionStore.getState().sessions[normalizedServerId]?.agents?.get(agentId) ?? null; + const agent = getActiveAgentSnapshot(normalizedServerId, agentId); const currentTitle = agent?.title ?? ""; setRenamingTab({ kind: "agent", id: agentId, currentTitle }); } diff --git a/packages/app/src/screens/workspace/workspace-header-source.ts b/packages/app/src/screens/workspace/workspace-header-source.ts index 86558b0b86..ba3ede591d 100644 --- a/packages/app/src/screens/workspace/workspace-header-source.ts +++ b/packages/app/src/screens/workspace/workspace-header-source.ts @@ -1,4 +1,4 @@ -import type { WorkspaceDescriptor } from "@/stores/session-store"; +import type { WorkspaceDescriptor } from "@/stores/session-store-hooks"; export type WorkspaceHeaderCheckoutState = | { kind: "pending" } diff --git a/packages/app/src/screens/workspace/workspace-route-state.ts b/packages/app/src/screens/workspace/workspace-route-state.ts index 7f8137ace1..9724f27bb6 100644 --- a/packages/app/src/screens/workspace/workspace-route-state.ts +++ b/packages/app/src/screens/workspace/workspace-route-state.ts @@ -1,5 +1,5 @@ import type { HostRuntimeConnectionStatus } from "@/runtime/host-runtime"; -import type { WorkspaceDescriptor } from "@/stores/session-store"; +import type { WorkspaceDescriptor } from "@/stores/session-store-hooks"; import type { WorkspaceRecoveryModel } from "@/workspace-recovery/model"; export type WorkspaceRouteState = diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index a27a1ed418..c8b87a29d1 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -12,7 +12,6 @@ import { type ReactElement, type ReactNode, } from "react"; -import { useStoreWithEqualityFn } from "zustand/traditional"; import { useIsFocused } from "@react-navigation/native"; import { BackHandler, Keyboard, Pressable, Text, View } from "react-native"; import { useQueryClient } from "@tanstack/react-query"; @@ -60,7 +59,19 @@ import { import { openWorkspacePullRequest } from "@/workspace-tabs/open-supporting-view"; import { type ExplorerCheckoutContext } from "@/stores/explorer-checkout-context"; import { traceInstant } from "@/performance/native-trace"; -import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store"; +import { + useAgentDirectoryFields, + useServerFeature, + useViewedTimelineSync, + useWorkspaceDirectoryStatus, + type WorkspaceDescriptor, +} from "@/stores/session-store-hooks"; +import { + getActiveAgentSnapshot, + getAgentTimelineSnapshot, + publishFocusedAgent as setFocusedAgentId, + publishFocusedTerminal as setFocusedTerminalId, +} from "@/runtime/session-data"; import { canDismissPaneInLayout, collectAllTabs, @@ -90,7 +101,11 @@ import { useCreateFlowStore } from "@/stores/create-flow-store"; import { normalizeWorkspaceTabTarget, workspaceTabTargetsEqual } from "@/workspace-tabs/identity"; import { useVisibleAgentIds } from "./visible-agent-ids"; import { - getHostRuntimeStore, + prepareAgentTimeline, + prepareWorkspaceRoute, + probeHostConnections, + fetchAgentTimeline, + getHostClient, useHostRuntimeClient, useHostRuntimeIsConnected, useHostRuntimeSnapshot, @@ -1211,7 +1226,7 @@ function useWorkspaceRouteActions(normalizedServerId: string): { if (!normalizedServerId) { return; } - void getHostRuntimeStore().runProbeCycleNow(normalizedServerId); + void probeHostConnections(normalizedServerId); }, [normalizedServerId]); const handleManageHost = useCallback(() => { if (!normalizedServerId) { @@ -1548,9 +1563,7 @@ function WorkspaceScreenContent({ const workspaceDescriptor = useWorkspace(normalizedServerId, normalizedWorkspaceId); useEffect(() => { if (!normalizedServerId || !normalizedWorkspaceId || workspaceDescriptor) return; - void getHostRuntimeStore() - .prepareWorkspaceRoute(normalizedServerId, normalizedWorkspaceId) - .catch(() => undefined); + void prepareWorkspaceRoute(normalizedServerId, normalizedWorkspaceId).catch(() => undefined); }, [normalizedServerId, normalizedWorkspaceId, workspaceDescriptor]); const workspaceScripts = getWorkspaceScripts(workspaceDescriptor); const { handleRetryHost, handleManageHost, handleDismissMissingWorkspace } = @@ -1566,9 +1579,7 @@ function WorkspaceScreenContent({ const client = useHostRuntimeClient(normalizedServerId); const isConnected = useHostRuntimeIsConnected(normalizedServerId); - const supportsProvidersSnapshot = useSessionStore( - (state) => state.sessions[normalizedServerId]?.serverInfo?.features?.providersSnapshot === true, - ); + const supportsProvidersSnapshot = useServerFeature(normalizedServerId, "providersSnapshot"); const workspaceDirectory = workspaceDescriptor?.workspaceDirectory || null; const isMissingWorkspaceDirectory = Boolean(workspaceDescriptor) && !workspaceDirectory; const [isImportSheetVisible, setIsImportSheetVisible] = useState(false); @@ -1645,9 +1656,7 @@ function WorkspaceScreenContent({ })); }, []); const focusWorkspacePane = useWorkspaceLayoutStore((state) => state.focusPane); - const hasHydratedWorkspaces = useSessionStore( - (state) => state.sessions[normalizedServerId]?.hasHydratedWorkspaces ?? false, - ); + const { hasHydratedWorkspaces } = useWorkspaceDirectoryStatus(normalizedServerId); const workspaceRecovery = useWorkspaceRecovery({ serverId: normalizedServerId, workspaceId: normalizedWorkspaceId, @@ -1659,12 +1668,12 @@ function WorkspaceScreenContent({ ), }); - const workspaceAgentVisibility = useStoreWithEqualityFn( - useSessionStore, - (state) => + const workspaceAgentVisibility = useAgentDirectoryFields( + normalizedServerId, + (directory) => deriveWorkspaceAgentVisibility({ - sessionAgents: state.sessions[normalizedServerId]?.agents, - agentDetails: state.sessions[normalizedServerId]?.agentDetails, + sessionAgents: directory.agents, + agentDetails: directory.agentDetails, workspaceId: normalizedWorkspaceId, }), workspaceAgentVisibilityEqual, @@ -1727,8 +1736,9 @@ function WorkspaceScreenContent({ normalizedWorkspaceId, workspaceDirectory, }); - const hasHydratedAgents = useSessionStore( - (state) => state.sessions[normalizedServerId]?.hasHydratedAgents ?? false, + const hasHydratedAgents = useAgentDirectoryFields( + normalizedServerId, + (directory) => directory.hasHydratedAgents, ); const workspaceRouteState = useResolvedWorkspaceRouteState({ serverId: normalizedServerId, @@ -1901,9 +1911,7 @@ function WorkspaceScreenContent({ }), [uiTabs, workspaceLayout], ); - const viewedTimelineSync = useSessionStore( - (state) => state.sessions[normalizedServerId]?.viewedTimelineSync ?? null, - ); + const viewedTimelineSync = useViewedTimelineSync(normalizedServerId); const syncFocusedPaneOnly = useMemo( () => isMobile || isFocusModeEnabled || !supportsDesktopPaneSplits(), [isFocusModeEnabled, isMobile], @@ -1916,9 +1924,7 @@ function WorkspaceScreenContent({ }); useEffect(() => { for (const agentId of visibleAgentIds) { - void getHostRuntimeStore() - .prepareAgentTimeline(normalizedServerId, agentId) - .catch(() => undefined); + void prepareAgentTimeline(normalizedServerId, agentId).catch(() => undefined); } }, [normalizedServerId, visibleAgentIds]); useLayoutEffect(() => { @@ -1933,8 +1939,6 @@ function WorkspaceScreenContent({ } return () => viewedTimelineSync.replaceVisibleAgentIds(persistenceKey, []); }, [persistenceKey, viewedTimelineSync]); - const setFocusedAgentId = useSessionStore((state) => state.setFocusedAgentId); - const setFocusedTerminalId = useSessionStore((state) => state.setFocusedTerminalId); const focusedPaneAgentId = useMemo(() => { const target = focusedPaneTabState.activeTab?.descriptor.target; if (target?.kind !== "agent") { @@ -1956,14 +1960,7 @@ function WorkspaceScreenContent({ } setFocusedAgentId(normalizedServerId, focusedPaneAgentId); setFocusedTerminalId(normalizedServerId, focusedPaneTerminalId); - }, [ - focusedPaneAgentId, - focusedPaneTerminalId, - isRouteFocused, - normalizedServerId, - setFocusedAgentId, - setFocusedTerminalId, - ]); + }, [focusedPaneAgentId, focusedPaneTerminalId, isRouteFocused, normalizedServerId]); useEffect(() => { if (!isRouteFocused) { @@ -1973,7 +1970,7 @@ function WorkspaceScreenContent({ setFocusedAgentId(normalizedServerId, null); setFocusedTerminalId(normalizedServerId, null); }; - }, [isRouteFocused, normalizedServerId, setFocusedAgentId, setFocusedTerminalId]); + }, [isRouteFocused, normalizedServerId]); const openWorkspaceDraftTab = useCallback( function openWorkspaceDraftTab(input?: { @@ -2530,10 +2527,9 @@ function WorkspaceScreenContent({ return; } - const agent = - useSessionStore.getState().sessions[normalizedServerId]?.agents?.get(agentId) ?? null; + const agent = getActiveAgentSnapshot(normalizedServerId, agentId); let closePolicy = resolveCloseAgentTabPolicy(agent); - const isRunning = agent?.status === "running"; + const isRunning = agent?.turn.phase === "open"; if (isRunning && closePolicy.kind === "archive-on-close") { const confirmed = await confirmDialog({ @@ -2549,7 +2545,7 @@ function WorkspaceScreenContent({ } if (closePolicy.kind === "layout-only") { - const sessionClient = useSessionStore.getState().sessions[normalizedServerId]?.client; + const sessionClient = getHostClient(normalizedServerId); if (!sessionClient) { toast.error(t("common.errors.daemonClientUnavailable")); return; @@ -2559,8 +2555,7 @@ function WorkspaceScreenContent({ await sessionClient.updateAgent(agentId, { labels: { [getOpenAgentTabLabel(clientId)]: "false" }, }); - const latestAgent = - useSessionStore.getState().sessions[normalizedServerId]?.agents?.get(agentId) ?? null; + const latestAgent = getActiveAgentSnapshot(normalizedServerId, agentId); closePolicy = resolveCloseAgentTabPolicy(latestAgent); } catch (error) { console.error("[WorkspaceScreen] Failed to close subagent tab", { error, agentId }); @@ -2698,8 +2693,7 @@ function WorkspaceScreenContent({ const handleCopyResumeCommand = useCallback( async (agentId: string) => { if (!agentId) return; - const agent = - useSessionStore.getState().sessions[normalizedServerId]?.agents?.get(agentId) ?? null; + const agent = getActiveAgentSnapshot(normalizedServerId, agentId); const providerSessionId = agent?.runtimeInfo?.sessionId ?? agent?.persistence?.sessionId ?? null; if (!agent || !providerSessionId) { @@ -2741,9 +2735,9 @@ function WorkspaceScreenContent({ // returns reset:true. Without a cursor, the server returns reset:false // and the client takes the incremental path, where new-epoch rows are // dropped against the stale cursor. - const sessionState = useSessionStore.getState().sessions[normalizedServerId]; - const currentCursor = sessionState?.agentTimelineCursor.get(agentId); - await getHostRuntimeStore().fetchAgentTimeline(normalizedServerId, agentId, { + const timeline = getAgentTimelineSnapshot(normalizedServerId, agentId); + const currentCursor = timeline.status === "synced" ? timeline.range : null; + await fetchAgentTimeline(normalizedServerId, agentId, { direction: "tail", projection: "projected", ...(currentCursor @@ -2814,7 +2808,7 @@ function WorkspaceScreenContent({ } const groups = classifyBulkClosableTabs(tabsToClose, (agentId) => { - const agent = useSessionStore.getState().sessions[normalizedServerId]?.agents?.get(agentId); + const agent = getActiveAgentSnapshot(normalizedServerId, agentId); return resolveCloseAgentTabPolicy(agent).kind === "layout-only" ? "layout-only" : "archive"; }); const modifiedCount = tabsToClose.filter( @@ -2852,8 +2846,7 @@ function WorkspaceScreenContent({ await client.updateAgent(agentId, { labels: { [getOpenAgentTabLabel(clientId)]: "false" }, }); - const latestAgent = - useSessionStore.getState().sessions[normalizedServerId]?.agents?.get(agentId) ?? null; + const latestAgent = getActiveAgentSnapshot(normalizedServerId, agentId); if (resolveCloseAgentTabPolicy(latestAgent).kind === "archive-on-close") { await archiveAgent({ serverId: normalizedServerId, agentId }); } diff --git a/packages/app/src/screens/workspace/workspace-scripts-button.tsx b/packages/app/src/screens/workspace/workspace-scripts-button.tsx index 59ddd9a0fb..aadcdb493d 100644 --- a/packages/app/src/screens/workspace/workspace-scripts-button.tsx +++ b/packages/app/src/screens/workspace/workspace-scripts-button.tsx @@ -15,9 +15,8 @@ import { } from "lucide-react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { useTranslation } from "react-i18next"; -import type { WorkspaceDescriptor } from "@/stores/session-store"; -import { useSessionStore } from "@/stores/session-store"; -import { useHostRuntimeSnapshot } from "@/runtime/host-runtime"; +import type { WorkspaceDescriptor } from "@/stores/session-store-hooks"; +import { useHostRuntimeClient, useHostRuntimeSnapshot } from "@/runtime/host-runtime"; import { DropdownMenu, DropdownMenuContent, @@ -547,7 +546,7 @@ export function WorkspaceScriptsButton({ }: WorkspaceScriptsButtonProps): ReactElement | null { const { t } = useTranslation(); const toast = useToast(); - const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null); + const client = useHostRuntimeClient(serverId); const activeConnection = useHostRuntimeSnapshot(serverId)?.activeConnection ?? null; const preferredRouteKind = useWorkspaceServiceRoutePreferencesStore( (state) => state.byServerId[serverId] ?? null, diff --git a/packages/app/src/stores/session-store-hooks/index.ts b/packages/app/src/stores/session-store-hooks/index.ts index 89978811ec..e68aeb9c90 100644 --- a/packages/app/src/stores/session-store-hooks/index.ts +++ b/packages/app/src/stores/session-store-hooks/index.ts @@ -20,19 +20,538 @@ import { workspaceEqualityFns, type WorkspaceStructure, } from "./selectors"; -import { useSessionStore, type WorkspaceDescriptor } from "../session-store"; +import { + normalizeProjectDescriptor, + normalizeWorkspaceDescriptor, + selectAgentTimelineState, + selectAgentTurnPresentation, + useSessionStore, + type Agent, + type AgentFileExplorerState, + type AgentTimelineCursorState, + type AgentTimelineState, + type DaemonServerInfo, + type ExplorerDirectory, + type ExplorerEntry, + type ExplorerEntryKind, + type ExplorerEncoding, + type ExplorerFile, + type ExplorerFileKind, + type ProjectDescriptor, + type SessionState, + type WorkspaceDescriptor, +} from "../session-store"; +import type { ViewedTimelineUiBridge } from "@/timeline/viewed-timeline-sync"; +import type { WorkspaceAgentActivity } from "@/utils/workspace-agent-activity"; +import type { PendingPermission } from "@/types/shared"; +import type { StreamItem, TodoEntry } from "@/types/stream"; import type { DesktopBadgeWorkspaceStatus } from "@/utils/desktop-badge-state"; +import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity"; // These are the ONLY supported ways to read workspaces from the session store. // Do not write raw `useSessionStore` selectors that return the workspaces Map, a session object, // or the sessions dict — it breaks re-render isolation. +export type { + Agent, + AgentFileExplorerState, + AgentTimelineCursorState, + AgentTimelineState, + DaemonServerInfo, + ExplorerDirectory, + ExplorerEntry, + ExplorerEntryKind, + ExplorerEncoding, + ExplorerFile, + ExplorerFileKind, + ProjectDescriptor, + WorkspaceDescriptor, +} from "../session-store"; + export type { DesktopBadgeWorkspaceStatus, WorkspaceStructure, WorkspaceStructureProject, } from "./selectors"; +export { normalizeProjectDescriptor, normalizeWorkspaceDescriptor }; + +type Equality = (left: T, right: T) => boolean; + +export interface ConnectionView { + exists: boolean; + serverInfo: DaemonServerInfo | null; +} + +export interface AgentDirectoryView { + agents: ReadonlyMap; + agentDetails: ReadonlyMap; + hasHydratedAgents: boolean; +} + +export interface WorkspaceDirectoryView { + serverId: string; + workspaces: ReadonlyMap; + projects: ReadonlyMap; + hasHydratedWorkspaces: boolean; + hasWorkspaceDirectorySnapshot: boolean; + workspaceAgentActivity: ReadonlyMap; +} + +const EMPTY_AGENT_DIRECTORY: AgentDirectoryView = { + agents: new Map(), + agentDetails: new Map(), + hasHydratedAgents: false, +}; + +function connectionView(session: SessionState | undefined): ConnectionView { + return { + exists: Boolean(session), + serverInfo: session?.serverInfo ?? null, + }; +} + +function agentDirectoryView(session: SessionState | undefined): AgentDirectoryView { + if (!session) return EMPTY_AGENT_DIRECTORY; + return { + agents: session.agents, + agentDetails: session.agentDetails, + hasHydratedAgents: session.hasHydratedAgents, + }; +} + +function workspaceDirectoryView( + serverId: string, + session: SessionState | undefined, +): WorkspaceDirectoryView { + return { + serverId, + workspaces: session?.workspaces ?? new Map(), + projects: session?.projects ?? new Map(), + hasHydratedWorkspaces: session?.hasHydratedWorkspaces ?? false, + hasWorkspaceDirectorySnapshot: session?.hasWorkspaceDirectorySnapshot ?? false, + workspaceAgentActivity: session?.workspaceAgentActivity ?? new Map(), + }; +} + +export function useConnection( + serverId: string | null | undefined, + project: (connection: ConnectionView) => T, + equality: Equality = Object.is, +): T { + return useStoreWithEqualityFn( + useSessionStore, + (state) => project(connectionView(serverId ? state.sessions[serverId] : undefined)), + equality, + ); +} + +export function useConnections( + serverIds: readonly string[], + project: (connections: ReadonlyMap) => T, + equality: Equality = Object.is, +): T { + return useStoreWithEqualityFn( + useSessionStore, + (state) => + project( + new Map(serverIds.map((serverId) => [serverId, connectionView(state.sessions[serverId])])), + ), + equality, + ); +} + +export function useServerFeature( + serverId: string | null | undefined, + feature: keyof NonNullable, +): boolean { + return useConnection(serverId, ({ serverInfo }) => serverInfo?.features?.[feature] === true); +} + +export function useHasHostServerInfo(serverId: string | null): boolean { + return useStoreWithEqualityFn( + useSessionStore, + (state) => Boolean(serverId && state.sessions[serverId]?.serverInfo), + Object.is, + ); +} + +export function useAgentDirectoryFields( + serverId: string | null, + project: (directory: AgentDirectoryView) => T, + equality: Equality = Object.is, +): T { + return useStoreWithEqualityFn( + useSessionStore, + (state) => project(agentDirectoryView(serverId ? state.sessions[serverId] : undefined)), + equality, + ); +} + +export function useAgentDirectories( + serverIds: readonly string[], + project: (directories: ReadonlyMap) => T, + equality: Equality = Object.is, +): T { + return useStoreWithEqualityFn( + useSessionStore, + (state) => + project( + new Map( + serverIds.map((serverId) => [serverId, agentDirectoryView(state.sessions[serverId])]), + ), + ), + equality, + ); +} + +export function useAgent( + serverId: string | null | undefined, + agentId: string | null | undefined, +): Agent | null { + return useStoreWithEqualityFn( + useSessionStore, + (state) => { + if (!serverId || !agentId) return null; + const session = state.sessions[serverId]; + return session?.agents.get(agentId) ?? session?.agentDetails.get(agentId) ?? null; + }, + Object.is, + ); +} + +export function useAgentFields( + serverId: string | null | undefined, + agentId: string | null | undefined, + project: (agent: Agent) => T, + equality: Equality = workspaceEqualityFns.deep, +): T | null { + return useStoreWithEqualityFn( + useSessionStore, + (state) => { + if (!serverId || !agentId) return null; + const session = state.sessions[serverId]; + const agent = session?.agents.get(agentId) ?? session?.agentDetails.get(agentId); + return agent ? project(agent) : null; + }, + equality, + ); +} + +export function useActiveAgent( + serverId: string | null | undefined, + agentId: string | null | undefined, +): Agent | null { + return useStoreWithEqualityFn( + useSessionStore, + (state) => + serverId && agentId ? (state.sessions[serverId]?.agents.get(agentId) ?? null) : null, + Object.is, + ); +} + +export function useActiveAgentFields( + serverId: string | null | undefined, + agentId: string | null | undefined, + project: (agent: Agent) => T, + equality: Equality = workspaceEqualityFns.deep, +): T | null { + return useStoreWithEqualityFn( + useSessionStore, + (state) => { + const agent = serverId && agentId ? state.sessions[serverId]?.agents.get(agentId) : undefined; + return agent ? project(agent) : null; + }, + equality, + ); +} + +export function useAgentWorkspaceId( + serverId: string | null | undefined, + agentId: string | null | undefined, +): string | null { + return useActiveAgentFields(serverId, agentId, (agent) => agent.workspaceId ?? null, Object.is); +} + +export function useAgentBelongsToWorkspace( + serverId: string | null, + agentId: string | null, + workspaceId: string | null, +): boolean { + return useStoreWithEqualityFn( + useSessionStore, + (state) => { + if (!serverId || !agentId || !workspaceId) return false; + const session = state.sessions[serverId]; + const agent = session?.agents.get(agentId) ?? session?.agentDetails.get(agentId); + return ( + normalizeWorkspaceOpaqueId(agent?.workspaceId) === normalizeWorkspaceOpaqueId(workspaceId) + ); + }, + Object.is, + ); +} + +export function useConnectedDesktopManagedHostIds(): string[] { + return useStoreWithEqualityFn( + useSessionStore, + (state) => + Object.entries(state.sessions) + .filter(([, session]) => session.serverInfo?.desktopManaged === true) + .map(([serverId]) => serverId) + .sort(), + workspaceEqualityFns.deep, + ); +} + +export function useFileExplorer( + serverId: string | null, + scopeKey: string | null, +): AgentFileExplorerState | undefined { + return useStoreWithEqualityFn( + useSessionStore, + (state) => + serverId && scopeKey ? state.sessions[serverId]?.fileExplorer.get(scopeKey) : undefined, + Object.is, + ); +} + +export function useAgentStreamView(serverId: string, agentId: string) { + return useStoreWithEqualityFn( + useSessionStore, + (state) => { + const session = state.sessions[serverId]; + return { + head: session?.agentStreamHead.get(agentId), + timelineEpoch: session?.agentTimelineCursor.get(agentId)?.epoch ?? null, + hasNewerTimeline: session?.agentTimelineHasNewer.get(agentId) === true, + }; + }, + workspaceEqualityFns.deep, + ); +} + +export function useWorkspaceDirectoryStatus(serverId: string | null) { + return useStoreWithEqualityFn( + useSessionStore, + (state) => ({ + hasHydratedWorkspaces: serverId + ? (state.sessions[serverId]?.hasHydratedWorkspaces ?? false) + : false, + }), + workspaceEqualityFns.deep, + ); +} + +export function useWorkspaceDirectories( + serverIds: readonly string[], + project: (directories: ReadonlyMap) => T, + equality: Equality = Object.is, +): T { + return useStoreWithEqualityFn( + useSessionStore, + (state) => + project( + new Map( + serverIds.map((serverId) => [ + serverId, + workspaceDirectoryView(serverId, state.sessions[serverId]), + ]), + ), + ), + equality, + ); +} + +export function useAgentTimeline( + serverId: string | null, + agentId: string | null, +): { + timeline: AgentTimelineState; + cursor: AgentTimelineCursorState | null; + isLoadingOlder: boolean; +}; +export function useAgentTimeline( + serverId: string | null, + agentId: string | null, + project: (timeline: AgentTimelineState, meta: { isLoadingOlder: boolean }) => T, + equality?: Equality, +): T; +export function useAgentTimeline( + serverId: string | null, + agentId: string | null, + project?: (timeline: AgentTimelineState, meta: { isLoadingOlder: boolean }) => unknown, + equality: Equality = workspaceEqualityFns.deep, +): unknown { + return useStoreWithEqualityFn( + useSessionStore, + (state) => { + const session = serverId ? state.sessions[serverId] : undefined; + const timeline = selectAgentTimelineState(session, agentId ?? ""); + const isLoadingOlder = agentId + ? session?.agentTimelineOlderFetchInFlight.get(agentId) === true + : false; + if (project) return project(timeline, { isLoadingOlder }); + return { + timeline, + cursor: timeline.status === "synced" ? timeline.range : null, + isLoadingOlder, + }; + }, + equality, + ); +} + +export interface AgentConversationView { + tasks: TodoEntry[] | undefined; + streamItems: StreamItem[]; + messageSubmissions: SessionState["messageSubmissions"] extends Map + ? Value | undefined + : never; + pendingPermissions: PendingPermission[]; + turn: ReturnType; + timeline: AgentTimelineState; + viewedTimelineSync: ViewedTimelineUiBridge | null; + isInitializing: boolean; + historySyncGeneration: number; + agentHistorySyncGeneration: number; +} + +function agentConversationView( + session: SessionState | undefined, + agentId: string, +): AgentConversationView { + return { + get tasks() { + return session?.agentTasks.get(agentId); + }, + get streamItems() { + return session?.agentStreamTail.get(agentId) ?? []; + }, + get messageSubmissions() { + return session?.messageSubmissions.get(agentId); + }, + get pendingPermissions() { + return Array.from(session?.pendingPermissions.values() ?? []).filter( + (permission) => permission.agentId === agentId, + ); + }, + get turn() { + return selectAgentTurnPresentation(session, agentId); + }, + get timeline() { + return selectAgentTimelineState(session, agentId); + }, + get viewedTimelineSync() { + return session?.viewedTimelineSync ?? null; + }, + get isInitializing() { + return session?.initializingAgents.get(agentId) ?? false; + }, + get historySyncGeneration() { + return session?.historySyncGeneration ?? 0; + }, + get agentHistorySyncGeneration() { + return session?.agentHistorySyncGeneration.get(agentId) ?? -1; + }, + }; +} + +export function useAgentConversation( + serverId: string, + agentId: string | null | undefined, +): AgentConversationView; +export function useAgentConversation( + serverId: string, + agentId: string | null | undefined, + project: (conversation: AgentConversationView) => T, + equality?: Equality, +): T; +export function useAgentConversation( + serverId: string, + agentId: string | null | undefined, + project: (conversation: AgentConversationView) => unknown = (conversation) => conversation, + equality: Equality = workspaceEqualityFns.deep, +): unknown { + return useStoreWithEqualityFn( + useSessionStore, + (state) => project(agentConversationView(state.sessions[serverId], agentId ?? "")), + equality, + ); +} + +export function useComposerSession(serverId: string, agentId: string) { + return useStoreWithEqualityFn( + useSessionStore, + (state) => { + const session = state.sessions[serverId]; + let hasPendingPermission = false; + for (const permission of session?.pendingPermissions.values() ?? []) { + if (permission.agentId === agentId) { + hasPendingPermission = true; + break; + } + } + return { + queuedMessages: session?.queuedMessages.get(agentId) ?? [], + hasPendingPermission, + }; + }, + workspaceEqualityFns.deep, + ); +} + +export function useAgentTurn( + serverId: string | null, + agentId: string | null, +): ReturnType; +export function useAgentTurn( + serverId: string | null, + agentId: string | null, + project: (turn: ReturnType) => T, + equality?: Equality, +): T; +export function useAgentTurn( + serverId: string | null, + agentId: string | null, + project?: (turn: ReturnType) => unknown, + equality: Equality = workspaceEqualityFns.deep, +): unknown { + return useStoreWithEqualityFn( + useSessionStore, + (state) => { + const turn = selectAgentTurnPresentation( + serverId ? state.sessions[serverId] : undefined, + agentId ?? "", + ); + return project ? project(turn) : turn; + }, + equality, + ); +} + +export function useSessionFocus(serverId: string | null) { + return useStoreWithEqualityFn( + useSessionStore, + (state) => { + const session = serverId ? state.sessions[serverId] : undefined; + return { + focusedAgentId: session?.focusedAgentId ?? null, + focusedTerminalId: session?.focusedTerminalId ?? null, + }; + }, + workspaceEqualityFns.deep, + ); +} + +export function useViewedTimelineSync(serverId: string | null): ViewedTimelineUiBridge | null { + return useStoreWithEqualityFn( + useSessionStore, + (state) => (serverId ? (state.sessions[serverId]?.viewedTimelineSync ?? null) : null), + Object.is, + ); +} + export function useWorkspace( serverId: string | null, workspaceId: string | null, @@ -44,15 +563,52 @@ export function useWorkspace( ); } +export function useWorkspaceByDirectory( + serverId: string | null, + workspaceDirectory: string | null | undefined, +): WorkspaceDescriptor | null { + return useStoreWithEqualityFn( + useSessionStore, + (state) => { + if (!serverId || !workspaceDirectory) return null; + for (const workspace of state.sessions[serverId]?.workspaces.values() ?? []) { + if (workspace.workspaceDirectory === workspaceDirectory) return workspace; + } + return null; + }, + workspaceEqualityFns.identity, + ); +} + +export function useWorkspaceByDirectoryFields( + serverId: string | null, + workspaceDirectory: string | null | undefined, + project: (workspace: WorkspaceDescriptor) => T, + equality: Equality = workspaceEqualityFns.deep, +): T | null { + return useStoreWithEqualityFn( + useSessionStore, + (state) => { + if (!serverId || !workspaceDirectory) return null; + for (const workspace of state.sessions[serverId]?.workspaces.values() ?? []) { + if (workspace.workspaceDirectory === workspaceDirectory) return project(workspace); + } + return null; + }, + equality, + ); +} + export function useWorkspaceFields( serverId: string | null, workspaceId: string | null, project: (w: WorkspaceDescriptor) => T, + equality: Equality = workspaceEqualityFns.deep, ): T | null { return useStoreWithEqualityFn( useSessionStore, (state) => selectWorkspaceFields(state, serverId, workspaceId, project), - workspaceEqualityFns.deep, + equality, ); } diff --git a/packages/app/src/stores/session-store.test.ts b/packages/app/src/stores/session-store.test.ts index 3475f4779c..c53eae4c49 100644 --- a/packages/app/src/stores/session-store.test.ts +++ b/packages/app/src/stores/session-store.test.ts @@ -8,9 +8,57 @@ import { selectAgentTurnPresentation, selectAgentTimelineState, useSessionStore, + type Agent, type WorkspaceDescriptor, } from "./session-store"; import type { StreamItem } from "../types/stream"; +import { reduceTurnLiveness, type TurnLivenessTransition } from "@/timeline/turn-liveness"; + +function createTestAgent(agentId: string): Agent { + return { + serverId: "test-server", + id: agentId, + provider: "codex", + status: "idle", + turn: { phase: "idle", cancellationRequestId: null }, + createdAt: new Date(0), + updatedAt: new Date(0), + lastUserMessageAt: null, + lastActivityAt: new Date(0), + capabilities: { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: false, + supportsMcpServers: false, + supportsReasoningStream: false, + supportsToolInvocations: false, + }, + currentModeId: null, + availableModes: [], + pendingPermissions: [], + persistence: null, + title: null, + cwd: "/repo", + model: null, + parentAgentId: null, + labels: {}, + }; +} + +function applyTestTurn( + serverId: string, + agentId: string, + transition: TurnLivenessTransition | readonly TurnLivenessTransition[], +): void { + useSessionStore.getState().setAgents(serverId, (agents) => { + const agent = agents.get(agentId) ?? createTestAgent(agentId); + const transitions = Array.isArray(transition) ? transition : [transition]; + const turn = transitions.reduce(reduceTurnLiveness, agent.turn); + const next = new Map(agents); + next.set(agentId, { ...agent, status: turn.phase === "open" ? "running" : "idle", turn }); + return next; + }); +} function createWorkspace( input: Partial & Pick, @@ -222,22 +270,25 @@ describe("agent timeline state", () => { it("stores turn liveness transitions without duplicating their policy", () => { initializeTestSession(); const store = useSessionStore.getState(); - store.applyAgentTurnLiveness("test-server", "agent-1", { + applyTestTurn("test-server", "agent-1", { type: "snapshot", activeTurn: { turnId: "turn-1", startedAt: null }, }); - expect(store.getSession("test-server")?.agentTurnLiveness.get("agent-1")).toEqual({ + expect(store.getSession("test-server")?.agents.get("agent-1")?.turn).toEqual({ phase: "open", turnId: "turn-1", startedAt: null, cancellationRequestId: null, }); - store.applyAgentTurnLiveness("test-server", "agent-1", { + applyTestTurn("test-server", "agent-1", { type: "snapshot", activeTurn: null, }); - expect(store.getSession("test-server")?.agentTurnLiveness.has("agent-1")).toBe(false); + expect(store.getSession("test-server")?.agents.get("agent-1")?.turn).toEqual({ + phase: "idle", + cancellationRequestId: null, + }); }); }); @@ -267,27 +318,27 @@ describe("message submission ordering", () => { return; } if (step === "stream-open") { - store.applyAgentTurnLiveness("test-server", agentId, { + applyTestTurn("test-server", agentId, { type: "stream_open", turn: { turnId: "turn-1", startedAt }, }); return; } if (step === "snapshot-open") { - store.applyAgentTurnLiveness("test-server", agentId, { + applyTestTurn("test-server", agentId, { type: "snapshot", activeTurn: { turnId: "turn-1", startedAt }, }); return; } if (step === "snapshot-idle") { - store.applyAgentTurnLiveness("test-server", agentId, { + applyTestTurn("test-server", agentId, { type: "snapshot", activeTurn: null, }); return; } - store.applyAgentTurnLiveness("test-server", agentId, { + applyTestTurn("test-server", agentId, { type: "stream_close", turnId: "turn-1", }); @@ -322,7 +373,7 @@ describe("message submission ordering", () => { store.setAgentStreamState("test-server", agentId, { acknowledgedClientMessageIds: [clientMessageId], }); - store.applyAgentTurnLiveness("test-server", agentId, [ + applyTestTurn("test-server", agentId, [ { type: "stream_close", turnId: "turn-1" }, { type: "snapshot", activeTurn: null }, ]); diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index c2328a5b62..3f1ae6ffcf 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -54,12 +54,10 @@ import { type WorkspaceAgentActivity, } from "@/utils/workspace-agent-activity"; import { - applyTurnLivenessTransition, resolveTurnPresentation, TURN_LIVENESS_IDLE, type TurnLiveness, type TurnPresentation, - type TurnLivenessTransition, } from "@/timeline/turn-liveness"; export interface AgentRuntimeInfo { @@ -76,7 +74,7 @@ export interface Agent { id: string; provider: AgentProvider; status: AgentLifecycleStatus; - activeTurn: { turnId: string | null; startedAt: Date | null } | null; + turn: TurnLiveness; createdAt: Date; updatedAt: Date; lastUserMessageAt: Date | null; @@ -332,7 +330,9 @@ export function selectAgentTurnPresentation( agentId: string, ): TurnPresentation { return resolveTurnPresentation( - session?.agentTurnLiveness.get(agentId) ?? TURN_LIVENESS_IDLE, + session?.agents.get(agentId)?.turn ?? + session?.agentDetails.get(agentId)?.turn ?? + TURN_LIVENESS_IDLE, getActiveMessageSubmissions(session?.messageSubmissions.get(agentId)).length > 0, ); } @@ -387,7 +387,6 @@ export interface SessionState { agentStreamTail: Map; agentStreamHead: Map; agentTasks: Map; - agentTurnLiveness: Map; messageSubmissions: Map; agentTimelineCursor: Map; agentTimelineHasOlder: Map; @@ -476,14 +475,6 @@ interface SessionStoreActions { taskSnapshot?: TodoEntry[]; }, ) => void; - applyAgentTurnLiveness: ( - serverId: string, - agentId: string, - transition: TurnLivenessTransition | readonly TurnLivenessTransition[], - ) => void; - beginAgentCancellation: (serverId: string, agentId: string) => number; - settleAgentCancellation: (serverId: string, agentId: string, requestId: number) => void; - clearAgentTurnLiveness: (serverId: string) => void; beginAgentMessageSubmission: ( serverId: string, agentId: string, @@ -644,7 +635,6 @@ function createInitialSessionState( agentStreamTail: new Map(), agentStreamHead: new Map(), agentTasks: new Map(), - agentTurnLiveness: new Map(), messageSubmissions: new Map(), agentTimelineCursor: new Map(), agentTimelineHasOlder: new Map(), @@ -711,7 +701,6 @@ function isSessionServerInfoUnchanged(input: { export const useSessionStore = create()( subscribeWithSelector((set, get) => { - let nextCancellationRequestId = 0; const commitActivityUpdates: AgentLastActivityCommitter = (updates) => { set((prev) => { let nextActivity: Map | null = null; @@ -1058,57 +1047,6 @@ export const useSessionStore = create()( }); }, - applyAgentTurnLiveness: (serverId, agentId, transition) => { - set((prev) => { - const session = prev.sessions[serverId]; - if (!session) return prev; - const agentTurnLiveness = applyTurnLivenessTransition( - session.agentTurnLiveness, - agentId, - transition, - ); - if (agentTurnLiveness === session.agentTurnLiveness) return prev; - return { - ...prev, - sessions: { - ...prev.sessions, - [serverId]: { ...session, agentTurnLiveness }, - }, - }; - }); - }, - - beginAgentCancellation: (serverId, agentId) => { - nextCancellationRequestId += 1; - const requestId = nextCancellationRequestId; - get().applyAgentTurnLiveness(serverId, agentId, { - type: "cancellation_started", - requestId, - }); - return requestId; - }, - - settleAgentCancellation: (serverId, agentId, requestId) => { - get().applyAgentTurnLiveness(serverId, agentId, { - type: "cancellation_settled", - requestId, - }); - }, - - clearAgentTurnLiveness: (serverId) => { - set((prev) => { - const session = prev.sessions[serverId]; - if (!session || session.agentTurnLiveness.size === 0) return prev; - return { - ...prev, - sessions: { - ...prev.sessions, - [serverId]: { ...session, agentTurnLiveness: new Map() }, - }, - }; - }); - }, - beginAgentMessageSubmission: (serverId, agentId, message) => { set((prev) => { const session = prev.sessions[serverId]; @@ -1924,6 +1862,7 @@ export const useSessionStore = create()( serverId, title: agent.title ?? null, status: agent.status, + turn: agent.turn, lastActivityAt, cwd: agent.cwd, provider: agent.provider, diff --git a/packages/app/src/stores/workspace-subagents-integration.test.ts b/packages/app/src/stores/workspace-subagents-integration.test.ts index 29925efd42..5f04a39bd9 100644 --- a/packages/app/src/stores/workspace-subagents-integration.test.ts +++ b/packages/app/src/stores/workspace-subagents-integration.test.ts @@ -37,7 +37,7 @@ const AGENT_DEFAULTS: Agent = { id: "agent", provider: "codex", status: "idle", - activeTurn: null, + turn: { phase: "idle", cancellationRequestId: null }, createdAt: AGENT_TIMESTAMP, updatedAt: AGENT_TIMESTAMP, lastUserMessageAt: null, diff --git a/packages/app/src/subagents/archive-finished.test.ts b/packages/app/src/subagents/archive-finished.test.ts index 42a3e51ca6..5c1615d96c 100644 --- a/packages/app/src/subagents/archive-finished.test.ts +++ b/packages/app/src/subagents/archive-finished.test.ts @@ -11,6 +11,10 @@ function paseo(id: string, status: PaseoSubagentRow["status"] = "idle"): PaseoSu description: null, subtitle: null, status, + turn: + status === "running" + ? { phase: "open", turnId: null, startedAt: null, cancellationRequestId: null } + : { phase: "idle", cancellationRequestId: null }, requiresAttention: false, createdAt: new Date(), }; diff --git a/packages/app/src/subagents/archive-subagent.test.ts b/packages/app/src/subagents/archive-subagent.test.ts index 9314961b16..cb41ef12dd 100644 --- a/packages/app/src/subagents/archive-subagent.test.ts +++ b/packages/app/src/subagents/archive-subagent.test.ts @@ -62,7 +62,7 @@ describe("resolveArchiveSubagentDialog", () => { expect( resolveArchiveSubagentDialog({ title: "Review branch", - status: "running", + turn: { phase: "open", turnId: null, startedAt: null, cancellationRequestId: null }, }), ).toEqual({ title: "Archive running subagent?", @@ -78,7 +78,7 @@ describe("resolveArchiveSubagentDialog", () => { expect( resolveArchiveSubagentDialog({ title: "Starting child", - status: "initializing", + turn: { phase: "idle", cancellationRequestId: null }, }), ).toEqual({ title: "Archive subagent?", @@ -93,7 +93,7 @@ describe("resolveArchiveSubagentDialog", () => { expect( resolveArchiveSubagentDialog({ title: "Review branch", - status: "idle", + turn: { phase: "idle", cancellationRequestId: null }, }), ).toEqual({ title: "Archive subagent?", @@ -108,7 +108,7 @@ describe("resolveArchiveSubagentDialog", () => { expect( resolveArchiveSubagentDialog({ title: "New Agent", - status: null, + turn: null, }), ).toEqual({ title: "Archive subagent?", @@ -127,7 +127,10 @@ describe("requestArchiveSubagent", () => { initialSubagents: [ { id: "child-agent", - snapshot: { title: "Review branch", status: "running" }, + snapshot: { + title: "Review branch", + turn: { phase: "open", turnId: null, startedAt: null, cancellationRequestId: null }, + }, }, ], }); @@ -143,7 +146,10 @@ describe("requestArchiveSubagent", () => { initialSubagents: [ { id: "child-agent", - snapshot: { title: "Review branch", status: "idle" }, + snapshot: { + title: "Review branch", + turn: { phase: "idle", cancellationRequestId: null }, + }, }, ], }); @@ -159,7 +165,10 @@ describe("requestArchiveSubagent", () => { initialSubagents: [ { id: "child-agent", - snapshot: { title: "Review branch", status: "running" }, + snapshot: { + title: "Review branch", + turn: { phase: "open", turnId: null, startedAt: null, cancellationRequestId: null }, + }, }, ], }); @@ -167,7 +176,10 @@ describe("requestArchiveSubagent", () => { await requestArchiveSubagent({ serverId: "server-1", subagentId: "child-agent" }, env.deps); expect(env.recordedConfirmInputs).toEqual([ - resolveArchiveSubagentDialog({ title: "Review branch", status: "running" }), + resolveArchiveSubagentDialog({ + title: "Review branch", + turn: { phase: "open", turnId: null, startedAt: null, cancellationRequestId: null }, + }), ]); }); @@ -177,7 +189,7 @@ describe("requestArchiveSubagent", () => { await requestArchiveSubagent({ serverId: "server-1", subagentId: "missing" }, env.deps); expect(env.recordedConfirmInputs).toEqual([ - resolveArchiveSubagentDialog({ title: undefined, status: undefined }), + resolveArchiveSubagentDialog({ title: undefined, turn: undefined }), ]); expect(env.recordedArchives).toEqual([]); }); @@ -188,7 +200,10 @@ describe("requestArchiveSubagent", () => { initialSubagents: [ { id: "child-agent", - snapshot: { title: "Review branch", status: "running" }, + snapshot: { + title: "Review branch", + turn: { phase: "open", turnId: null, startedAt: null, cancellationRequestId: null }, + }, }, ], }); diff --git a/packages/app/src/subagents/archive-subagent.ts b/packages/app/src/subagents/archive-subagent.ts index 653dcb1cb2..1ff23b526d 100644 --- a/packages/app/src/subagents/archive-subagent.ts +++ b/packages/app/src/subagents/archive-subagent.ts @@ -3,7 +3,7 @@ import type { ConfirmDialogInput } from "@/utils/confirm-dialog"; export interface ResolveArchiveSubagentDialogInput { title: Agent["title"] | null | undefined; - status: Agent["status"] | null | undefined; + turn: Agent["turn"] | null | undefined; } function resolveSubagentLabel(title: Agent["title"] | null | undefined): string | null { @@ -24,7 +24,7 @@ export function resolveArchiveSubagentDialog( input: ResolveArchiveSubagentDialogInput, ): ConfirmDialogInput { const subagentLabel = resolveSubagentLabel(input.title) ?? "this subagent"; - const isRunning = input.status === "running"; + const isRunning = input.turn?.phase === "open"; return { title: isRunning ? "Archive running subagent?" : "Archive subagent?", @@ -57,7 +57,7 @@ export async function requestArchiveSubagent( const confirmed = await deps.confirm( resolveArchiveSubagentDialog({ title: subagent?.title, - status: subagent?.status, + turn: subagent?.turn, }), ); if (!confirmed) { diff --git a/packages/app/src/subagents/select.test.ts b/packages/app/src/subagents/select.test.ts index 068210233a..11196f9835 100644 --- a/packages/app/src/subagents/select.test.ts +++ b/packages/app/src/subagents/select.test.ts @@ -13,7 +13,7 @@ const AGENT_DEFAULTS: Agent = { id: "agent", provider: "codex", status: "idle", - activeTurn: null, + turn: { phase: "idle", cancellationRequestId: null }, createdAt: AGENT_TIMESTAMP, updatedAt: AGENT_TIMESTAMP, lastUserMessageAt: null, @@ -290,7 +290,13 @@ describe("selectSubagentsForParent", () => { parentAgentId: "parent", provider: "claude", title: "Review child", - status: "running", + status: "idle", + turn: { + phase: "open", + turnId: "turn-1", + startedAt: createdAt, + cancellationRequestId: null, + }, requiresAttention: true, createdAt, model: "should-not-leak", @@ -315,7 +321,13 @@ describe("selectSubagentsForParent", () => { title: "Review child", description: null, subtitle: null, - status: "running", + status: "idle", + turn: { + phase: "open", + turnId: "turn-1", + startedAt: createdAt, + cancellationRequestId: null, + }, requiresAttention: true, createdAt, }, @@ -330,6 +342,7 @@ describe("selectSubagentsForParent", () => { "status", "subtitle", "title", + "turn", ]); expect(rows[0]).not.toHaveProperty("onOpen"); expect(rows[0]).not.toHaveProperty("model"); diff --git a/packages/app/src/subagents/select.ts b/packages/app/src/subagents/select.ts index b9e1883705..2aeb070f5d 100644 --- a/packages/app/src/subagents/select.ts +++ b/packages/app/src/subagents/select.ts @@ -2,7 +2,12 @@ import { useEffect, useMemo } from "react"; import { usePendingArchiveAgentIds } from "@/hooks/use-archive-agent"; import equal from "fast-deep-equal"; import { useStoreWithEqualityFn } from "zustand/traditional"; -import { useSessionStore, type Agent } from "@/stores/session-store"; +import { useHostRuntimeClient } from "@/runtime/host-runtime"; +import { + useAgentDirectoryFields, + useServerFeature, + type Agent, +} from "@/stores/session-store-hooks"; import { refreshProviderSubagents, useProviderSubagentStore } from "./provider-store"; import type { ProviderSubagentDescriptorPayload } from "@getpaseo/protocol/messages"; @@ -15,6 +20,7 @@ export interface PaseoSubagentRow { description: null; subtitle: null; status: Agent["status"]; + turn: Agent["turn"]; requiresAttention: Agent["requiresAttention"]; createdAt: Agent["createdAt"]; } @@ -38,7 +44,9 @@ export interface ProviderSubagentRow { export type SubagentRow = PaseoSubagentRow | ProviderSubagentRow; -type SessionStoreSnapshot = ReturnType; +interface SessionStoreSnapshot { + sessions: Record }>; +} type ProviderSubagentStoreSnapshot = ReturnType; interface SelectSubagentsParams { @@ -60,6 +68,7 @@ function toSubagentRow(agent: Agent): SubagentRow { description: null, subtitle: null, status: agent.status, + turn: agent.turn, requiresAttention: agent.requiresAttention, createdAt: agent.createdAt, }; @@ -132,24 +141,24 @@ export function selectProviderSubagentsForParent( export function useSubagentsForParent(params: SelectSubagentsParams): SubagentRow[] { const pendingArchiveIds = usePendingArchiveAgentIds(params.serverId); - const paseoRows = useStoreWithEqualityFn( - useSessionStore, - (state) => selectSubagentsForParent(state, params, pendingArchiveIds), + const paseoRows = useAgentDirectoryFields( + params.serverId, + (directory) => + selectSubagentsForParent( + { sessions: { [params.serverId]: { agents: directory.agents } } }, + params, + pendingArchiveIds, + ), equal, ); - const supported = useSessionStore( - (state) => state.sessions[params.serverId]?.serverInfo?.features?.providerSubagents === true, - ); - const nestingSupported = useSessionStore( - (state) => - state.sessions[params.serverId]?.serverInfo?.features?.providerSubagentNesting === true, - ); + const supported = useServerFeature(params.serverId, "providerSubagents"); + const nestingSupported = useServerFeature(params.serverId, "providerSubagentNesting"); const providerRows = useStoreWithEqualityFn( useProviderSubagentStore, (state) => selectProviderSubagentsForParent(state, params, supported, nestingSupported), equal, ); - const client = useSessionStore((state) => state.sessions[params.serverId]?.client ?? null); + const client = useHostRuntimeClient(params.serverId); useEffect(() => { if (!client || !supported) return; diff --git a/packages/app/src/subagents/track-presentation.test.ts b/packages/app/src/subagents/track-presentation.test.ts index 516e2fafb7..df009583be 100644 --- a/packages/app/src/subagents/track-presentation.test.ts +++ b/packages/app/src/subagents/track-presentation.test.ts @@ -19,6 +19,11 @@ function row( description: null, subtitle: null, status: overrides.status ?? "idle", + turn: + overrides.turn ?? + (overrides.status === "running" + ? { phase: "open", turnId: null, startedAt: null, cancellationRequestId: null } + : { phase: "idle", cancellationRequestId: null }), requiresAttention: overrides.requiresAttention ?? false, createdAt: overrides.createdAt ?? new Date("2026-04-20T00:00:00.000Z"), }; diff --git a/packages/app/src/subagents/track-presentation.ts b/packages/app/src/subagents/track-presentation.ts index 1e2ddbb8d8..d95082ca39 100644 --- a/packages/app/src/subagents/track-presentation.ts +++ b/packages/app/src/subagents/track-presentation.ts @@ -7,7 +7,10 @@ import { isFinishedSubagent } from "./archive-finished"; import { providerSubagentLifecycleStatus } from "./provider-store"; function presentationStatus(row: SubagentRow) { - if (row.kind === "paseo") return row.status; + if (row.kind === "paseo") { + if (row.turn.phase === "open") return "running"; + return row.status === "running" ? "idle" : row.status; + } return providerSubagentLifecycleStatus(row.status); } diff --git a/packages/app/src/subagents/use-archive-finished.ts b/packages/app/src/subagents/use-archive-finished.ts index e7653f054e..1ecc19e485 100644 --- a/packages/app/src/subagents/use-archive-finished.ts +++ b/packages/app/src/subagents/use-archive-finished.ts @@ -1,6 +1,6 @@ import { useEffect, useMemo, useSyncExternalStore } from "react"; import { useArchiveAgent } from "@/hooks/use-archive-agent"; -import { useSessionStore } from "@/stores/session-store"; +import { getAgentSnapshot } from "@/runtime/session-data"; import { createArchiveFinishedSubagents, type ArchiveFinishedOutcome, @@ -32,7 +32,7 @@ export function useArchiveFinishedSubagents({ () => createArchiveFinishedSubagents([], { parentAgentId, - getManagedSubagent: (id) => useSessionStore.getState().sessions[serverId]?.agents.get(id), + getManagedSubagent: (id) => getAgentSnapshot(serverId, id) ?? undefined, archiveManagedSubagent: (id) => archiveAgent({ serverId, agentId: id }), dismissProviderSubagents: (ids) => { useProviderSubagentStore.getState().hideFromTrack(serverId, parentAgentId, ids); diff --git a/packages/app/src/subagents/use-archive-subagent.ts b/packages/app/src/subagents/use-archive-subagent.ts index b00f9bb871..1c5a1ecfb1 100644 --- a/packages/app/src/subagents/use-archive-subagent.ts +++ b/packages/app/src/subagents/use-archive-subagent.ts @@ -1,7 +1,7 @@ import { useCallback } from "react"; import { useToast } from "@/contexts/toast-context"; import { useArchiveAgent } from "@/hooks/use-archive-agent"; -import { useSessionStore } from "@/stores/session-store"; +import { getAgentSnapshot } from "@/runtime/session-data"; import { confirmDialog } from "@/utils/confirm-dialog"; import { toErrorMessage } from "@/utils/error-messages"; import { requestArchiveSubagent, type ResolveArchiveSubagentDialogInput } from "./archive-subagent"; @@ -28,7 +28,7 @@ export function useArchiveSubagent(input: UseArchiveSubagentInput): (subagentId: { serverId, subagentId }, { getSubagent: (id): ResolveArchiveSubagentDialogInput | undefined => - useSessionStore.getState().sessions[serverId]?.agents?.get(id), + getAgentSnapshot(serverId, id) ?? undefined, confirm: confirmDialog, archiveAgent, reportError: (error) => { diff --git a/packages/app/src/subagents/use-detach-subagent.ts b/packages/app/src/subagents/use-detach-subagent.ts index 36b2f2a560..92b4744e0b 100644 --- a/packages/app/src/subagents/use-detach-subagent.ts +++ b/packages/app/src/subagents/use-detach-subagent.ts @@ -1,7 +1,8 @@ import { useCallback } from "react"; import { useToast } from "@/contexts/toast-context"; import { i18n } from "@/i18n/i18next"; -import { useSessionStore } from "@/stores/session-store"; +import { getHostClient } from "@/runtime/host-runtime"; +import { getAgentSnapshot } from "@/runtime/session-data"; import { confirmDialog } from "@/utils/confirm-dialog"; import { toErrorMessage } from "@/utils/error-messages"; import { navigateToAgent } from "@/utils/navigate-to-agent"; @@ -28,10 +29,10 @@ export function useDetachSubagent(input: UseDetachSubagentInput): (subagentId: s { serverId, subagentId }, { getSubagent: (id): ResolveDetachSubagentDialogInput | undefined => - useSessionStore.getState().sessions[serverId]?.agents?.get(id), + getAgentSnapshot(serverId, id) ?? undefined, confirm: confirmDialog, - detachAgent: async ({ serverId: targetServerId, agentId }) => { - const client = useSessionStore.getState().sessions[targetServerId]?.client; + detachAgent: async ({ agentId }) => { + const client = getHostClient(serverId); if (!client) { throw new Error(i18n.t("workspaceSetup.errors.hostDisconnected")); } diff --git a/packages/app/src/subagents/use-open-agent-tab-labels.ts b/packages/app/src/subagents/use-open-agent-tab-labels.ts index ea02fd1b16..18d802012a 100644 --- a/packages/app/src/subagents/use-open-agent-tab-labels.ts +++ b/packages/app/src/subagents/use-open-agent-tab-labels.ts @@ -1,7 +1,7 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { getOpenAgentTabLabel } from "@getpaseo/protocol/agent-labels"; import { useEffect, useRef, useState } from "react"; -import { useSessionStore } from "@/stores/session-store"; +import { useAgentDirectoryFields } from "@/stores/session-store-hooks"; import { getOrCreateClientId } from "@/utils/client-id"; import type { WorkspaceTab } from "@/workspace-tabs/model"; import { getAgentTabsNeedingOpenLabel } from "./open-tab-labels"; @@ -16,9 +16,10 @@ export function useOpenAgentTabLabels(input: { tabs: WorkspaceTab[]; enabled: boolean; }): void { - const agents = useSessionStore((state) => state.sessions[input.serverId]?.agents ?? null); - const agentDetails = useSessionStore( - (state) => state.sessions[input.serverId]?.agentDetails ?? null, + const activeAgents = useAgentDirectoryFields(input.serverId, ({ agents }) => agents); + const agentDetails = useAgentDirectoryFields( + input.serverId, + (directory) => directory.agentDetails, ); const pendingAgentIdsRef = useRef(new Set()); const retryTimerRef = useRef | null>(null); @@ -57,7 +58,7 @@ export function useOpenAgentTabLabels(input: { const label = getOpenAgentTabLabel(clientId); const agentIds = getAgentTabsNeedingOpenLabel({ tabs: input.tabs, - getAgent: (agentId) => agents?.get(agentId) ?? agentDetails?.get(agentId), + getAgent: (agentId) => activeAgents.get(agentId) ?? agentDetails.get(agentId), label, pendingAgentIds: pendingAgentIdsRef.current, }); @@ -82,5 +83,5 @@ export function useOpenAgentTabLabels(input: { console.warn("[OpenAgentTabLabels] Failed to resolve client ID", { error }); } })(); - }, [agentDetails, agents, input.client, input.enabled, input.tabs, retryVersion]); + }, [activeAgents, agentDetails, input.client, input.enabled, input.tabs, retryVersion]); } diff --git a/packages/app/src/timeline/agent-timeline-service.ts b/packages/app/src/timeline/agent-timeline-service.ts new file mode 100644 index 0000000000..f0b2f8df7f --- /dev/null +++ b/packages/app/src/timeline/agent-timeline-service.ts @@ -0,0 +1,36 @@ +import type { AgentTimelinePromptIndexPayload } from "@getpaseo/client/internal/daemon-client"; +import { fetchAgentTimeline, getHostClient } from "@/runtime/host-runtime"; + +export async function listAgentTimelinePrompts( + serverId: string, + agentId: string, +): Promise { + return (await getHostClient(serverId)?.listAgentTimelinePrompts(agentId)) ?? null; +} + +export function subscribeToAgentTimelinePrompts( + serverId: string, + agentId: string, + refresh: () => void, +): () => void { + const client = getHostClient(serverId); + if (!client) return () => undefined; + return client.on("agent_stream", (message) => { + if ( + message.type === "agent_stream" && + message.payload.agentId === agentId && + message.payload.event.type === "timeline" && + message.payload.event.item.type === "user_message" + ) { + refresh(); + } + }); +} + +export function loadAgentTimelineWindow( + serverId: string, + agentId: string, + request: Parameters[2], +) { + return fetchAgentTimeline(serverId, agentId, request); +} diff --git a/packages/app/src/timeline/replica.test.ts b/packages/app/src/timeline/replica.test.ts index daaadd8c08..fc773b33f8 100644 --- a/packages/app/src/timeline/replica.test.ts +++ b/packages/app/src/timeline/replica.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it } from "vitest"; import type { AgentStreamEventPayload } from "@getpaseo/protocol/messages"; -import type { CachedTimeline } from "@/runtime/replica-cache"; +import type { CachedTimeline } from "@/runtime/client-replica"; import { selectAgentTimelineState, useSessionStore } from "@/stores/session-store"; import type { StreamItem } from "@/types/stream"; import { @@ -173,6 +173,59 @@ describe("viewed timeline persistence", () => { owner.dispose(); }); + it("reopens painted live mutations without persisting authoritative coverage", async () => { + useSessionStore.getState().initializeSession(SERVER_ID, null); + let durable: CachedTimeline | undefined = cachedTimeline(); + let pending: CachedTimeline | undefined; + const storage: TimelineReplicaStorage = { + readTimeline: async () => durable, + commitTimeline: (_serverId, _agentId, timeline) => { + pending = timeline; + }, + }; + const first = createOwner(storage); + first.replaceVisibleAgentIds("test", [AGENT_ID]); + await expect + .poll(() => + selectAgentTimelineState(useSessionStore.getState().sessions[SERVER_ID], AGENT_ID), + ) + .toMatchObject({ status: "painted" }); + + first.enqueueStreamEvent(AGENT_ID, { + event: { + type: "timeline", + provider: "codex", + item: { type: "assistant_message", text: "live", messageId: "live" }, + } as AgentStreamEventPayload, + seq: 5, + epoch: "epoch-1", + timestamp: new Date("2026-08-26T10:00:01.000Z"), + }); + first.flushStreamAgent(AGENT_ID); + + expect(pending).toMatchObject({ range: null, hasOlder: false }); + expect(pending?.items.map((entry) => entry.id)).toEqual(["cached", expect.any(String)]); + durable = pending; + first.dispose(); + useSessionStore.getState().clearSession(SERVER_ID); + useSessionStore.getState().initializeSession(SERVER_ID, null); + + const reopened = createOwner(storage); + reopened.replaceVisibleAgentIds("test", [AGENT_ID]); + await expect + .poll(() => + selectAgentTimelineState(useSessionStore.getState().sessions[SERVER_ID], AGENT_ID), + ) + .toMatchObject({ + status: "painted", + items: [ + expect.objectContaining({ id: "cached" }), + expect.objectContaining({ text: "live" }), + ], + }); + reopened.dispose(); + }); + it("does not let a late cache read overwrite newer network state", async () => { useSessionStore.getState().initializeSession(SERVER_ID, null); let release!: (value: CachedTimeline) => void; diff --git a/packages/app/src/timeline/viewed-timeline-sync.ts b/packages/app/src/timeline/viewed-timeline-sync.ts index d6bd3b2a20..5fbac95f3a 100644 --- a/packages/app/src/timeline/viewed-timeline-sync.ts +++ b/packages/app/src/timeline/viewed-timeline-sync.ts @@ -3,7 +3,7 @@ import { planTimelineResumeFetch, type ProjectedTimelineForwardFetchPlan, } from "./timeline-sync-plan"; -import type { CachedTimeline } from "@/runtime/replica-cache"; +import type { CachedTimeline } from "@/runtime/client-replica"; import { selectAgentTimelineState, useSessionStore, @@ -141,13 +141,13 @@ class TimelineReplicaOwner implements TimelineReplica { timelineUpdated(agentId: string): void { const session = useSessionStore.getState().sessions[this.serverId]; const timeline = selectAgentTimelineState(session, agentId); - if (timeline.status !== "synced") return; - this.cachedRanges.delete(agentId); + if (timeline.status === "cold") return; + if (timeline.status === "synced") this.cachedRanges.delete(agentId); this.storage.commitTimeline(this.serverId, agentId, { agentId, items: [...timeline.items, ...(session?.agentStreamHead.get(agentId) ?? [])], - range: timeline.range, - hasOlder: timeline.older === "available", + range: timeline.status === "synced" ? timeline.range : null, + hasOlder: timeline.status === "synced" && timeline.older === "available", }); } } @@ -250,7 +250,7 @@ function finalizeProcessedTimeline(input: { .clearByAgent({ serverId: input.serverId, agentId: input.agentId }); const session = useSessionStore.getState().sessions[input.serverId]; const agent = session?.agents.get(input.agentId) ?? session?.agentDetails.get(input.agentId); - if (agent && agent.status !== "running") input.drainQueuedAgentMessage(input.agentId); + if (agent && agent.turn.phase === "idle") input.drainQueuedAgentMessage(input.agentId); } if (input.result.initResolution === "resolve") resolveInitDeferred(input.initKey); } diff --git a/packages/app/src/types/agent-directory.ts b/packages/app/src/types/agent-directory.ts index 19beea5ecb..8ac1870a6f 100644 --- a/packages/app/src/types/agent-directory.ts +++ b/packages/app/src/types/agent-directory.ts @@ -6,6 +6,7 @@ export type AgentDirectoryEntry = Pick< | "serverId" | "title" | "status" + | "turn" | "lastActivityAt" | "cwd" | "workspaceId" diff --git a/packages/app/src/utils/agent-attention.test.ts b/packages/app/src/utils/agent-attention.test.ts index b9a0d8e6be..ac5a6f84f3 100644 --- a/packages/app/src/utils/agent-attention.test.ts +++ b/packages/app/src/utils/agent-attention.test.ts @@ -34,7 +34,7 @@ function createAgent(input: Partial & Pick): Agent { attentionReason: null, attentionTimestamp: null, ...rest, - activeTurn: rest.activeTurn ?? null, + turn: rest.turn ?? { phase: "idle", cancellationRequestId: null }, }; } diff --git a/packages/app/src/utils/agent-directory-reconciliation.test.ts b/packages/app/src/utils/agent-directory-reconciliation.test.ts index bf1e0a53ef..85e311345b 100644 --- a/packages/app/src/utils/agent-directory-reconciliation.test.ts +++ b/packages/app/src/utils/agent-directory-reconciliation.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from "vitest"; import type { FetchAgentsEntry } from "@getpaseo/client/internal/daemon-client"; import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages"; -import type { Agent } from "@/stores/session-store"; import { reconcileAgentDirectory } from "./agent-directory-reconciliation"; function snapshot(id: string, status: AgentSnapshotPayload["status"]): AgentSnapshotPayload { @@ -50,28 +49,9 @@ function entry(id: string, status: AgentSnapshotPayload["status"]): FetchAgentsE }; } -function replica(id: string, status: Agent["status"]): Agent { - return { - ...snapshot(id, status), - activeTurn: null, - serverId: "server", - createdAt: new Date("2026-07-12T10:00:00.000Z"), - updatedAt: new Date("2026-07-12T10:00:00.000Z"), - lastActivityAt: new Date("2026-07-12T10:00:00.000Z"), - lastUserMessageAt: null, - attentionTimestamp: null, - archivedAt: null, - parentAgentId: null, - }; -} - describe("agent directory reconciliation", () => { - it("reports snapshot-only and buffered running transitions exactly once", () => { + it("preserves snapshot and buffered protocol status without interpreting liveness", () => { const result = reconcileAgentDirectory({ - previous: new Map([ - ["snapshot", replica("snapshot", "running")], - ["buffered", replica("buffered", "running")], - ]), snapshot: [entry("snapshot", "idle"), entry("buffered", "running")], deltas: [ { @@ -87,8 +67,7 @@ describe("agent directory reconciliation", () => { ], }); - expect(result.stoppedRunningAgentIds).toEqual(["snapshot", "buffered"]); - expect(result.entries.map(({ agent }) => [agent.id, agent.status])).toEqual([ + expect(result.map(({ agent }) => [agent.id, agent.status])).toEqual([ ["snapshot", "idle"], ["buffered", "idle"], ]); @@ -96,7 +75,6 @@ describe("agent directory reconciliation", () => { it("preserves ordered upserts and removals received after page one", () => { const result = reconcileAgentDirectory({ - previous: new Map(), snapshot: [entry("updated", "idle"), entry("removed", "idle")], deltas: [ { @@ -108,9 +86,7 @@ describe("agent directory reconciliation", () => { ], }); - expect(result.entries.map(({ agent }) => [agent.id, agent.title])).toEqual([ - ["updated", "live"], - ]); + expect(result.map(({ agent }) => [agent.id, agent.title])).toEqual([["updated", "live"]]); }); it("keeps newer page metadata when a stale buffered upsert arrives", () => { @@ -120,7 +96,6 @@ describe("agent directory reconciliation", () => { projectName: "stale project", }; const result = reconcileAgentDirectory({ - previous: new Map([["agent", replica("agent", "running")]]), snapshot: [ { ...entry("agent", "running"), @@ -145,16 +120,14 @@ describe("agent directory reconciliation", () => { }); expect({ - title: result.entries[0]?.agent.title, - status: result.entries[0]?.agent.status, - projectName: result.entries[0]?.project.projectName, - stopped: result.stoppedRunningAgentIds, - }).toEqual({ title: "newer page", status: "running", projectName: "repo", stopped: [] }); + title: result[0]?.agent.title, + status: result[0]?.agent.status, + projectName: result[0]?.project.projectName, + }).toEqual({ title: "newer page", status: "running", projectName: "repo" }); }); it("clears a snapshot stop when a newer buffered upsert is running", () => { const result = reconcileAgentDirectory({ - previous: new Map([["agent", replica("agent", "running")]]), snapshot: [entry("agent", "idle")], deltas: [ { @@ -168,13 +141,11 @@ describe("agent directory reconciliation", () => { ], }); - expect(result.entries[0]?.agent.status).toBe("running"); - expect(result.stoppedRunningAgentIds).toEqual([]); + expect(result[0]?.agent.status).toBe("running"); }); it("accepts usage from a stale buffered upsert without regressing metadata", () => { const result = reconcileAgentDirectory({ - previous: new Map(), snapshot: [ { ...entry("agent", "idle"), @@ -201,9 +172,9 @@ describe("agent directory reconciliation", () => { }); expect({ - title: result.entries[0]?.agent.title, - status: result.entries[0]?.agent.status, - usage: result.entries[0]?.agent.lastUsage, + title: result[0]?.agent.title, + status: result[0]?.agent.status, + usage: result[0]?.agent.lastUsage, }).toEqual({ title: "newer page", status: "idle", @@ -213,7 +184,6 @@ describe("agent directory reconciliation", () => { it("preserves usage when a stale buffered upsert omits it", () => { const result = reconcileAgentDirectory({ - previous: new Map(), snapshot: [ { ...entry("agent", "idle"), @@ -236,6 +206,6 @@ describe("agent directory reconciliation", () => { ], }); - expect(result.entries[0]?.agent.lastUsage).toEqual({ inputTokens: 10, outputTokens: 5 }); + expect(result[0]?.agent.lastUsage).toEqual({ inputTokens: 10, outputTokens: 5 }); }); }); diff --git a/packages/app/src/utils/agent-directory-reconciliation.ts b/packages/app/src/utils/agent-directory-reconciliation.ts index 86130f5d2a..fa34f911bd 100644 --- a/packages/app/src/utils/agent-directory-reconciliation.ts +++ b/packages/app/src/utils/agent-directory-reconciliation.ts @@ -1,39 +1,20 @@ import type { FetchAgentsEntry } from "@getpaseo/client/internal/daemon-client"; -import type { Agent } from "@/stores/session-store"; import type { AgentDirectoryDelta } from "./agent-directory-sync"; import { acceptAgentDirectoryUpdate } from "./agent-directory-update-policy"; export function reconcileAgentDirectory(input: { - previous: ReadonlyMap; snapshot: FetchAgentsEntry[]; deltas: readonly AgentDirectoryDelta[]; -}): { entries: FetchAgentsEntry[]; stoppedRunningAgentIds: string[] } { +}): FetchAgentsEntry[] { const entries = new Map(input.snapshot.map((entry) => [entry.agent.id, entry])); - const statuses = new Map(Array.from(input.previous, ([id, agent]) => [id, agent.status])); - const stoppedRunningAgentIds = new Set(); - - for (const entry of input.snapshot) { - if (statuses.get(entry.agent.id) === "running" && entry.agent.status !== "running") { - stoppedRunningAgentIds.add(entry.agent.id); - } - statuses.set(entry.agent.id, entry.agent.status); - } for (const delta of input.deltas) { if (delta.kind === "remove") { entries.delete(delta.agentId); - statuses.delete(delta.agentId); - stoppedRunningAgentIds.delete(delta.agentId); continue; } const previousEntry = entries.get(delta.agent.id); const acceptedAgent = acceptAgentDirectoryUpdate(previousEntry?.agent, delta.agent); - if (acceptedAgent.status === "running") { - stoppedRunningAgentIds.delete(delta.agent.id); - } else if (statuses.get(delta.agent.id) === "running") { - stoppedRunningAgentIds.add(delta.agent.id); - } - statuses.set(delta.agent.id, acceptedAgent.status); const previousProject = previousEntry?.project; const acceptedProject = acceptedAgent === delta.agent ? (delta.project ?? previousProject) : previousProject; @@ -55,8 +36,5 @@ export function reconcileAgentDirectory(input: { }); } - return { - entries: Array.from(entries.values()), - stoppedRunningAgentIds: Array.from(stoppedRunningAgentIds), - }; + return Array.from(entries.values()); } diff --git a/packages/app/src/utils/agent-directory-sync.test.ts b/packages/app/src/utils/agent-directory-sync.test.ts index 71fb4cc350..c8e2479894 100644 --- a/packages/app/src/utils/agent-directory-sync.test.ts +++ b/packages/app/src/utils/agent-directory-sync.test.ts @@ -8,7 +8,7 @@ import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; import { isAgentArchiving, setAgentArchiving } from "@/hooks/use-archive-agent"; import { queryClient } from "@/data/query-client"; import { createUserMessage } from "@/types/stream"; -import { applyAgentDirectoryDelta, replaceFetchedAgentDirectory } from "./agent-directory-sync"; +import { AgentStoreProjection } from "@/runtime/directory-sync/internal/agent-store"; function createAgentPayload( input: Partial> & { @@ -79,79 +79,18 @@ function beginPendingSubmission(serverId: string, agentId: string): string { return clientMessageId; } -function applyAgentStatus(input: { - serverId: string; - agentId: string; - status: AgentSnapshotPayload["status"]; - updatedAt: string; - lastUserMessageAt?: string; -}): void { - const agent = createAgentPayload({ - id: input.agentId, - status: input.status, - updatedAt: input.updatedAt, - lastUserMessageAt: input.lastUserMessageAt ?? null, - }); - applyAgentDirectoryDelta({ - serverId: input.serverId, - delta: { kind: "upsert", agent, project: createEntry(agent).project }, - }); -} - -describe("turn liveness authority", () => { - it("normalizes old-daemon status into the shared activity replica", () => { - const serverId = "server-turn-liveness"; - const agentId = "agent-1"; - const startedAt = "2026-07-27T10:00:01.000Z"; - const store = useSessionStore.getState(); - store.initializeSession(serverId, null as unknown as DaemonClient); - applyAgentStatus({ serverId, agentId, status: "idle", updatedAt: startedAt }); - - applyAgentStatus({ - serverId, - agentId, - status: "running", - updatedAt: "2026-07-27T10:00:02.000Z", - lastUserMessageAt: startedAt, - }); - expect(store.getSession(serverId)?.agentTurnLiveness.get(agentId)).toEqual({ - phase: "open", - turnId: null, - startedAt: new Date(startedAt), - cancellationRequestId: null, - }); - - applyAgentStatus({ - serverId, - agentId, - status: "idle", - updatedAt: "2026-07-27T10:00:03.000Z", - }); - - expect(store.getSession(serverId)?.agentTurnLiveness.has(agentId)).toBe(false); - store.clearSession(serverId); - }); -}); - describe("message submission authority", () => { it("does not settle a submission from an unrelated running transition", () => { const serverId = "server-running-is-not-submission-ack"; const agentId = "agent-1"; const store = useSessionStore.getState(); store.initializeSession(serverId, null as unknown as DaemonClient); - applyAgentStatus({ - serverId, - agentId, - status: "idle", - updatedAt: "2026-07-27T10:00:00.000Z", - }); const clientMessageId = beginPendingSubmission(serverId, agentId); - - applyAgentStatus({ - serverId, - agentId, - status: "running", - updatedAt: "2026-07-27T10:00:01.000Z", + const agent = createAgentPayload({ id: agentId, status: "running" }); + new AgentStoreProjection(serverId).applyDelta({ + kind: "upsert", + agent, + project: createEntry(agent).project, }); expect(useSessionStore.getState().sessions[serverId]?.messageSubmissions.get(agentId)).toEqual([ @@ -169,12 +108,6 @@ describe("message submission authority", () => { const agentId = "agent-1"; const store = useSessionStore.getState(); store.initializeSession(serverId, null as unknown as DaemonClient); - applyAgentStatus({ - serverId, - agentId, - status: "idle", - updatedAt: "2026-07-27T10:00:00.000Z", - }); const clientMessageId = beginPendingSubmission(serverId, agentId); store.setAgentStreamState(serverId, agentId, { tail: [ @@ -213,10 +146,9 @@ describe("replaceFetchedAgentDirectory", () => { store.initializeSession(serverId, null as unknown as DaemonClient); store.setInitializingAgents(serverId, new Map([["agent", true]])); - replaceFetchedAgentDirectory({ - serverId, - entries: [createEntry(createAgentPayload({ id: "agent" }))], - }); + new AgentStoreProjection(serverId).replaceFetched([ + createEntry(createAgentPayload({ id: "agent" })), + ]); expect(useSessionStore.getState().sessions[serverId]?.initializingAgents.get("agent")).toBe( true, @@ -229,29 +161,24 @@ describe("replaceFetchedAgentDirectory", () => { const store = useSessionStore.getState(); store.initializeSession(serverId, null as unknown as DaemonClient); - replaceFetchedAgentDirectory({ - serverId, - entries: [ - createEntry( - createAgentPayload({ - id: "child-1", - labels: { [PARENT_AGENT_ID_LABEL]: "parent-a" }, - }), - ), - ], - }); + const projection = new AgentStoreProjection(serverId); + projection.replaceFetched([ + createEntry( + createAgentPayload({ + id: "child-1", + labels: { [PARENT_AGENT_ID_LABEL]: "parent-a" }, + }), + ), + ]); - replaceFetchedAgentDirectory({ - serverId, - entries: [ - createEntry( - createAgentPayload({ - id: "child-1", - labels: { [PARENT_AGENT_ID_LABEL]: "parent-b" }, - }), - ), - ], - }); + projection.replaceFetched([ + createEntry( + createAgentPayload({ + id: "child-1", + labels: { [PARENT_AGENT_ID_LABEL]: "parent-b" }, + }), + ), + ]); expect( useSessionStore.getState().sessions[serverId]?.agents.get("child-1")?.parentAgentId, @@ -286,7 +213,7 @@ describe("replaceFetchedAgentDirectory", () => { store.setInitializingAgents(serverId, new Map([[agentId, true]])); setAgentArchiving({ queryClient, serverId, agentId, isArchiving: true }); - applyAgentDirectoryDelta({ serverId, delta: { kind: "remove", agentId } }); + new AgentStoreProjection(serverId).applyDelta({ kind: "remove", agentId }); const session = useSessionStore.getState().sessions[serverId]; expect({ @@ -346,28 +273,27 @@ describe("replaceFetchedAgentDirectory", () => { lastUsage: { inputTokens: 10, outputTokens: 5 }, pendingPermissions: [permission("current-permission")], }); - applyAgentDirectoryDelta({ - serverId, - delta: { kind: "upsert", agent: current, project: createEntry(current).project }, + const projection = new AgentStoreProjection(serverId); + projection.applyDelta({ + kind: "upsert", + agent: current, + project: createEntry(current).project, }); store.flushAgentLastActivity(); setAgentArchiving({ queryClient, serverId, agentId, isArchiving: true }); - const staleResult = applyAgentDirectoryDelta({ - serverId, - delta: { - kind: "upsert", - agent: { - ...current, - title: "stale", - status: "idle", - updatedAt: "2026-07-12T10:00:00.000Z", - lastUsage: { inputTokens: 20, outputTokens: 8 }, - pendingPermissions: [permission("stale-permission")], - archivedAt: "2026-07-12T10:00:00.000Z", - }, - project: createEntry(current).project, + const staleResult = projection.applyDelta({ + kind: "upsert", + agent: { + ...current, + title: "stale", + status: "idle", + updatedAt: "2026-07-12T10:00:00.000Z", + lastUsage: { inputTokens: 20, outputTokens: 8 }, + pendingPermissions: [permission("stale-permission")], + archivedAt: "2026-07-12T10:00:00.000Z", }, + project: createEntry(current).project, }); store.flushAgentLastActivity(); diff --git a/packages/app/src/utils/agent-directory-sync.ts b/packages/app/src/utils/agent-directory-sync.ts index 2d81158c81..d700a0f510 100644 --- a/packages/app/src/utils/agent-directory-sync.ts +++ b/packages/app/src/utils/agent-directory-sync.ts @@ -1,15 +1,8 @@ -import equal from "fast-deep-equal"; import type { FetchAgentsEntry } from "@getpaseo/client/internal/daemon-client"; -import { type Agent, useSessionStore } from "@/stores/session-store"; +import type { Agent } from "@/stores/session-store"; import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agent-snapshots"; import { resolveProjectPlacement } from "@/utils/project-placement"; import type { SessionOutboundMessage } from "@getpaseo/protocol/messages"; -import { clearArchiveAgentPending } from "@/hooks/use-archive-agent"; -import { queryClient } from "@/data/query-client"; -import { acceptAgentDirectoryUpdate } from "@/utils/agent-directory-update-policy"; -import { buildDraftStoreKey } from "@/stores/draft-keys"; -import { useDraftStore } from "@/stores/draft-store"; -import { getInitDeferred, getInitKey, rejectInitDeferred } from "@/utils/agent-initialization"; type AgentDirectoryFetchEntry = FetchAgentsEntry; export type AgentDirectoryDelta = Extract< @@ -17,128 +10,6 @@ export type AgentDirectoryDelta = Extract< { type: "agent_update" } >["payload"]; -export function applyAgentDirectoryDelta(input: { serverId: string; delta: AgentDirectoryDelta }): { - agentId: string; - stoppedRunning: boolean; -} { - if (input.delta.kind === "remove") { - removeAgentDirectoryReplica(input.serverId, input.delta.agentId); - return { agentId: input.delta.agentId, stoppedRunning: false }; - } - return upsertAgentDirectoryReplica(input.serverId, input.delta); -} - -type AgentUpsertDelta = Extract; - -function upsertAgentDirectoryReplica( - serverId: string, - delta: AgentUpsertDelta, -): { agentId: string; stoppedRunning: boolean } { - const normalized = normalizeAgentSnapshot(delta.agent, serverId); - const session = useSessionStore.getState().sessions[serverId]; - const previousAgent = - session?.agents.get(normalized.id) ?? session?.agentDetails.get(normalized.id); - const legacyWorkspaceId = - previousAgent?.workspaceId ?? - Array.from(session?.workspaces.values() ?? []).find( - (workspace) => - session?.serverInfo?.features?.workspaceMultiplicity !== true && - workspace.workspaceDirectory === normalized.cwd, - )?.id; - const agent: Agent = { - ...normalized, - workspaceId: normalized.workspaceId ?? legacyWorkspaceId, - projectPlacement: - resolveProjectPlacement({ projectPlacement: delta.project, cwd: normalized.cwd }) ?? - previousAgent?.projectPlacement, - }; - const acceptedAgent = upsertAgentReplica(serverId, agent); - if (acceptedAgent.archivedAt) { - clearArchiveAgentPending({ queryClient, serverId, agentId: acceptedAgent.id }); - } - replaceAgentPendingPermissions(serverId, acceptedAgent); - useSessionStore.getState().setAgentLastActivity(acceptedAgent.id, acceptedAgent.lastActivityAt); - return { - agentId: acceptedAgent.id, - stoppedRunning: previousAgent?.status === "running" && acceptedAgent.status !== "running", - }; -} - -export function upsertAgentReplica(serverId: string, agent: Agent): Agent { - let acceptedAgent = agent; - useSessionStore.getState().setAgents(serverId, (current) => { - const currentAgent = current.get(agent.id); - acceptedAgent = acceptAgentDirectoryUpdate(currentAgent, agent); - if (acceptedAgent === currentAgent) return current; - const next = new Map(current); - next.set(agent.id, acceptedAgent); - return next; - }); - applyAgentTurnSnapshot(serverId, acceptedAgent); - return acceptedAgent; -} - -function applyAgentTurnSnapshot(serverId: string, agent: Agent): void { - useSessionStore.getState().applyAgentTurnLiveness(serverId, agent.id, { - type: "snapshot", - activeTurn: agent.activeTurn, - }); -} - -export function replaceAgentPendingPermissions(serverId: string, agent: Agent): void { - const pendingPermissions = new Map( - useSessionStore.getState().sessions[serverId]?.pendingPermissions, - ); - for (const [key, pending] of pendingPermissions) { - if (pending.agentId === agent.id) pendingPermissions.delete(key); - } - for (const request of agent.pendingPermissions) { - const key = derivePendingPermissionKey(agent.id, request); - pendingPermissions.set(key, { key, agentId: agent.id, request }); - } - useSessionStore.getState().setPendingPermissions(serverId, pendingPermissions); -} - -export function removeAgentDirectoryReplica(serverId: string, agentId: string): void { - const store = useSessionStore.getState(); - clearArchiveAgentPending({ queryClient, serverId, agentId }); - const removeKey = (current: Map): Map => { - if (!current.has(agentId)) return current; - const next = new Map(current); - next.delete(agentId); - return next; - }; - store.setAgents(serverId, removeKey); - store.setAgentDetails(serverId, removeKey); - store.setQueuedMessages(serverId, removeKey); - store.setAgentTimelineCursor(serverId, removeKey); - store.setInitializingAgents(serverId, removeKey); - store.setPendingPermissions(serverId, (current) => { - const next = new Map(current); - for (const [key, pending] of next) { - if (pending.agentId === agentId) next.delete(key); - } - return next.size === current.size ? current : next; - }); - store.setAgentAuthoritativeHistoryApplied(serverId, agentId, false); - store.setAgentStreamTail(serverId, removeKey); - store.clearAgentStreamHead(serverId, agentId); - store.applyAgentTurnLiveness(serverId, agentId, { type: "destructive_close" }); - useSessionStore.setState((state) => { - if (!state.agentLastActivity.has(agentId)) return state; - const agentLastActivity = new Map(state.agentLastActivity); - agentLastActivity.delete(agentId); - return { ...state, agentLastActivity }; - }); - useDraftStore.getState().clearDraftInput({ - draftKey: buildDraftStoreKey({ serverId, agentId }), - }); - const initKey = getInitKey(serverId, agentId); - if (getInitDeferred(initKey)) { - rejectInitDeferred(initKey, new Error("Agent was removed during initialization")); - } -} - interface PendingPermissionEntry { key: string; agentId: string; @@ -175,49 +46,3 @@ export function buildAgentDirectoryState(input: { return { agents, pendingPermissions }; } - -export function replaceFetchedAgentDirectory(input: { - serverId: string; - entries: FetchAgentsEntry[]; -}): { agents: Map } { - const { agents: fetchedAgents, pendingPermissions } = buildAgentDirectoryState(input); - const store = useSessionStore.getState(); - for (const agent of fetchedAgents.values()) { - if (agent.archivedAt) { - clearArchiveAgentPending({ queryClient, serverId: input.serverId, agentId: agent.id }); - } - } - - const currentAgents = store.sessions[input.serverId]?.agents ?? new Map(); - const agents = new Map(); - for (const [agentId, fetchedAgent] of fetchedAgents) { - const current = currentAgents.get(agentId); - agents.set(agentId, current && equal(current, fetchedAgent) ? current : fetchedAgent); - } - - store.setAgents(input.serverId, agents); - for (const agent of agents.values()) { - applyAgentTurnSnapshot(input.serverId, agent); - } - store.setAgentDetails(input.serverId, (prev) => { - let next: Map | null = null; - for (const agentId of agents.keys()) { - if (!prev.has(agentId)) { - continue; - } - next ??= new Map(prev); - next.delete(agentId); - } - return next ?? prev; - }); - - const lastActivityByAgentId = new Map(); - for (const agent of agents.values()) { - lastActivityByAgentId.set(agent.id, agent.lastActivityAt); - } - store.setAgentLastActivityBatch(lastActivityByAgentId); - - store.setPendingPermissions(input.serverId, new Map(pendingPermissions)); - store.setHasHydratedAgents(input.serverId, true); - return { agents }; -} diff --git a/packages/app/src/utils/agent-grouping.test.ts b/packages/app/src/utils/agent-grouping.test.ts index 7df88ada97..f380dc1118 100644 --- a/packages/app/src/utils/agent-grouping.test.ts +++ b/packages/app/src/utils/agent-grouping.test.ts @@ -10,6 +10,14 @@ function makeAgent(overrides: Partial = {}): AggregatedAgent { serverLabel: (overrides as { serverLabel?: string }).serverLabel ?? "server", title: overrides.title ?? null, status: overrides.status ?? ("running" as AggregatedAgent["status"]), + turn: + overrides.turn ?? + ({ + phase: "open", + turnId: null, + startedAt: null, + cancellationRequestId: null, + } as const), lastActivityAt: overrides.lastActivityAt ?? now, cwd: overrides.cwd ?? "/tmp/repo", provider: overrides.provider ?? ("openai" as AggregatedAgent["provider"]), @@ -63,6 +71,28 @@ describe("deriveProjectDisplayName", () => { }); describe("groupAgents", () => { + it("uses turn liveness instead of protocol status for active grouping", () => { + const old = new Date("2020-01-01T00:00:00.000Z"); + const open = makeAgent({ id: "open", status: "idle", lastActivityAt: old }); + const idle = makeAgent({ + id: "idle", + status: "running", + lastActivityAt: old, + turn: { phase: "idle", cancellationRequestId: null }, + }); + + const grouped = groupAgents([open, idle]); + + const activeIds = grouped.activeGroups + .flatMap((group) => group.agents) + .map((agent) => agent.id); + const inactiveIds = grouped.inactiveGroups + .flatMap((group) => group.agents) + .map((agent) => agent.id); + expect(activeIds).toEqual(["open"]); + expect(inactiveIds).toEqual(["idle"]); + }); + it("groups active agents by remote URL when available", () => { const agents = [ makeAgent({ id: "a1", cwd: "/Users/me/dev/paseo" }), diff --git a/packages/app/src/utils/agent-grouping.ts b/packages/app/src/utils/agent-grouping.ts index a741a81d39..eaca2d1511 100644 --- a/packages/app/src/utils/agent-grouping.ts +++ b/packages/app/src/utils/agent-grouping.ts @@ -264,7 +264,9 @@ const MAX_INACTIVE_PER_PROJECT = 5; */ function isAgentTrulyActive(agent: AggregatedAgent): boolean { return ( - agent.status === "running" || agent.requiresAttention || (agent.pendingPermissionCount ?? 0) > 0 + agent.turn.phase === "open" || + agent.requiresAttention || + (agent.pendingPermissionCount ?? 0) > 0 ); } diff --git a/packages/app/src/utils/agent-snapshots.test.ts b/packages/app/src/utils/agent-snapshots.test.ts index 334e36d9f2..e1be8c5a0c 100644 --- a/packages/app/src/utils/agent-snapshots.test.ts +++ b/packages/app/src/utils/agent-snapshots.test.ts @@ -57,15 +57,25 @@ describe("normalizeAgentSnapshot", () => { activeTurn: { turnId: "turn-1", startedAt }, }), "server-1", - ).activeTurn, - ).toEqual({ turnId: "turn-1", startedAt: new Date(startedAt) }); + ).turn, + ).toEqual({ + phase: "open", + turnId: "turn-1", + startedAt: new Date(startedAt), + cancellationRequestId: null, + }); expect( normalizeAgentSnapshot( createSnapshot({ status: "running", lastUserMessageAt: startedAt }), "server-1", - ).activeTurn, - ).toEqual({ turnId: null, startedAt: new Date(startedAt) }); + ).turn, + ).toEqual({ + phase: "open", + turnId: null, + startedAt: new Date(startedAt), + cancellationRequestId: null, + }); }); it("derives parentAgentId from the parent label while preserving labels", () => { diff --git a/packages/app/src/utils/agent-snapshots.ts b/packages/app/src/utils/agent-snapshots.ts index a035a054a2..c1c70c640e 100644 --- a/packages/app/src/utils/agent-snapshots.ts +++ b/packages/app/src/utils/agent-snapshots.ts @@ -1,7 +1,11 @@ import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages"; import type { AgentPermissionRequest } from "@getpaseo/protocol/agent-types"; import { getParentAgentIdFromLabels } from "@getpaseo/protocol/agent-labels"; -import type { ActiveTurnIdentity } from "@/timeline/turn-liveness"; +import { + TURN_LIVENESS_IDLE, + type ActiveTurnIdentity, + type TurnLiveness, +} from "@/timeline/turn-liveness"; import type { Agent } from "@/stores/session-store"; function normalizeActiveTurn( @@ -18,13 +22,23 @@ function normalizeActiveTurn( return snapshot.status === "running" ? { turnId: null, startedAt: lastUserMessageAt } : null; } +function normalizeTurn( + snapshot: AgentSnapshotPayload, + lastUserMessageAt: Date | null, +): TurnLiveness { + const activeTurn = normalizeActiveTurn(snapshot, lastUserMessageAt); + return activeTurn + ? { phase: "open", ...activeTurn, cancellationRequestId: null } + : TURN_LIVENESS_IDLE; +} + function projectActiveTurn(agent: Agent): Pick { - if (agent.activeTurn === null) return { activeTurn: null }; - if (agent.activeTurn.turnId === null) return {}; + if (agent.turn.phase === "idle") return { activeTurn: null }; + if (agent.turn.turnId === null) return {}; return { activeTurn: { - turnId: agent.activeTurn.turnId, - startedAt: agent.activeTurn.startedAt?.toISOString() ?? null, + turnId: agent.turn.turnId, + startedAt: agent.turn.startedAt?.toISOString() ?? null, }, }; } @@ -88,14 +102,14 @@ export function normalizeAgentSnapshot(snapshot: AgentSnapshotPayload, serverId: // COMPAT(agentTurnIdentity): added in v0.2.6, remove after 2027-01-31 once daemon floor >= v0.2.6. // Old daemons expose only status. Normalize that legacy signal once so the rest // of the app consumes one activity shape. - const activeTurn = normalizeActiveTurn(snapshot, lastUserMessageAt); + const turn = normalizeTurn(snapshot, lastUserMessageAt); return { serverId, id: snapshot.id, provider: snapshot.provider, status: snapshot.status, - activeTurn, + turn, createdAt, updatedAt, lastUserMessageAt, diff --git a/packages/app/src/utils/workspace-agent-activity.test.ts b/packages/app/src/utils/workspace-agent-activity.test.ts index 09ada95695..7ccca095f1 100644 --- a/packages/app/src/utils/workspace-agent-activity.test.ts +++ b/packages/app/src/utils/workspace-agent-activity.test.ts @@ -6,6 +6,7 @@ function agent(input: { id: string; workspaceId?: string; status?: Agent["status"]; + turn?: Agent["turn"]; updatedAt: string; attentionTimestamp?: string | null; requiresAttention?: boolean; @@ -19,7 +20,16 @@ function agent(input: { id: input.id, provider: "codex", status: input.status ?? "idle", - activeTurn: input.status === "running" ? { turnId: "turn-1", startedAt: null } : null, + turn: + input.turn ?? + (input.status === "running" + ? { + phase: "open", + turnId: "turn-1", + startedAt: null, + cancellationRequestId: null, + } + : { phase: "idle", cancellationRequestId: null }), createdAt: new Date("2026-01-01T00:00:00.000Z"), updatedAt: new Date(input.updatedAt), lastUserMessageAt: null, @@ -56,6 +66,41 @@ function agent(input: { } describe("workspace agent activity index", () => { + it("uses turn liveness for running while preserving protocol lifecycle states", () => { + const result = buildWorkspaceAgentActivityIndex( + new Map([ + [ + "open", + agent({ + id: "open", + workspaceId: "workspace-open", + status: "idle", + turn: { + phase: "open", + turnId: null, + startedAt: null, + cancellationRequestId: null, + }, + updatedAt: "2026-01-01T00:00:00.000Z", + }), + ], + [ + "idle-error", + agent({ + id: "idle-error", + workspaceId: "workspace-error", + status: "error", + turn: { phase: "idle", cancellationRequestId: null }, + updatedAt: "2026-01-01T00:00:00.000Z", + }), + ], + ]), + ); + + expect(result.get("workspace-open")?.status).toBe("running"); + expect(result.get("workspace-error")?.status).toBe("failed"); + }); + it("keeps the latest active root agent for each workspace", () => { const index = buildWorkspaceAgentActivityIndex( new Map([ diff --git a/packages/app/src/utils/workspace-agent-activity.ts b/packages/app/src/utils/workspace-agent-activity.ts index 3e4355ee3a..69f3c23da4 100644 --- a/packages/app/src/utils/workspace-agent-activity.ts +++ b/packages/app/src/utils/workspace-agent-activity.ts @@ -8,6 +8,11 @@ export interface WorkspaceAgentActivity { enteredAt: Date | null; } +function workspaceAgentStatus(agent: Agent): Agent["status"] { + if (agent.turn.phase === "open") return "running"; + return agent.status === "running" ? "idle" : agent.status; +} + export function buildWorkspaceAgentActivityIndex( agents: ReadonlyMap, previous?: ReadonlyMap, @@ -29,7 +34,7 @@ export function buildWorkspaceAgentActivityIndex( latestActivityAtByWorkspaceId.set(agent.workspaceId, enteredAt); const status = deriveSidebarStateBucket({ - status: agent.status, + status: workspaceAgentStatus(agent), pendingPermissionCount: agent.pendingPermissions.length, requiresAttention: agent.requiresAttention, attentionReason: agent.attentionReason, diff --git a/packages/app/src/utils/workspace-identity.ts b/packages/app/src/utils/workspace-identity.ts index 8c778372d9..301230eb19 100644 --- a/packages/app/src/utils/workspace-identity.ts +++ b/packages/app/src/utils/workspace-identity.ts @@ -32,7 +32,7 @@ export function resolveWorkspaceRouteId(input: { } export function resolveWorkspaceMapKeyByIdentity(input: { - workspaces: Map | null | undefined; + workspaces: ReadonlyMap | null | undefined; workspaceId: string | null | undefined; }): string | null { const normalizedWorkspaceId = normalizeWorkspaceOpaqueId(input.workspaceId); diff --git a/packages/app/src/workspace-recovery/use-workspace-recovery.ts b/packages/app/src/workspace-recovery/use-workspace-recovery.ts index ab19957190..99209bd3a5 100644 --- a/packages/app/src/workspace-recovery/use-workspace-recovery.ts +++ b/packages/app/src/workspace-recovery/use-workspace-recovery.ts @@ -2,7 +2,7 @@ import { useCallback, useMemo } from "react"; import { useMutation } from "@tanstack/react-query"; import { useFetchQuery } from "@/data/query"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; -import { useSessionStore } from "@/stores/session-store"; +import { useHasHostServerInfo, useServerFeature } from "@/stores/session-store-hooks"; import { toErrorMessage } from "@/utils/error-messages"; import { recoverWorkspaceSelection, @@ -37,8 +37,8 @@ export function useWorkspaceRecovery(input: { }): WorkspaceRecoveryController { const client = useHostRuntimeClient(input.serverId); const isConnected = useHostRuntimeIsConnected(input.serverId); - const serverInfo = useSessionStore((store) => store.sessions[input.serverId]?.serverInfo ?? null); - const supportsRecovery = serverInfo?.features?.workspaceRecovery === true; + const supportsRecovery = useServerFeature(input.serverId, "workspaceRecovery"); + const hasServerInfo = useHasHostServerInfo(input.serverId); const inspection = useFetchQuery({ queryKey: ["workspaceRecovery", input.serverId, input.workspaceId], @@ -75,7 +75,7 @@ export function useWorkspaceRecovery(input: { enabled: input.enabled, connected: isConnected, hasClient: client !== null, - hasServerInfo: serverInfo !== null, + hasServerInfo, supportsRecovery, inspection: { pending: inspection.isPending, @@ -90,6 +90,7 @@ export function useWorkspaceRecovery(input: { [ client, input.enabled, + hasServerInfo, inspection.data, inspection.error, inspection.isError, @@ -98,7 +99,6 @@ export function useWorkspaceRecovery(input: { restoreMutation.error, restoreMutation.isError, restoreMutation.isPending, - serverInfo, supportsRecovery, ], ); diff --git a/packages/app/src/workspace-tabs/agent-visibility.test.ts b/packages/app/src/workspace-tabs/agent-visibility.test.ts index 8488c8f515..bece625a89 100644 --- a/packages/app/src/workspace-tabs/agent-visibility.test.ts +++ b/packages/app/src/workspace-tabs/agent-visibility.test.ts @@ -23,7 +23,7 @@ function makeAgent(input: { id: input.id, provider: "codex", status: "idle", - activeTurn: null, + turn: { phase: "idle", cancellationRequestId: null }, createdAt, updatedAt: createdAt, lastUserMessageAt: null, diff --git a/packages/app/src/workspace-tabs/agent-visibility.ts b/packages/app/src/workspace-tabs/agent-visibility.ts index 1f8f5c5ed4..4df685aa1f 100644 --- a/packages/app/src/workspace-tabs/agent-visibility.ts +++ b/packages/app/src/workspace-tabs/agent-visibility.ts @@ -14,8 +14,8 @@ function agentBelongsToWorkspace(agent: Agent, workspaceId: string): boolean { } export function deriveWorkspaceAgentVisibility(input: { - sessionAgents: Map | undefined; - agentDetails?: Map | undefined; + sessionAgents: ReadonlyMap | undefined; + agentDetails?: ReadonlyMap | undefined; workspaceId: string | null | undefined; }): WorkspaceAgentVisibility { const { sessionAgents, agentDetails } = input; diff --git a/packages/app/src/workspace/legacy-daemon-workspaces.test.ts b/packages/app/src/workspace/legacy-daemon-workspaces.test.ts index 7f50c4b57a..50dc1a8ca1 100644 --- a/packages/app/src/workspace/legacy-daemon-workspaces.test.ts +++ b/packages/app/src/workspace/legacy-daemon-workspaces.test.ts @@ -5,7 +5,6 @@ import { deriveWorkspaceAgentVisibility } from "@/workspace-tabs/agent-visibilit import { buildWorkspaceStructureProjects } from "@/projects/workspace-structure"; import { applyLegacyDaemonWorkspaceOwnership, - backfillLegacyDaemonWorkspaceDirectoryIfEmpty, buildLegacyDaemonWorkspaceSnapshot, } from "./legacy-daemon-workspaces"; @@ -197,47 +196,4 @@ describe("buildLegacyDaemonWorkspaceSnapshot", () => { expect(stampedUpdate.workspaceId).toBe("/repo/app"); expect(visibility.activeAgentIds).toEqual(new Set(["agent-running"])); }); - - it("does not backfill path-backed workspaces after hydration is cancelled", async () => { - const store = useSessionStore.getState(); - store.initializeSession(SERVER_ID, null as unknown as DaemonClient); - store.updateSessionServerInfo(SERVER_ID, { - serverId: SERVER_ID, - hostname: null, - version: "0.1.96", - }); - let cancelled = false; - let didFetchAgents = false; - const client: Pick = { - fetchAgents: async () => { - didFetchAgents = true; - cancelled = true; - return { - requestId: "req_cancelled_backfill", - subscriptionId: null, - entries: [legacyAgent({ id: "agent-cancelled", cwd: "/repo/app" })], - pageInfo: { - nextCursor: null, - prevCursor: null, - hasMore: false, - }, - }; - }, - }; - - const didBackfill = await backfillLegacyDaemonWorkspaceDirectoryIfEmpty({ - client, - serverId: SERVER_ID, - workspaces: new Map(), - projects: new Map(), - isCancelled: () => cancelled, - }); - - const session = useSessionStore.getState().sessions[SERVER_ID]; - expect(didFetchAgents).toBe(true); - expect(didBackfill).toBe(true); - expect(session?.agents.size).toBe(0); - expect(session?.workspaces.size).toBe(0); - expect(session?.hasHydratedWorkspaces).toBe(false); - }); }); diff --git a/packages/app/src/workspace/legacy-daemon-workspaces.ts b/packages/app/src/workspace/legacy-daemon-workspaces.ts index 6de82a3f3e..b3f35e31a0 100644 --- a/packages/app/src/workspace/legacy-daemon-workspaces.ts +++ b/packages/app/src/workspace/legacy-daemon-workspaces.ts @@ -7,17 +7,13 @@ import { deriveAgentStateBucket, getWorkspaceStateBucketPriority, } from "@getpaseo/protocol/agent-state-bucket"; -import type { - Agent, - DaemonServerInfo, - ProjectDescriptor, - WorkspaceDescriptor, -} from "@/stores/session-store"; -import { useSessionStore } from "@/stores/session-store"; +import type { Agent, DaemonServerInfo, WorkspaceDescriptor } from "@/stores/session-store-hooks"; import { - buildAgentDirectoryState, - replaceFetchedAgentDirectory, -} from "@/utils/agent-directory-sync"; + getAgentSnapshot, + getWorkspaceDirectorySnapshot, + readServerInfo, +} from "@/runtime/session-data"; +import { buildAgentDirectoryState } from "@/utils/agent-directory-sync"; import { normalizeWorkspacePath } from "@/utils/workspace-identity"; export interface LegacyDaemonWorkspaceSnapshot { @@ -25,23 +21,11 @@ export interface LegacyDaemonWorkspaceSnapshot { workspaces: Map; } -export interface LegacyDaemonWorkspaceFetchResult extends LegacyDaemonWorkspaceSnapshot { - subscriptionId: string | null; -} - interface LegacyDaemonWorkspaceDirectoryReadResult { entries: FetchAgentsEntry[]; subscriptionId: string | null; } -interface LegacyDaemonWorkspaceBackfillInput { - client: Pick; - serverId: string; - workspaces: ReadonlyMap; - projects: ReadonlyMap; - isCancelled?: () => boolean; -} - const LEGACY_AGENT_DIRECTORY_SORT: NonNullable = [ { key: "updated_at", direction: "desc" }, ]; @@ -82,50 +66,6 @@ function shouldBackfillLegacyDaemonWorkspaceDirectory( return serverInfo?.features?.workspaceMultiplicity !== true; } -export async function fetchLegacyDaemonWorkspaceDirectory(input: { - client: Pick; - serverId: string; - subscribe?: FetchAgentsOptions["subscribe"]; - page?: FetchAgentsOptions["page"]; -}): Promise { - const directory = await readLegacyDaemonWorkspaceDirectory(input); - if (!directory) { - throw new Error("Legacy daemon workspace directory fetch was cancelled."); - } - const snapshot = replaceLegacyDaemonWorkspaceDirectory({ - serverId: input.serverId, - entries: directory.entries, - }); - return { ...snapshot, subscriptionId: directory.subscriptionId }; -} - -export async function backfillLegacyDaemonWorkspaceDirectoryIfEmpty( - input: LegacyDaemonWorkspaceBackfillInput, -): Promise { - if (input.workspaces.size > 0 || input.projects.size > 0) { - return false; - } - const serverInfo = useSessionStore.getState().sessions[input.serverId]?.serverInfo; - if (!shouldBackfillLegacyDaemonWorkspaceDirectory(serverInfo)) { - return false; - } - if (input.isCancelled?.()) { - return true; - } - const directory = await readLegacyDaemonWorkspaceDirectory({ - client: input.client, - isCancelled: input.isCancelled, - }); - if (!directory || input.isCancelled?.()) { - return true; - } - replaceLegacyDaemonWorkspaceDirectory({ - serverId: input.serverId, - entries: directory.entries, - }); - return true; -} - export async function readLegacyDaemonWorkspaceDirectory(input: { client: Pick; subscribe?: FetchAgentsOptions["subscribe"]; @@ -176,16 +116,15 @@ export function applyLegacyDaemonWorkspaceOwnership(input: { return input.agent; } - const session = useSessionStore.getState().sessions[input.serverId]; - if (!shouldBackfillLegacyDaemonWorkspaceDirectory(session?.serverInfo)) { + if (!shouldBackfillLegacyDaemonWorkspaceDirectory(readServerInfo(input.serverId))) { return input.agent; } - const existingAgent = - session?.agents.get(input.agent.id) ?? session?.agentDetails.get(input.agent.id) ?? null; + const existingAgent = getAgentSnapshot(input.serverId, input.agent.id); + const workspaces = getWorkspaceDirectorySnapshot(input.serverId).workspaces; const workspaceId = existingAgent?.workspaceId ?? - resolveLegacyWorkspaceIdFromAgent(input.agent, session?.workspaces) ?? + resolveLegacyWorkspaceIdFromAgent(input.agent, workspaces) ?? null; if (!workspaceId) { return input.agent; @@ -198,37 +137,6 @@ export function applyLegacyDaemonWorkspaceOwnership(input: { }; } -export function replaceLegacyDaemonWorkspaceDirectory(input: { - serverId: string; - entries: FetchAgentsEntry[]; -}): LegacyDaemonWorkspaceSnapshot { - const entries = stampLegacyWorkspaceIds(input.entries); - const { agents } = replaceFetchedAgentDirectory({ - serverId: input.serverId, - entries, - }); - const workspaces = buildLegacyWorkspaces(entries); - const store = useSessionStore.getState(); - store.setWorkspaces(input.serverId, workspaces); - store.setProjects( - input.serverId, - Array.from(workspaces.values(), legacyProjectDescriptorFromWorkspace), - ); - store.setHasHydratedWorkspaces(input.serverId, true); - return { agents, workspaces }; -} - -function legacyProjectDescriptorFromWorkspace(workspace: WorkspaceDescriptor): ProjectDescriptor { - return { - projectId: workspace.projectId, - projectKey: null, - projectDisplayName: workspace.projectDisplayName, - projectCustomName: workspace.projectCustomName ?? null, - projectRootPath: workspace.projectRootPath, - projectKind: workspace.projectKind, - }; -} - function readFetchAgentsHasMore( pageInfo: Awaited>["pageInfo"], ): boolean { @@ -369,7 +277,7 @@ function resolveLegacyWorkspaceId(entry: FetchAgentsEntry): string { function resolveLegacyWorkspaceIdFromAgent( agent: Agent, - workspaces: Map | undefined, + workspaces: ReadonlyMap | undefined, ): string | null { const cwd = normalizeWorkspacePath(agent.cwd); if (!cwd) { diff --git a/packages/app/src/workspace/use-workspace-archive.ts b/packages/app/src/workspace/use-workspace-archive.ts index 7dc86a61ae..e037cef9e8 100644 --- a/packages/app/src/workspace/use-workspace-archive.ts +++ b/packages/app/src/workspace/use-workspace-archive.ts @@ -1,13 +1,13 @@ import { useCallback } from "react"; import { useTranslation } from "react-i18next"; -import { getHostRuntimeStore } from "@/runtime/host-runtime"; +import { getHostClient } from "@/runtime/host-runtime"; import { useToast } from "@/contexts/toast-context"; import { confirmRiskyWorktreeArchive, DEFAULT_WORKTREE_ARCHIVE_WARNING_LABELS, type WorktreeArchiveWarningLabels, } from "@/git/worktree-archive-warning"; -import type { WorkspaceDescriptor } from "@/stores/session-store"; +import type { WorkspaceDescriptor } from "@/stores/session-store-hooks"; import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model"; import { archiveWorkspaceOptimistically } from "@/workspace/workspace-archive"; @@ -53,7 +53,7 @@ export function useWorkspaceArchive(input: ArchiveWorkspaceInput): WorkspaceArch const toast = useToast(); const archiveWorkspaceRecord = useCallback(async () => { - const client = getHostRuntimeStore().getClient(serverId); + const client = getHostClient(serverId); if (!client) { toast.error(t("sidebar.workspace.toasts.hostDisconnected")); return; diff --git a/packages/app/src/workspace/workspace-archive.ts b/packages/app/src/workspace/workspace-archive.ts index 0bb4bcf5f9..ff13eb8580 100644 --- a/packages/app/src/workspace/workspace-archive.ts +++ b/packages/app/src/workspace/workspace-archive.ts @@ -2,7 +2,12 @@ import { clearWorkspaceArchivePending, markWorkspaceArchivePending, } from "@/contexts/session-workspace-upserts"; -import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store"; +import type { WorkspaceDescriptor } from "@/stores/session-store-hooks"; +import { + acceptWorkspaceSnapshots, + getWorkspaceDirectorySnapshot, + removeWorkspaceSnapshot, +} from "@/runtime/session-data"; import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity"; import { i18n } from "@/i18n/i18next"; @@ -40,7 +45,7 @@ function isWorkspaceArchiveFailure(error: unknown): error is WorkspaceArchiveFai function hideWorkspaceOptimistically( workspace: WorkspaceArchiveTarget, ): OptimisticWorkspaceArchiveSnapshot { - const workspaces = useSessionStore.getState().sessions[workspace.serverId]?.workspaces; + const workspaces = getWorkspaceDirectorySnapshot(workspace.serverId).workspaces; const workspaceKey = resolveWorkspaceMapKeyByIdentity({ workspaces, workspaceId: workspace.workspaceId, @@ -50,7 +55,7 @@ function hideWorkspaceOptimistically( serverId: workspace.serverId, workspaceId: workspace.workspaceId, }); - useSessionStore.getState().removeWorkspace(workspace.serverId, workspace.workspaceId); + removeWorkspaceSnapshot(workspace.serverId, workspace.workspaceId); return { workspace: snapshot }; } @@ -64,7 +69,7 @@ function restoreOptimisticallyHiddenWorkspace(input: { workspaceId: input.workspaceId, }); if (input.snapshot.workspace) { - useSessionStore.getState().mergeWorkspaces(input.serverId, [input.snapshot.workspace]); + acceptWorkspaceSnapshots(input.serverId, [input.snapshot.workspace]); } } diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index b3d79b023b..da173431fe 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -945,6 +945,87 @@ test("does not reconnect after close when ensureConnected is called", async () = expect(client.getConnectionState().status).toBe("disposed"); }); +test("ensureConnected reconnects immediately without leaving the scheduled retry armed", async () => { + useHeartbeatClock(); + try { + const first = createMockTransport(); + const second = createMockTransport(); + const third = createMockTransport(); + const transports = [first, second, third]; + let transportIndex = 0; + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_immediate_reconnect", + reconnect: { enabled: true, baseDelayMs: 1_500, maxDelayMs: 1_500 }, + transportFactory: () => { + const transport = transports[transportIndex]; + if (!transport) throw new Error("unexpected extra reconnect"); + transportIndex += 1; + return transport.transport; + }, + }); + clients.push(client); + + const initialConnect = client.connect(); + first.triggerOpen(); + await initialConnect; + first.triggerClose({ code: 1001, reason: "app resumed" }); + expect(client.getConnectionState().status).toBe("disconnected"); + + client.ensureConnected(); + expect(client.getConnectionState().status).toBe("connecting"); + expect(transportIndex).toBe(2); + + second.triggerOpen(); + expect(client.getConnectionState().status).toBe("connected"); + await vi.advanceTimersByTimeAsync(1_500); + + expect(client.getConnectionState().status).toBe("connected"); + expect(transportIndex).toBe(2); + } finally { + vi.useRealTimers(); + } +}); + +test("disabling reconnect cancels a pending retry until explicitly resumed", async () => { + useHeartbeatClock(); + try { + const first = createMockTransport(); + const second = createMockTransport(); + const transports = [first, second]; + let transportIndex = 0; + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_paused_reconnect", + reconnect: { enabled: true, baseDelayMs: 1_500, maxDelayMs: 1_500 }, + transportFactory: () => { + const transport = transports[transportIndex]; + if (!transport) throw new Error("unexpected extra reconnect"); + transportIndex += 1; + return transport.transport; + }, + }); + clients.push(client); + + const initialConnect = client.connect(); + first.triggerOpen(); + await initialConnect; + first.triggerClose({ code: 1001, reason: "app backgrounded" }); + + client.setReconnectEnabled(false); + await vi.advanceTimersByTimeAsync(1_500); + expect(client.getConnectionState().status).toBe("disconnected"); + expect(transportIndex).toBe(1); + + client.setReconnectEnabled(true); + client.ensureConnected(); + expect(client.getConnectionState().status).toBe("connecting"); + expect(transportIndex).toBe(2); + } finally { + vi.useRealTimers(); + } +}); + test("keeps the transport connected when a session RPC ping times out", async () => { const logger = createMockLogger(); const mock = createMockTransport(); diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 4b3e4d7e3c..8121b6e91c 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -1402,6 +1402,14 @@ export class DaemonClient { ) { return; } + if (this.reconnectTimeout) { + clearTimeout(this.reconnectTimeout); + this.reconnectTimeout = null; + } + if (this.connectPromise) { + this.attemptConnect(); + return; + } void this.connect(); } @@ -5962,6 +5970,10 @@ export class DaemonClient { setReconnectEnabled(enabled: boolean): void { this.config = { ...this.config, reconnect: { ...this.config.reconnect, enabled } }; + if (!enabled && this.reconnectTimeout) { + clearTimeout(this.reconnectTimeout); + this.reconnectTimeout = null; + } } private scheduleReconnect(input?: {