diff --git a/docs/architecture.md b/docs/architecture.md index 071cb94496..0ed8c46ade 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/client-replica` owns typed storage behind narrow host-lifecycle, directory, and timeline capabilities. Its cache internals never escape the module or observe `SessionStore`. +- `runtime/replica-cache` is typed storage behind the directory and timeline owners. It never observes or mutates `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/timeline-sync.md b/docs/timeline-sync.md index 2fbffdada3..5d3464a30b 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 session-data owner's automatic queue drain — goes through +send-now, and the host runtime'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. diff --git a/packages/app/e2e/browser/agent-message-submission.spec.ts b/packages/app/e2e/browser/agent-message-submission.spec.ts index 41397e9d5b..295e07ba88 100644 --- a/packages/app/e2e/browser/agent-message-submission.spec.ts +++ b/packages/app/e2e/browser/agent-message-submission.spec.ts @@ -551,8 +551,8 @@ async function expectProviderAcknowledgementBeforeRpcAcceptanceSettlesSubmission await gate.waitForAgentStreamItem("user_message"); gate.releaseHeldServerMessage(); await gate.drop(); + await expect(page.getByTestId("turn-working-indicator")).toHaveCount(0); await expect(userMessage).toHaveAttribute("aria-busy", "false"); - await expect(page.getByTestId("turn-working-indicator")).toBeVisible(); } finally { gate.restore(); await agent.cleanup(); 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 7589ae6b9b..c4b97ec229 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 { useHostRuntimeClient } from "@/runtime/host-runtime"; +import { useSessionStore } from "@/stores/session-store"; 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 = useHostRuntimeClient(serverId ?? ""); + const client = useSessionStore((state) => state.sessions[serverId ?? ""]?.client ?? null); 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 8737028a5b..fe5105b18c 100644 --- a/packages/app/src/agent-profiles/internal/use-agent-profiles.ts +++ b/packages/app/src/agent-profiles/internal/use-agent-profiles.ts @@ -1,7 +1,8 @@ import { useCallback } from "react"; import type { AgentProfile } from "@getpaseo/protocol/messages"; import { useDaemonConfig } from "@/hooks/use-daemon-config"; -import { useServerFeature } from "@/stores/session-store-hooks"; +import { useSessionStore } from "@/stores/session-store"; +import { supportsAgentProfiles } from "./capabilities"; export interface UseAgentProfilesResult { /** `null` until the daemon config has arrived. */ @@ -14,9 +15,9 @@ export interface UseAgentProfilesResult { export function useAgentProfiles(serverId: string | null): UseAgentProfilesResult { const { config, patchConfig } = useDaemonConfig(serverId); - const supportsProfiles = useServerFeature(serverId, "agentProfiles"); - const supportsApply = useServerFeature(serverId, "agentConfigApply"); - const isSupported = supportsProfiles && supportsApply; + const isSupported = useSessionStore((state) => { + return supportsAgentProfiles(state.sessions[serverId ?? ""]?.serverInfo?.features); + }); 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 deleted file mode 100644 index 48a2bf56d3..0000000000 --- a/packages/app/src/agent-skills/legacy-migration-service.ts +++ /dev/null @@ -1,12 +0,0 @@ -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 34ae719925..2a1ab9aa33 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 { useConnectedDesktopManagedHostIds } from "@/stores/session-store-hooks"; -import { importLegacyAgentSkillsSelection } from "./legacy-migration-service"; +import { getHostRuntimeStore } from "@/runtime/host-runtime"; +import { useSessionStore } from "@/stores/session-store"; interface LegacySelectionClient { importLegacyAgentSkillsSelection(selection: AgentSkillSelection): Promise; @@ -26,16 +26,21 @@ export async function migrateLegacyAgentSkillsSelection( // COMPAT(desktopSkillSelectionMigration): added in v0.4.0; remove after 2027-02-16. export function LegacyAgentSkillsMigration() { - const connectedManagedHosts = useConnectedDesktopManagedHostIds().join(","); + const connectedManagedHosts = useSessionStore((state) => + Object.entries(state.sessions) + .filter(([, session]) => session.serverInfo?.desktopManaged === true) + .map(([serverId]) => serverId) + .sort() + .join(","), + ); useEffect(() => { if (!shouldUseDesktopDaemon() || !connectedManagedHosts) return; async function migrate(): Promise { const status = await getDesktopDaemonStatus(); if (status.status !== "running" || !status.desktopManaged) return; - const selection = await readLegacySkillSelection(); - if (!selection) return; - const imported = await importLegacyAgentSkillsSelection(status.serverId, selection); - if (imported) await deleteLegacySkillSelection(); + const client = getHostRuntimeStore().getSnapshot(status.serverId)?.client; + if (!client) return; + await migrateLegacyAgentSkillsSelection(client); } 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.test.tsx b/packages/app/src/agent-stream/chat-outline/use-chat-outline.test.tsx index 71035fbce1..6200b8b0f9 100644 --- a/packages/app/src/agent-stream/chat-outline/use-chat-outline.test.tsx +++ b/packages/app/src/agent-stream/chat-outline/use-chat-outline.test.tsx @@ -24,8 +24,6 @@ const runtime = vi.hoisted(() => ({ vi.mock("@/constants/platform", () => ({ isWeb: true })); vi.mock("@/runtime/host-runtime", () => ({ - fetchAgentTimeline: runtime.fetchAgentTimeline, - getHostClient: () => runtime, getHostRuntimeStore: () => ({ getClient: () => runtime, fetchAgentTimeline: runtime.fetchAgentTimeline, 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 4a6337409d..b88876ef49 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,11 +2,7 @@ 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 { - listAgentTimelinePrompts, - loadAgentTimelineWindow, - subscribeToAgentTimelinePrompts, -} from "@/timeline/agent-timeline-service"; +import { getHostRuntimeStore } from "@/runtime/host-runtime"; import { planTimelinePromptJump } from "@/timeline/timeline-sync-plan"; import type { StreamItem } from "@/types/stream"; import type { StreamViewportHandle } from "../strategy"; @@ -75,13 +71,15 @@ export function useChatOutline({ return; } setIndex(null); + const client = getHostRuntimeStore().getClient(serverId); + if (!client) return; let active = true; const refresh = () => { const requestId = ++nextIndexRequestIdRef.current; - void listAgentTimelinePrompts(serverId, agentId) + void client + .listAgentTimelinePrompts(agentId) .then((payload) => { if ( - payload && active && requestId === nextIndexRequestIdRef.current && shouldAcceptPromptIndexEpoch(timelineEpoch, payload.epoch) @@ -93,7 +91,16 @@ export function useChatOutline({ .catch(() => undefined); }; refresh(); - const unsubscribe = subscribeToAgentTimelinePrompts(serverId, agentId, 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(); + } + }); return () => { active = false; unsubscribe(); @@ -165,11 +172,8 @@ export function useChatOutline({ if (!index) return; const requestId = nextJumpRequestIdRef.current; setPendingJump({ requestId, seq, fetchSettled: false, hasScrolled: false }); - void loadAgentTimelineWindow( - serverId, - agentId, - planTimelinePromptJump({ epoch: index.epoch, seq }), - ) + void getHostRuntimeStore() + .fetchAgentTimeline(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 c167dbba79..eb68675580 100644 --- a/packages/app/src/agent-stream/view.tsx +++ b/packages/app/src/agent-stream/view.tsx @@ -50,8 +50,7 @@ import type { AgentPermissionResponse, } from "@getpaseo/protocol/agent-types"; import type { AgentScreenAgent } from "@/hooks/use-agent-screen-state-machine"; -import { useHostRuntimeClient } from "@/runtime/host-runtime"; -import { useAgentStreamView, useServerFeature } from "@/stores/session-store-hooks"; +import { useSessionStore } from "@/stores/session-store"; import { useRevealedText } from "@/hooks/use-revealed-text"; import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions"; import { useLoadOlderAgentHistory } from "@/hooks/use-load-older-agent-history"; @@ -72,7 +71,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 { loadAgentTimelineWindow } from "@/timeline/agent-timeline-service"; +import { getHostRuntimeStore } from "@/runtime/host-runtime"; import { planTimelineTailFetch } from "@/timeline/timeline-sync-plan"; import { CompletedTurnFooterRow, @@ -379,18 +378,26 @@ const AgentStreamViewComponent = forwardRef state.sessions[resolvedServerId]?.client ?? null); + const sessionStreamHead = useSessionStore((state) => + state.sessions[resolvedServerId]?.agentStreamHead?.get(agentId), + ); const streamHead = providedStreamHead ?? sessionStreamHead; const forkAgent = useForkAgent({ serverId: resolvedServerId, toast, readOnly }); - const supportsAgentForkContextCursor = useServerFeature( - resolvedServerId, - "agentForkContextCursor", + 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 supportsChatOutline = useServerFeature(resolvedServerId, "agentTimelinePromptIndex"); - const timelineEpoch = sessionStream.timelineEpoch; - const isTimelineDetached = sessionStream.hasNewerTimeline; const workspaceRoot = context.cwd?.trim() || ""; const { requestDirectoryListing } = useFileExplorerActions({ @@ -651,7 +658,7 @@ const AgentStreamViewComponent = forwardRef - loadAgentTimelineWindow(resolvedServerId, agentId, { + getHostRuntimeStore().fetchAgentTimeline(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 cb0c725a5a..1446a32d28 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -68,7 +68,7 @@ import { resolveStartupBlocker, resolveStartupNavigationReady, shouldRunStartupGiveUpTimer, - startDefaultHostRuntimeBootstrap, + startHostRuntimeBootstrap, type StartupBlocker, } from "@/navigation/host-runtime-bootstrap"; import { registerWorkspaceRouteNavigationRef } from "@/navigation/workspace-route-navigation"; @@ -105,20 +105,19 @@ 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 { getManagedDaemonStartService } from "@/runtime/daemon-start-service"; +import { getDaemonStartService } from "@/runtime/daemon-start-service"; import { usePanelStore } from "@/stores/panel-store"; import { flushDraftPersistStorage } from "@/stores/draft-store"; import { getNextThemePreference } from "@/styles/theme"; -import { useConnection } from "@/stores/session-store-hooks"; +import { useSessionStore } from "@/stores/session-store"; import { installWebScrollbarStyles } from "@/styles/install-web-scrollbar-styles"; import type { HostProfile } from "@/types/host-connection"; import { @@ -290,7 +289,7 @@ function LegacyFavoriteProfileMigrationBootstrap({ serverId: string; client: NonNullable>; }) { - const serverInfo = useConnection(serverId, (connection) => connection.serverInfo); + const serverInfo = useSessionStore((state) => state.sessions[serverId]?.serverInfo ?? null); const isConnected = useHostRuntimeIsConnected(serverId); useEffect(() => { @@ -322,11 +321,27 @@ function HostSessionManager() { } export function useEarliestOnlineHostServerId(): string | null { - return useRuntimeEarliestOnlineHostServerId(); + 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(), + ); } function useDaemonStartLastError(): string | null { - const service = getManagedDaemonStartService(); + const service = getDaemonStartService({ store: getHostRuntimeStore() }); return useSyncExternalStore( (listener) => service.subscribe(listener), () => service.getLastError(), @@ -335,7 +350,7 @@ function useDaemonStartLastError(): string | null { } function useDaemonStartIsRunning(): boolean { - const service = getManagedDaemonStartService(); + const service = getDaemonStartService({ store: getHostRuntimeStore() }); return useSyncExternalStore( (listener) => service.subscribe(listener), () => service.isRunning(), @@ -357,19 +372,23 @@ async function shouldStartBuiltInDaemon(): Promise { } function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) { - const pathname = usePathname(); - const routeServerId = useMemo(() => parseServerIdFromPathname(pathname), [pathname]); - useEffect(() => { - startDefaultHostRuntimeBootstrap(shouldStartBuiltInDaemon); + const store = getHostRuntimeStore(); + const subscription = AppState.addEventListener("change", (nextState) => { + store.setAppVisible(nextState === "active"); + }); + return () => subscription.remove(); }, []); useEffect(() => { - const subscription = AppState.addEventListener("change", (nextState) => { - setAppVisible(nextState === "active", routeServerId); + const store = getHostRuntimeStore(); + const daemonStartService = getDaemonStartService({ store }); + startHostRuntimeBootstrap({ + store, + daemonStartService, + shouldStartDaemon: shouldStartBuiltInDaemon, }); - return () => subscription.remove(); - }, [routeServerId]); + }, []); const anyOnlineHostServerId = useEarliestOnlineHostServerId(); const daemonStartError = useDaemonStartLastError(); @@ -405,7 +424,7 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) { }, [shouldRunGiveUpTimer]); const retry = useCallback(() => { - const daemonStartService = getManagedDaemonStartService(); + const daemonStartService = getDaemonStartService({ store: getHostRuntimeStore() }); 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 bb449b1c1a..7f66a07b25 100644 --- a/packages/app/src/app/h/[serverId]/agent/[agentId].tsx +++ b/packages/app/src/app/h/[serverId]/agent/[agentId].tsx @@ -4,13 +4,8 @@ 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 { useAgentWorkspaceId } from "@/stores/session-store-hooks"; -import { - prepareAgentRoute, - probeHostConnections, - useHostRuntimeSnapshot, - useHosts, -} from "@/runtime/host-runtime"; +import { useSessionStore } from "@/stores/session-store"; +import { getHostRuntimeStore, 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"; @@ -37,10 +32,17 @@ function HostAgentReadyRouteContent() { const client = runtimeSnapshot?.client ?? null; const connectionStatus = runtimeSnapshot?.connectionStatus ?? "connecting"; const hostName = hosts.find((host) => host.serverId === serverId)?.label ?? serverId; - const agentWorkspaceId = useAgentWorkspaceId(serverId, agentId); + const agentWorkspaceId = useSessionStore((state) => { + if (!serverId || !agentId) { + return null; + } + return state.sessions[serverId]?.agents?.get(agentId)?.workspaceId ?? null; + }); useEffect(() => { if (!serverId || !agentId || agentWorkspaceId) return; - void prepareAgentRoute(serverId, agentId).catch(() => undefined); + void getHostRuntimeStore() + .prepareAgentRoute(serverId, agentId) + .catch(() => undefined); }, [agentId, agentWorkspaceId, serverId]); const shouldLookupAgent = Boolean( serverId && agentId && client && connectionStatus === "online" && !agentWorkspaceId, @@ -116,7 +118,7 @@ function HostAgentReadyRouteContent() { return; } if (serverId) { - void probeHostConnections(serverId); + void getHostRuntimeStore().runProbeCycleNow(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 deleted file mode 100644 index a96f9025c2..0000000000 --- a/packages/app/src/architecture-boundaries.test.ts +++ /dev/null @@ -1,137 +0,0 @@ -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/command-center/workspace-file-search.ts b/packages/app/src/command-center/workspace-file-search.ts index f27792df5e..1008604bec 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 { useHostRuntimeClient } from "@/runtime/host-runtime"; +import { useSessionStore } from "@/stores/session-store"; import { useWorkspaceDirectory } from "@/stores/session-store-hooks"; import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; import { clearCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore"; @@ -57,7 +57,9 @@ 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 = useHostRuntimeClient(serverId ?? ""); + const client = useSessionStore((state) => + serverId ? (state.sessions[serverId]?.client ?? null) : null, + ); 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 d04d428e2d..70789e5353 100644 --- a/packages/app/src/command-center/workspace-registration.tsx +++ b/packages/app/src/command-center/workspace-registration.tsx @@ -179,6 +179,10 @@ 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 219423d010..4dcd50b019 100644 --- a/packages/app/src/components/add-project-flow.tsx +++ b/packages/app/src/components/add-project-flow.tsx @@ -1,3 +1,4 @@ +import { getHostRuntimeStore } from "@/runtime/host-runtime"; import { router } from "expo-router"; import type { WorkspaceProjectDescriptorPayload } from "@getpaseo/protocol/messages"; import { @@ -89,11 +90,8 @@ 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"; @@ -375,6 +373,16 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) { const recommendedPaths = useRecommendedProjectPaths(hostId); const openProject = useOpenProject(hostId); const cloneGithubProject = useCloneGithubProject(hostId); + const upsertProject = useCallback( + ( + targetServerId: string, + project: Parameters["acceptProjectSnapshot"]>[1], + ) => { + getHostRuntimeStore().acceptProjectSnapshot(targetServerId, project); + }, + [], + ); + const setHasHydratedWorkspaces = useSessionStore((store) => store.setHasHydratedWorkspaces); const inputRef = useRef(null); const submissionInFlightRef = useRef(false); const browseInFlightRef = useRef(false); @@ -752,7 +760,7 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) { } finally { submissionInFlightRef.current = false; } - }, [client, openNewWorkspaceForProject, page]); + }, [client, openNewWorkspaceForProject, page, setHasHydratedWorkspaces, upsertProject]); 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 d03afef42f..58023da386 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 { getHostClient, useHostRuntimeClient } from "@/runtime/host-runtime"; +import { useSessionStore } from "@/stores/session-store"; import { Archive, ChevronRight } from "lucide-react-native"; import { getProviderIcon } from "@/components/provider-icons"; import { navigateToAgent } from "@/utils/navigate-to-agent"; @@ -390,7 +390,9 @@ export function AgentList({ const isMobile = useIsCompactFormFactor(); const { archiveAgent } = useArchiveAgent(); - const actionClient = useHostRuntimeClient(actionAgent?.serverId ?? ""); + const actionClient = useSessionStore((state) => + actionAgent?.serverId ? (state.sessions[actionAgent.serverId]?.client ?? null) : null, + ); const isActionSheetVisible = actionAgent !== null; const isActionDaemonUnavailable = Boolean(actionAgent?.serverId && !actionClient); @@ -417,13 +419,13 @@ export function AgentList({ const handleAgentLongPress = useCallback( (agent: AggregatedAgent) => { - const isRunning = agent.turn.phase === "open"; + const isRunning = agent.status === "running"; if (isRunning) { setActionAgent(agent); return; } - const client = getHostClient(agent.serverId); + const client = useSessionStore.getState().sessions[agent.serverId]?.client ?? null; 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 dcc08762d2..9cf74a9323 100644 --- a/packages/app/src/components/app-diagnostic-sheet.tsx +++ b/packages/app/src/components/app-diagnostic-sheet.tsx @@ -17,11 +17,7 @@ import { redactAppDiagnosticReport, } from "@/diagnostics/app-diagnostic-report"; import { collectDesktopDiagnosticSections } from "@/diagnostics/desktop-diagnostic-report"; -import { - readHostRuntimeSnapshot, - useHosts, - type HostRuntimeSnapshot, -} from "@/runtime/host-runtime"; +import { getHostRuntimeStore, useHosts, type HostRuntimeSnapshot } from "@/runtime/host-runtime"; import { ICON_SIZE, type Theme } from "@/styles/theme"; import type { HostProfile } from "@/types/host-connection"; @@ -112,10 +108,11 @@ 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 = readHostRuntimeSnapshot(host.serverId); + const snapshot = store.getSnapshot(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 9ca12b2d2f..8d44a9d767 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-hooks"; +import type { WorkspaceDescriptor } from "@/stores/session-store"; 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 cd6b279d69..e014928531 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-hooks"; -import { useFileExplorer, useServerFeature } from "@/stores/session-store-hooks"; +} from "@/stores/session-store"; +import { useSessionStore } from "@/stores/session-store"; import { FileActionsContextMenuContent } from "@/components/file-actions-menu"; import { ContextMenu, ContextMenuTrigger, useContextMenu } from "@/components/ui/context-menu"; import { useFileDownload } from "@/hooks/use-file-download"; @@ -426,7 +426,11 @@ export function FileExplorerPane({ [normalizedWorkspaceRoot, workspaceId], ); const hasWorkspaceScope = Boolean(workspaceStateKey && normalizedWorkspaceRoot); - const explorerState = useFileExplorer(serverId, workspaceStateKey); + const explorerState = useSessionStore((state) => + workspaceStateKey && state.sessions[serverId] + ? state.sessions[serverId]?.fileExplorer.get(workspaceStateKey) + : undefined, + ); const { requestDirectoryListing, @@ -451,9 +455,13 @@ export function FileExplorerPane({ workspaceDirectory: normalizedWorkspaceRoot, }); // COMPAT(fsEntryOps): added in v0.3.0, remove gate after 2027-02-08. - const fsEntryOpsEnabled = useServerFeature(serverId, "fsEntryOps"); + const fsEntryOpsEnabled = useSessionStore( + (state) => state.sessions[serverId]?.serverInfo?.features?.fsEntryOps === true, + ); // COMPAT(fsEntryDuplicate): added in v0.3.0, remove gate after 2027-02-09. - const fsEntryDuplicateEnabled = useServerFeature(serverId, "fsEntryDuplicate"); + const fsEntryDuplicateEnabled = useSessionStore( + (state) => state.sessions[serverId]?.serverInfo?.features?.fsEntryDuplicate === true, + ); 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 8ca21f8cfb..d482bb2d42 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 { getAgentTimelineLoadSnapshot } from "@/runtime/session-data"; +import { useSessionStore } from "@/stores/session-store"; import { shouldRestoreComposerForRewindMode } from "./rewind-mode"; -import { fetchAgentTimeline } from "@/runtime/host-runtime"; +import { getHostRuntimeStore } from "@/runtime/host-runtime"; interface UseRewindAgentMutationInput { serverId?: string; @@ -35,12 +35,13 @@ export function useRewindAgentMutation(input: UseRewindAgentMutationInput): { } await input.client.rewindAgent(input.agentId, input.messageId, mode); if (mode !== "files") { - const timeline = input.serverId - ? getAgentTimelineLoadSnapshot(input.serverId, input.agentId).timeline - : null; - const cursor = timeline?.status === "synced" ? timeline.range : undefined; + const cursor = input.serverId + ? useSessionStore + .getState() + .sessions[input.serverId]?.agentTimelineCursor.get(input.agentId) + : undefined; if (!input.serverId) throw new Error(t("common.errors.daemonClientUnavailable")); - await fetchAgentTimeline(input.serverId, input.agentId, { + await getHostRuntimeStore().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 9b2748b987..2a3cd97c10 100644 --- a/packages/app/src/components/schedules/schedule-form-sheet.tsx +++ b/packages/app/src/components/schedules/schedule-form-sheet.tsx @@ -14,6 +14,7 @@ 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"; @@ -42,7 +43,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 { useConnections, type ConnectionView } from "@/stores/session-store-hooks"; +import { useSessionStore } from "@/stores/session-store"; import { buildScheduleProjectTargets } from "@/schedules/schedule-project-targets"; import { useScheduleFormModel } from "@/schedules/use-schedule-form-model"; import { useScheduleFormProviderSnapshot } from "@/schedules/use-schedule-form-provider-snapshot"; @@ -113,14 +114,14 @@ function openKey(props: ScheduleFormSheetProps): string { function selectScheduleHosts( hosts: readonly { serverId: string; label: string }[], - connections: ReadonlyMap, -): ScheduleFormHost[] { - return hosts.map((host) => ({ - serverId: host.serverId, - label: host.label, - supportsWorkspaceMultiplicity: - connections.get(host.serverId)?.serverInfo?.features?.workspaceMultiplicity === true, - })); +): (state: ReturnType) => ScheduleFormHost[] { + return (state) => + hosts.map((host) => ({ + serverId: host.serverId, + label: host.label, + supportsWorkspaceMultiplicity: + state.sessions[host.serverId]?.serverInfo?.features?.workspaceMultiplicity === true, + })); } function buildSnapshot(input: { @@ -241,10 +242,9 @@ function OpenScheduleFormSheet({ const controlSize: FieldControlSize = useIsCompactFormFactor() ? "md" : "sm"; const { projects } = useProjects(); const hostProfiles = useHosts(); - const hostIds = useMemo(() => hostProfiles.map((host) => host.serverId), [hostProfiles]); - const hosts = useConnections( - hostIds, - (connections) => selectScheduleHosts(hostProfiles, connections), + const hosts = useStoreWithEqualityFn( + useSessionStore, + useMemo(() => selectScheduleHosts(hostProfiles), [hostProfiles]), equal, ); const { preferences, updatePreferences } = useFormPreferences(); diff --git a/packages/app/src/components/sidebar-workspace-list.test.tsx b/packages/app/src/components/sidebar-workspace-list.test.tsx index df83474db5..94e6f36466 100644 --- a/packages/app/src/components/sidebar-workspace-list.test.tsx +++ b/packages/app/src/components/sidebar-workspace-list.test.tsx @@ -38,7 +38,7 @@ import { } from "@/runtime/host-runtime"; import type { HostProfile } from "@/types/host-connection"; import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store"; -import { publishSessionWorkspaces } from "@/test/seed-session"; +import { seedSessionWorkspaces } from "@/test/seed-session"; import { useSidebarOrderStore } from "@/stores/sidebar-order-store"; import { useWorkspaceFields } from "@/stores/session-store-hooks"; import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; @@ -167,7 +167,7 @@ function initializeSidebarState(workspaces: WorkspaceDescriptor[]): void { act(() => { setHostProfiles([makeHost()]); useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient); - publishSessionWorkspaces(SERVER_ID, new Map(workspaces.map((entry) => [entry.id, entry]))); + seedSessionWorkspaces(SERVER_ID, new Map(workspaces.map((entry) => [entry.id, entry]))); useSessionStore.getState().setHasHydratedWorkspaces(SERVER_ID, true); useSidebarOrderStore.setState({ projectOrder: ["project-a", "project-b"], diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index 9f768c4b8f..347315bd19 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 { getHostClient, useHosts } from "@/runtime/host-runtime"; +import { getHostRuntimeStore, 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) => getHostClient(serverId), + getClient: (serverId) => getHostRuntimeStore().getClient(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 7c5f707af3..eb00df6e12 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 { useConnection } from "@/stores/session-store-hooks"; +import { useSessionStore } from "@/stores/session-store"; import { ICON_SIZE, type Theme } from "@/styles/theme"; import type { HostProfile } from "@/types/host-connection"; import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates"; @@ -58,9 +58,8 @@ const changelogLeadingIcon = ( function HostVersionHint({ host }: { host: HostProfile }) { const { t } = useTranslation(); const isConnected = useHostRuntimeIsConnected(host.serverId); - const daemonVersion = useConnection( - host.serverId, - ({ serverInfo }) => serverInfo?.version ?? null, + const daemonVersion = useSessionStore( + (state) => state.sessions[host.serverId]?.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 7b39a7d179..3e98799035 100644 --- a/packages/app/src/components/terminal-pane.tsx +++ b/packages/app/src/components/terminal-pane.tsx @@ -50,8 +50,7 @@ import { import { resolveTerminalRestoreOptions } from "@/terminal/runtime/terminal-restore-options"; import { usePanelStore } from "@/stores/panel-store"; import { useBlockMobilePanelOpenGestures } from "@/mobile-panels/provider"; -import { useServerFeature } from "@/stores/session-store-hooks"; -import { publishFocusedTerminal as setFocusedTerminalId } from "@/runtime/session-data"; +import { useSessionStore } from "@/stores/session-store"; import { toXtermTheme } from "@/utils/to-xterm-theme"; import TerminalEmulator, { type TerminalEmulatorHandle } from "./terminal-emulator"; import { TerminalFloatingCopyAction, TerminalPasteAction } from "./terminal-copy-paste-actions"; @@ -234,9 +233,17 @@ export function TerminalPane({ const client = useHostRuntimeClient(serverId); const isConnected = useHostRuntimeIsConnected(serverId); const isTerminalPresented = retainedPanelActive && isWorkspaceFocused; - const supportsTerminalRestoreModes = useServerFeature(serverId, "terminal-restore-modes"); - const supportsTerminalInputModeReplay = useServerFeature(serverId, "terminal-input-mode-replay"); - const supportsTerminalSizeOwnership = useServerFeature(serverId, "terminal-size-ownership"); + 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 scopeKey = useMemo(() => terminalScopeKey({ serverId, cwd }), [serverId, cwd]); const terminalStreamKey = useMemo(() => `${scopeKey}:${terminalId}`, [scopeKey, terminalId]); @@ -386,7 +393,14 @@ export function TerminalPane({ lastSentTerminalSizeRef.current = null; requestTerminalReflow(); emulatorRef.current?.claimSize(); - }, [isPaneFocused, isWorkspaceFocused, requestTerminalReflow, serverId, terminalId]); + }, [ + isPaneFocused, + isWorkspaceFocused, + requestTerminalReflow, + serverId, + setFocusedTerminalId, + 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 c1d85a1cd1..1b3cdbe237 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 } from "react"; +import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react"; import { useTranslation } from "react-i18next"; import { Pressable, Text, View, ScrollView } from "react-native"; import { useRouter } from "expo-router"; @@ -13,12 +13,7 @@ import { } from "lucide-react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import type { HostProfile } from "@/types/host-connection"; -import { - isHostRuntimeConnected, - readHostRuntimeSnapshot, - useHostRuntimeVersion, - useHosts, -} from "@/runtime/host-runtime"; +import { getHostRuntimeStore, isHostRuntimeConnected, 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"; @@ -129,24 +124,42 @@ const styles = StyleSheet.create((theme) => ({ })); function useAnyHostOnline(serverIds: string[]): string | null { - 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; + 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; + } } - if (!firstOnlineAt || lastOnlineAt < firstOnlineAt) { - firstOnlineAt = lastOnlineAt; - firstOnlineServerId = serverId; + 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; + } } - } - return firstOnlineServerId; - }, [runtimeVersion, serverIds]); + return firstOnlineServerId; + }, + ); } export interface WelcomeScreenProps { diff --git a/packages/app/src/components/workspace-rename-modal.tsx b/packages/app/src/components/workspace-rename-modal.tsx index 2402c67c01..2f96534ea0 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 { getHostClient } from "@/runtime/host-runtime"; +import { getHostRuntimeStore } 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 = getHostClient(workspace.serverId); + const client = getHostRuntimeStore().getClient(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 39d602c5c6..29cd7ea0b7 100644 --- a/packages/app/src/components/workspace-setup-dialog.tsx +++ b/packages/app/src/components/workspace-setup-dialog.tsx @@ -1,3 +1,4 @@ +import { getHostRuntimeStore } from "@/runtime/host-runtime"; import { useCallback, useEffect, useMemo, useState } from "react"; import { Text, View } from "react-native"; import { StyleSheet } from "react-native-unistyles"; @@ -13,11 +14,10 @@ import { useAgentInputDraft } from "@/composer/draft/input-draft"; import { useProjectIcon } from "@/projects/icons"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { - acceptAgentSnapshot, - acceptWorkspaceSnapshots as mergeWorkspaces, - publishWorkspaceHydration as setHasHydratedWorkspaces, -} from "@/runtime/session-data"; -import { normalizeWorkspaceDescriptor, useServerFeature } from "@/stores/session-store-hooks"; + normalizeWorkspaceDescriptor, + useSessionStore, + type WorkspaceDescriptor, +} from "@/stores/session-store"; import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store"; import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; import { applyLegacyDaemonWorkspaceOwnership } from "@/workspace/legacy-daemon-workspaces"; @@ -169,6 +169,13 @@ export function WorkspaceSetupDialog() { const toast = useToast(); const pendingWorkspaceSetup = useWorkspaceSetupStore((state) => state.pendingWorkspaceSetup); const clearWorkspaceSetup = useWorkspaceSetupStore((state) => state.clearWorkspaceSetup); + const mergeWorkspaces = useCallback( + (targetServerId: string, workspaces: Iterable) => { + getHostRuntimeStore().acceptWorkspaceSnapshots(targetServerId, Array.from(workspaces)); + }, + [], + ); + const setHasHydratedWorkspaces = useSessionStore((state) => state.setHasHydratedWorkspaces); const [errorMessage, setErrorMessage] = useState(null); const [createdWorkspace, setCreatedWorkspace] = useState(null); const serverId = pendingWorkspaceSetup?.serverId ?? ""; - const supportsForgeSearch = useServerFeature(serverId, "forgeSearch"); + const supportsForgeSearch = useSessionStore( + (state) => state.sessions[serverId]?.serverInfo?.features?.forgeSearch === true, + ); const sourceDirectory = pendingWorkspaceSetup?.sourceDirectory ?? ""; const displayName = pendingWorkspaceSetup?.displayName?.trim() ?? ""; const workspace = createdWorkspace; @@ -278,7 +287,14 @@ export function WorkspaceSetupDialog() { setCreatedWorkspace(normalizedWorkspace); return normalizedWorkspace; }, - [createdWorkspace, pendingWorkspaceSetup, t, withConnectedClient], + [ + createdWorkspace, + mergeWorkspaces, + pendingWorkspaceSetup, + setHasHydratedWorkspaces, + t, + withConnectedClient, + ], ); const getIsStillActive = useCallback(() => { @@ -334,7 +350,7 @@ export function WorkspaceSetupDialog() { return; } - acceptAgentSnapshot( + getHostRuntimeStore().acceptAgentSnapshot( serverId, applyLegacyDaemonWorkspaceOwnership({ serverId, diff --git a/packages/app/src/components/workspace-shortcut-targets-subscriber.test.tsx b/packages/app/src/components/workspace-shortcut-targets-subscriber.test.tsx index ba428e94ce..2cceeae961 100644 --- a/packages/app/src/components/workspace-shortcut-targets-subscriber.test.tsx +++ b/packages/app/src/components/workspace-shortcut-targets-subscriber.test.tsx @@ -9,7 +9,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { getHostRuntimeStore } from "@/runtime/host-runtime"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store"; -import { publishSessionWorkspaces } from "@/test/seed-session"; +import { seedSessionWorkspaces } from "@/test/seed-session"; import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store"; import { useSidebarOrderStore } from "@/stores/sidebar-order-store"; import { useSidebarViewStore } from "@/stores/sidebar-view-store"; @@ -96,7 +96,7 @@ describe("WorkspaceShortcutTargetsSubscriber", () => { act(() => { setHostProfiles([hostProfile()]); useSessionStore.getState().initializeSession("srv", null as unknown as DaemonClient); - publishSessionWorkspaces( + seedSessionWorkspaces( "srv", new Map([ ["ws-1", workspaceDescriptor({ id: "ws-1", name: "Workspace 1" })], @@ -142,7 +142,7 @@ describe("WorkspaceShortcutTargetsSubscriber", () => { it("publishes status-mode shortcut targets in visual status order", async () => { act(() => { useSidebarViewStore.getState().setGroupMode("status"); - publishSessionWorkspaces( + seedSessionWorkspaces( "srv", new Map([ [ @@ -214,11 +214,11 @@ describe("WorkspaceShortcutTargetsSubscriber", () => { setHostProfiles([hostProfile("host-a"), hostProfile("host-b")]); useSessionStore.getState().initializeSession("host-a", null as unknown as DaemonClient); useSessionStore.getState().initializeSession("host-b", null as unknown as DaemonClient); - publishSessionWorkspaces( + seedSessionWorkspaces( "host-a", new Map([["a-1", workspaceDescriptor({ id: "a-1", name: "Host A" })]]), ); - publishSessionWorkspaces( + seedSessionWorkspaces( "host-b", new Map([["b-1", workspaceDescriptor({ id: "b-1", name: "Host B" })]]), ); diff --git a/packages/app/src/composer/agent-controls/index.tsx b/packages/app/src/composer/agent-controls/index.tsx index 7c006238d8..32941e46e7 100644 --- a/packages/app/src/composer/agent-controls/index.tsx +++ b/packages/app/src/composer/agent-controls/index.tsx @@ -22,6 +22,7 @@ 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"; @@ -33,8 +34,7 @@ import { type ProviderSelectorProvider, } from "@/provider-selection/provider-selection"; import { filterSelectableModels } from "@/provider-selection/model-catalog"; -import { useActiveAgentFields, type Agent } from "@/stores/session-store-hooks"; -import { useHostRuntimeClient } from "@/runtime/host-runtime"; +import { useSessionStore } from "@/stores/session-store"; import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot"; import { resolveProviderDefinition } from "@/utils/provider-definitions"; import { mergeProviderPreferences, useFormPreferences } from "@/hooks/use-form-preferences"; @@ -398,7 +398,15 @@ type AgentControlsSlice = { lastUsage: unknown; } | null; -function selectAgentControlsSlice(currentAgent: Agent): AgentControlsSlice { +function selectAgentControlsSlice( + state: ReturnType, + serverId: string, + agentId: string, +): AgentControlsSlice { + const currentAgent = state.sessions[serverId]?.agents?.get(agentId) ?? null; + if (!currentAgent) { + return null; + } return { provider: currentAgent.provider, cwd: currentAgent.cwd, @@ -1533,8 +1541,10 @@ export const AgentControls = memo(function AgentControls({ isCompactLayout, }: AgentControlsProps) { const { updatePreferences } = useFormPreferences(); - const agent = useActiveAgentFields(serverId, agentId, selectAgentControlsSlice); - const client = useHostRuntimeClient(serverId); + const agent = useSessionStore( + useShallow((state) => selectAgentControlsSlice(state, serverId, agentId)), + ); + const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null); 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 f13e882209..59fb515ce5 100644 --- a/packages/app/src/composer/agent-controls/mode-control.tsx +++ b/packages/app/src/composer/agent-controls/mode-control.tsx @@ -2,6 +2,8 @@ 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"; @@ -15,8 +17,7 @@ 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 { useActiveAgentFields } from "@/stores/session-store-hooks"; -import { useHostRuntimeClient } from "@/runtime/host-runtime"; +import { useSessionStore } from "@/stores/session-store"; import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot"; import { mergeProviderPreferences, useFormPreferences } from "@/hooks/use-form-preferences"; import { resolveProviderDefinition } from "@/utils/provider-definitions"; @@ -237,18 +238,31 @@ 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 = 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 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 { 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 5157c067f2..d1cfce1161 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 { hostSupports } from "@/runtime/session-data"; +import { useSessionStore } from "@/stores/session-store"; import { createUserMessage, generateMessageId, @@ -267,7 +267,9 @@ export function useDraftAgentCreateFlow({ dispatch({ type: "DRAFT_SET_ERROR", message: error.message }); throw error; } - const supportsForgeSearch = hostSupports(pendingServerId, "forgeSearch"); + const supportsForgeSearch = + useSessionStore.getState().sessions[pendingServerId]?.serverInfo?.features?.forgeSearch === + true; 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 7c133b2e2e..9f1c2b172c 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-hooks"; +import type { Agent } from "@/stores/session-store"; import { useWorkspaceFields } from "@/stores/session-store-hooks"; import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store"; import { useAgentControlCommandCenterActions } from "@/command-center/agent-control-registration"; @@ -248,12 +248,7 @@ function buildDraftAgentSnapshot(input: { id: tabId, provider, status: "running", - turn: { - phase: "open", - turnId: null, - startedAt: now, - cancellationRequestId: null, - }, + turn: { phase: "idle", cancellationRequestId: null }, createdAt: now, updatedAt: now, lastUserMessageAt: now, @@ -347,9 +342,9 @@ export function WorkspaceDraftAgentTab({ const insets = useSafeAreaInsets(); const client = useHostRuntimeClient(serverId); const isConnected = useHostRuntimeIsConnected(serverId); - const workspaceFields = useWorkspaceFields(serverId, workspaceId, (workspace) => ({ - id: workspace.id, - workspaceDirectory: workspace.workspaceDirectory, + const workspaceFields = useWorkspaceFields(serverId, workspaceId, (w) => ({ + workspaceDirectory: w.workspaceDirectory, + id: w.id, })); 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 450ab70a38..84de49a3b9 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -1,3 +1,4 @@ +import { getHostRuntimeStore } from "@/runtime/host-runtime"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { View, @@ -20,6 +21,7 @@ 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, @@ -42,18 +44,7 @@ import { import { ContextWindowMeter } from "@/components/context-window-meter"; import { KeyboardTranslateView } from "@/components/keyboard-translate-view"; import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker"; -import { - useActiveAgentFields, - type Agent, - useAgentTurn, - useComposerSession, - useServerFeature, -} from "@/stores/session-store-hooks"; -import { - getAgentTurnSnapshot, - getQueuedMessagesSnapshot, - publishQueuedMessages as setQueuedMessages, -} from "@/runtime/session-data"; +import { selectAgentTurnPresentation, useSessionStore } from "@/stores/session-store"; import { useFilePicker } from "@/hooks/use-file-picker"; import { useFileDrop } from "@/components/file-drop/use-file-drop"; import type { DroppedItem } from "@/components/file-drop/types"; @@ -103,7 +94,6 @@ import { useHostRuntimeClient, useHostRuntimeIsConnected, } from "@/runtime/host-runtime"; -import { beginAgentCancellation, settleAgentCancellation } from "@/runtime/session-data"; import { deleteAttachments, persistAttachmentFromBlob, @@ -265,14 +255,17 @@ function buildRealtimeVoiceButtonStyle( ); } -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, +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, + }; }; } @@ -989,6 +982,7 @@ interface ComposerProps { const MAX_FILE_SIZE_BYTES = 50 * 1024 * 1024; +const EMPTY_ARRAY: readonly QueuedMessage[] = []; const StableMessageInput = memo(MessageInput); function resolveContextWindowValues( @@ -1210,15 +1204,14 @@ function ComposerContentImpl({ const { settings: appSettings } = useAppSettings(); - 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 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 isCompactFormFactor = useIsCompactFormFactor(); const isCompactLayout = resolveCompactLayout(isCompactLayoutOverride, isCompactFormFactor); @@ -1244,7 +1237,9 @@ function ComposerContentImpl({ }); const setSelectedAttachments = onChangeAttachments; const checkoutStatusQuery = useCheckoutStatusQuery({ serverId, cwd }); - const supportsForgeSearch = useServerFeature(serverId, "forgeSearch"); + const supportsForgeSearch = useSessionStore( + (state) => state.sessions[serverId]?.serverInfo?.features?.forgeSearch === true, + ); const forgeAutoAttach = useComposerForgeAutoAttach({ text: userInput, remoteUrl: resolveCheckoutRemoteUrl(checkoutStatusQuery.status), @@ -1493,7 +1488,10 @@ function ComposerContentImpl({ activeTurnBehavior, activeTurnId: activeTurnBehavior === "steer" - ? (getAgentTurnSnapshot(serverId, targetAgentId).turnId ?? undefined) + ? (selectAgentTurnPresentation( + useSessionStore.getState().sessions[serverId], + targetAgentId, + ).turnId ?? undefined) : undefined, }); onAttentionPromptSend?.(); @@ -1504,11 +1502,23 @@ function ComposerContentImpl({ onSubmitMessageRef.current = onSubmitMessage; }, [onSubmitMessage]); - const hasActiveTurn = useAgentTurn(serverId, agentId, (turn) => turn.isActive); - const isCancellingAgent = useAgentTurn(serverId, agentId, (turn) => turn.isCancelling); + const hasActiveTurn = useSessionStore( + (state) => selectAgentTurnPresentation(state.sessions[serverId], agentId).isActive, + ); + const isCancellingAgent = useSessionStore( + (state) => selectAgentTurnPresentation(state.sessions[serverId], agentId).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, @@ -1517,10 +1527,10 @@ function ComposerContentImpl({ const queueWriter = useMemo( () => ({ - read: (id) => getQueuedMessagesSnapshot(serverId, id), + read: (id) => useSessionStore.getState().sessions[serverId]?.queuedMessages?.get(id) ?? [], write: (updater) => setQueuedMessages(serverId, updater), }), - [serverId], + [serverId, setQueuedMessages], ); const queueMessage = useCallback( @@ -1803,7 +1813,7 @@ function ComposerContentImpl({ isConnected, }); if (!cancellation) return; - const requestId = beginAgentCancellation(serverId, targetAgentId); + const requestId = getHostRuntimeStore().beginAgentCancellation(serverId, targetAgentId); void cancellation .catch((error) => { const message = resolveErrorMessage(error); @@ -1812,7 +1822,7 @@ function ComposerContentImpl({ } }) .finally(() => { - settleAgentCancellation(serverId, targetAgentId, requestId); + getHostRuntimeStore().settleAgentCancellation(serverId, targetAgentId, requestId); }); messageInputRef.current?.focus(); }, [client, isAgentRunning, isCancellingAgent, isConnected, serverId]); diff --git a/packages/app/src/composer/input/input.tsx b/packages/app/src/composer/input/input.tsx index 84b3988d33..fc8a260cfb 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 { useConnection } from "@/stores/session-store-hooks"; +import { useSessionStore } from "@/stores/session-store"; import { useVoiceOptional } from "@/contexts/voice-context"; import { useToast } from "@/contexts/toast-context"; import { resolveVoiceUnavailableMessage } from "@/utils/server-info-capabilities"; @@ -1274,11 +1274,16 @@ export const MessageInput = forwardRef( getNativeElement: () => (isWeb ? getTextInputNativeElement(textInputRef.current) : null), })); const sendAfterTranscriptRef = useRef(false); - const serverInfo = useConnection( - voiceServerId, - useCallback((connection) => { - return connection.serverInfo; - }, []), + const serverInfo = useSessionStore( + useCallback( + (state) => { + if (!voiceServerId) { + return null; + } + return state.sessions[voiceServerId]?.serverInfo ?? null; + }, + [voiceServerId], + ), ); useEffect(() => { diff --git a/packages/app/src/composer/submission/writer.ts b/packages/app/src/composer/submission/writer.ts index 6a6758359e..f8b40034b6 100644 --- a/packages/app/src/composer/submission/writer.ts +++ b/packages/app/src/composer/submission/writer.ts @@ -1,13 +1,5 @@ import type { MessageSubmissionWriter } from "@/composer/actions"; -import { - acceptMessageSubmission, - beginMessageSubmission, - handoffCreatedAgentSubmission, - getAgentStreamSnapshot, - hostSupports, - publishAgentStreamState, - rejectMessageSubmission, -} from "@/runtime/session-data"; +import { useSessionStore } from "@/stores/session-store"; import { appendSubmittedUserMessage, removeSubmittedUserMessage, @@ -15,8 +7,13 @@ import { } from "@/types/stream"; function appendUntrackedSubmission(serverId: string, agentId: string, message: UserMessageItem) { - const { tail, head } = getAgentStreamSnapshot(serverId, agentId); - publishAgentStreamState(serverId, agentId, appendSubmittedUserMessage({ tail, head, message })); + 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 })); } function removeUntrackedSubmission( @@ -24,12 +21,17 @@ function removeUntrackedSubmission( agentId: string, clientMessageId: string, ): void { - const { tail, head } = getAgentStreamSnapshot(serverId, agentId); - publishAgentStreamState( - serverId, - agentId, - removeSubmittedUserMessage({ tail, head, clientMessageId }), - ); + 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 }), + ); } function createUntrackedMessageSubmissionWriter(serverId: string): MessageSubmissionWriter { @@ -44,17 +46,20 @@ function createUntrackedMessageSubmissionWriter(serverId: string): MessageSubmis } export function createMessageSubmissionWriter(serverId: string): MessageSubmissionWriter { - const supportsTrackedMessageSubmissions = hostSupports(serverId, "canonicalSubmittedPrompts"); + const supportsTrackedMessageSubmissions = + useSessionStore.getState().sessions[serverId]?.serverInfo?.features + ?.canonicalSubmittedPrompts === true; 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) => beginMessageSubmission(serverId, agentId, message), + begin: (agentId, message) => + useSessionStore.getState().beginAgentMessageSubmission(serverId, agentId, message), accept: (agentId, clientMessageId) => - acceptMessageSubmission(serverId, agentId, clientMessageId), + useSessionStore.getState().acceptAgentMessageSubmission(serverId, agentId, clientMessageId), reject: (agentId, clientMessageId) => - rejectMessageSubmission(serverId, agentId, clientMessageId), + useSessionStore.getState().rejectAgentMessageSubmission(serverId, agentId, clientMessageId), }; } @@ -63,5 +68,5 @@ export function handoffCreatedAgentMessageSubmission( agentId: string, message: UserMessageItem, ): boolean { - return handoffCreatedAgentSubmission(serverId, agentId, message); + return useSessionStore.getState().handoffCreatedAgentUserMessage(serverId, agentId, message); } diff --git a/packages/app/src/composer/workspace-diff-stat.ts b/packages/app/src/composer/workspace-diff-stat.ts index 19b2d0ed2d..1cef419e6b 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-hooks"; +import type { WorkspaceDescriptor } from "@/stores/session-store"; 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 f5401a36bf..fff3a0f496 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -33,29 +33,14 @@ 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 { - 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 { getHostRuntimeStore, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useVoiceAudioEngineOptional, useVoiceRuntimeOptional } from "@/contexts/voice-context"; import type { AudioPlaybackSource } from "@/voice/audio-engine-types"; -import { useSessionFocus } from "@/stores/session-store-hooks"; +import { + selectAgentTimelineState, + useSessionStore, + type SessionState, +} from "@/stores/session-store"; import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store"; import { sendOsNotification } from "@/utils/os-notifications"; import { getIsAppActivelyVisible, getIsAppVisible } from "@/utils/app-visibility"; @@ -85,7 +70,7 @@ export type { ExplorerFileKind, ExplorerEncoding, AgentFileExplorerState, -} from "@/stores/session-store-hooks"; +} from "@/stores/session-store"; type AudioOutputPayload = Extract["payload"]; @@ -141,6 +126,32 @@ 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; @@ -216,10 +227,27 @@ 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 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, focusedTerminalId } = useSessionFocus(serverId); + const focusedAgentId = useSessionStore( + (state) => state.sessions[serverId]?.focusedAgentId ?? null, + ); + const focusedTerminalId = useSessionStore( + (state) => state.sessions[serverId]?.focusedTerminalId ?? null, + ); const _sessionStateTimeoutRef = useRef | null>(null); const attentionNotifiedRef = useRef>(new Map()); const appStateRef = useRef(AppState.currentState); @@ -251,7 +279,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider bumpHistorySyncGeneration, }); }, - [serverId], + [bumpHistorySyncGeneration, serverId], ); // Client activity tracking (heartbeat, push token registration) @@ -271,7 +299,8 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider notification?: AgentAttentionNotificationPayload; }) => { const appState = appStateRef.current; - const attentionFocusedAgentId = getSessionFocusSnapshot(serverId).focusedAgentId; + const session = useSessionStore.getState().sessions[serverId]; + const attentionFocusedAgentId = session?.focusedAgentId ?? null; if (params.reason === "error") { return; } @@ -288,16 +317,12 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider } attentionNotifiedRef.current.set(params.agentId, timestampMs); - const { head, tail } = getAgentStreamSnapshot(serverId, params.agentId); - const conversation = getAgentConversationSnapshot(serverId, params.agentId); - const agent = getAgentSnapshot(serverId, params.agentId); + const head = session?.agentStreamHead.get(params.agentId) ?? []; + const tail = session?.agentStreamTail.get(params.agentId) ?? []; const assistantMessage = findLatestAssistantMessageText(head) ?? findLatestAssistantMessageText(tail); - const permissionRequest = - conversation.pendingPermissions.at(-1)?.request ?? - (agent?.pendingPermissions.at(-1) as NotificationPermissionRequest | undefined) ?? - null; - const workspaceId = agent?.workspaceId; + const permissionRequest = getLatestPermissionRequest(session, params.agentId); + const workspaceId = session?.agents?.get(params.agentId)?.workspaceId; const notification = resolveAgentAttentionNotification({ notification: params.notification, @@ -337,7 +362,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider ...(serverInfo.capabilities ? { capabilities: serverInfo.capabilities } : {}), ...(serverInfo.features ? { features: serverInfo.features } : {}), }); - }, [client, serverId]); + }, [client, serverId, updateSessionServerInfo]); useEffect(() => { const unregister = voiceRuntime?.registerSession({ @@ -371,7 +396,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider }, }); return () => unregister?.(); - }, [client, serverId, t, voiceRuntime]); + }, [client, serverId, setIsPlayingAudio, t, voiceRuntime]); useEffect(() => { voiceRuntime?.updateSessionConnection(serverId, isConnected); @@ -383,7 +408,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider flushAgentLastActivity(); setInitializingAgents(serverId, new Map()); } - }, [serverId, isConnected]); + }, [flushAgentLastActivity, serverId, isConnected, setInitializingAgents]); const applyWorkspaceSetupProgress = useCallback( (payload: WorkspaceSetupProgressPayload) => { @@ -407,18 +432,22 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider const initialDeliveryMode = getTimelineDeliveryMode( client.getLastServerInfoMessage()?.features?.selectiveAgentTimeline, ); - const sync = createViewedTimelineOwnerForHost(serverId, { + const sync = getHostRuntimeStore().createViewedTimelineOwner(serverId, { initialDeliveryMode, setSubscription: (agentIds) => client.setAgentTimelineSubscription(agentIds), readCursor: (agentId) => { - const timeline = getAgentTimelineSnapshot(serverId, agentId); + const timeline = selectAgentTimelineState( + useSessionStore.getState().sessions[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 = getAgentTimelineSnapshot(serverId, agentId).status !== "synced"; + const shouldInitialize = selectAgentTimelineState(session, agentId).status !== "synced"; if (shouldInitialize) { if (!getInitDeferred(initKey)) { const deferred = createInitDeferred(initKey, request.direction ?? "tail"); @@ -432,7 +461,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider setAgentInitializing(agentId, true); } try { - const page = await fetchAgentTimeline(serverId, agentId, request); + const page = await getHostRuntimeStore().fetchAgentTimeline(serverId, agentId, request); if (shouldInitialize && getInitDeferred(initKey)) { refreshAgentInitializationTimeout({ key: initKey, agentId, setAgentInitializing }); } @@ -448,7 +477,11 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider fetchLatestTail: async (agentId) => { forcedTimelineTailReplacements.current.add(agentId); try { - return await fetchAgentTimeline(serverId, agentId, planTimelineTailFetch()); + return await getHostRuntimeStore().fetchAgentTimeline( + serverId, + agentId, + planTimelineTailFetch(), + ); } finally { forcedTimelineTailReplacements.current.delete(agentId); } @@ -472,7 +505,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider setViewedTimelineSync(serverId, null); sync.dispose(); }; - }, [client, serverId]); + }, [client, serverId, setInitializingAgents, setViewedTimelineSync]); useEffect(() => { viewedTimelineSyncRef.current?.setConnected(isConnected); @@ -500,7 +533,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider { event: streamEvent, seq, epoch, timestamp: parsedTimestamp }, ]); if (turnLiveness.length > 0) { - applyAgentTurn(serverId, agentId, turnLiveness); + getHostRuntimeStore().applyAgentTurnLiveness(serverId, agentId, turnLiveness); } owner.enqueueStreamEvent(agentId, { event: streamEvent, @@ -530,7 +563,8 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider if (message.type !== "agent.timeline.replacement") return; void requestTimelineReplacement( { - fetchAgentTimeline: (agentId, request) => fetchAgentTimeline(serverId, agentId, request), + fetchAgentTimeline: (agentId, request) => + getHostRuntimeStore().fetchAgentTimeline(serverId, agentId, request), }, message.payload.agentId, ).catch((error: unknown) => { @@ -749,9 +783,17 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider client, queryClient, serverId, + setIsPlayingAudio, + setAgentStreamTail, + setAgentStreamHead, + clearAgentStreamHead, + setInitializingAgents, + setAgents, + setPendingPermissions, notifyAgentAttention, applyWorkspaceSetupProgress, applyTimelineResponse, + updateSessionServerInfo, toast, voiceRuntime, voiceAudioEngine, diff --git a/packages/app/src/contexts/session-status-tracking.ts b/packages/app/src/contexts/session-status-tracking.ts index f411ceb52a..3e56167d4a 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-hooks"; +import type { Agent } from "@/stores/session-store"; 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 97108b962a..1b4279631f 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-hooks"; +import type { WorkspaceDescriptor } from "@/stores/session-store"; 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 6547a198ef..7bb0b9b7e1 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 { readServerInfo } from "@/runtime/session-data"; +import { useSessionStore } from "@/stores/session-store"; import { createAudioEngine } from "@/voice/audio-engine"; import type { AudioEngine } from "@/voice/audio-engine-types"; import { @@ -137,7 +137,8 @@ export function VoiceProvider({ children }: VoiceProviderProps) { runtime = createVoiceRuntime({ engine, - getServerInfo: readServerInfo, + getServerInfo: (serverId) => + useSessionStore.getState().getSession(serverId)?.serverInfo ?? null, 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 d81adf3683..4be9cdd9ff 100644 --- a/packages/app/src/contexts/workspace-directory-reconciliation.ts +++ b/packages/app/src/contexts/workspace-directory-reconciliation.ts @@ -1,8 +1,5 @@ import type { SessionOutboundMessage } from "@getpaseo/protocol/messages"; -import { - normalizeWorkspaceDescriptor, - type WorkspaceDescriptor, -} from "@/stores/session-store-hooks"; +import { normalizeWorkspaceDescriptor, type WorkspaceDescriptor } from "@/stores/session-store"; 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 8e33d651c6..16076b4d6d 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,7 +17,8 @@ import { } from "@/desktop/daemon/daemon-management-error"; import { useDesktopIpcErrorReporter } from "@/desktop/hooks/desktop-ipc-error"; import type { DesktopSettings } from "@/desktop/settings/desktop-settings"; -import { registerDesktopDaemonConnection } from "@/runtime/daemon-start-service"; +import { getHostRuntimeStore } from "@/runtime/host-runtime"; +import { upsertDesktopDaemonConnection } from "@/runtime/daemon-start-service"; import { confirmDialog } from "@/utils/confirm-dialog"; type DesktopDaemonSettings = DesktopSettings["daemon"]; @@ -67,7 +68,10 @@ export function useBuiltInDaemonManagement( stopDaemon: () => stopDesktopDaemon("settings"), }); if (result.kind === "enabled") { - const upsertResult = await registerDesktopDaemonConnection(result.newStatus); + const upsertResult = await upsertDesktopDaemonConnection( + getHostRuntimeStore(), + 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 e56bca3027..a5630ed82f 100644 --- a/packages/app/src/file-pane/pane.tsx +++ b/packages/app/src/file-pane/pane.tsx @@ -13,8 +13,7 @@ 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 { useHostRuntimeClient } from "@/runtime/host-runtime"; -import { useServerFeature, type ExplorerFile } from "@/stores/session-store-hooks"; +import { useSessionStore, type ExplorerFile } from "@/stores/session-store"; import { filePreviewRenderKind } from "@/components/file-pane-render-mode"; import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url"; import { getFileNameFromPath } from "@/attachments/utils"; @@ -235,9 +234,11 @@ export function FilePane({ const isMobile = useIsCompactFormFactor(); const [previewMode, setPreviewMode] = useState<"preview" | "source">("preview"); - const client = useHostRuntimeClient(serverId); + const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null); // COMPAT(workspaceFileEditing): added in v0.2.0, remove after 2027-01-18 once daemon floor >= v0.2.0. - const supportsEditing = useServerFeature(serverId, "workspaceFileEditing"); + const supportsEditing = useSessionStore( + (state) => state.sessions[serverId]?.serverInfo?.features?.workspaceFileEditing === true, + ); 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.test.ts b/packages/app/src/git/actions-store.test.ts index 2b1b3af20e..9ab27ba40f 100644 --- a/packages/app/src/git/actions-store.test.ts +++ b/packages/app/src/git/actions-store.test.ts @@ -7,12 +7,6 @@ import { useCheckoutGitActionsStore, } from "@/git/actions-store"; -const hostClients = vi.hoisted(() => new Map()); - -vi.mock("@/runtime/host-runtime", () => ({ - getHostClient: (serverId: string) => hostClients.get(serverId) ?? null, -})); - vi.mock("@react-native-async-storage/async-storage", () => ({ default: { getItem: vi.fn(async () => null), @@ -39,7 +33,6 @@ describe("checkout-git-actions-store", () => { vi.useFakeTimers(); __resetCheckoutGitActionsStoreForTests(); appQueryClient.clear(); - hostClients.clear(); useSessionStore.setState((state) => ({ ...state, sessions: {} })); }); @@ -47,7 +40,6 @@ describe("checkout-git-actions-store", () => { vi.useRealTimers(); __resetCheckoutGitActionsStoreForTests(); appQueryClient.clear(); - hostClients.clear(); useSessionStore.setState((state) => ({ ...state, sessions: {} })); }); @@ -57,7 +49,13 @@ describe("checkout-git-actions-store", () => { checkoutCommit: vi.fn(() => deferred.promise), }; - hostClients.set(serverId, client as unknown as DaemonClient); + useSessionStore.setState((state) => ({ + ...state, + sessions: { + ...state.sessions, + [serverId]: { client } as unknown as (typeof state.sessions)[string], + }, + })); const store = useCheckoutGitActionsStore.getState(); @@ -87,7 +85,13 @@ describe("checkout-git-actions-store", () => { return {}; }), }; - hostClients.set(serverId, client as unknown as DaemonClient); + useSessionStore.setState((state) => ({ + ...state, + sessions: { + ...state.sessions, + [serverId]: { client } as unknown as (typeof state.sessions)[string], + }, + })); await useCheckoutGitActionsStore.getState().pullAndPush({ serverId, cwd }); @@ -102,7 +106,13 @@ describe("checkout-git-actions-store", () => { checkoutPull: vi.fn(async () => ({ error: { message: "pull conflict" } })), checkoutPush: vi.fn(async () => ({})), }; - hostClients.set(serverId, client as unknown as DaemonClient); + useSessionStore.setState((state) => ({ + ...state, + sessions: { + ...state.sessions, + [serverId]: { client } as unknown as (typeof state.sessions)[string], + }, + })); await expect( useCheckoutGitActionsStore.getState().pullAndPush({ serverId, cwd }), @@ -117,7 +127,13 @@ describe("checkout-git-actions-store", () => { checkoutPull: vi.fn(async () => ({})), checkoutPush: vi.fn(async () => ({ error: { message: "push rejected" } })), }; - hostClients.set(serverId, client as unknown as DaemonClient); + useSessionStore.setState((state) => ({ + ...state, + sessions: { + ...state.sessions, + [serverId]: { client } as unknown as (typeof state.sessions)[string], + }, + })); await expect( useCheckoutGitActionsStore.getState().pullAndPush({ serverId, cwd }), @@ -131,7 +147,13 @@ describe("checkout-git-actions-store", () => { const client = { checkoutRefresh: vi.fn(async () => ({ success: true, error: null })), }; - hostClients.set(serverId, client as unknown as DaemonClient); + useSessionStore.setState((state) => ({ + ...state, + sessions: { + ...state.sessions, + [serverId]: { client } as unknown as (typeof state.sessions)[string], + }, + })); await useCheckoutGitActionsStore.getState().refresh({ serverId, cwd }); @@ -145,7 +167,13 @@ describe("checkout-git-actions-store", () => { const client = { checkoutRefresh: vi.fn(async () => ({ error: { message: "not a git repository" } })), }; - hostClients.set(serverId, client as unknown as DaemonClient); + useSessionStore.setState((state) => ({ + ...state, + sessions: { + ...state.sessions, + [serverId]: { client } as unknown as (typeof state.sessions)[string], + }, + })); await expect(useCheckoutGitActionsStore.getState().refresh({ serverId, cwd })).rejects.toThrow( "not a git repository", @@ -158,7 +186,13 @@ describe("checkout-git-actions-store", () => { it("discards selected paths through the shared checkout action workflow", async () => { const checkoutDiscardChanges = vi.fn(async () => ({ success: true, error: null })); const client = { checkoutDiscardChanges }; - hostClients.set(serverId, client as unknown as DaemonClient); + useSessionStore.setState((state) => ({ + ...state, + sessions: { + ...state.sessions, + [serverId]: { client } as unknown as (typeof state.sessions)[string], + }, + })); await useCheckoutGitActionsStore .getState() @@ -193,7 +227,6 @@ describe("checkout-git-actions-store", () => { error: null, })); const client = { [rpc.method]: setAutoMerge }; - hostClients.set(serverId, client as unknown as DaemonClient); useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient); useSessionStore.getState().updateSessionServerInfo(serverId, { serverId, @@ -224,7 +257,6 @@ describe("checkout-git-actions-store", () => { error: null, })); const client = { [rpc.method]: setAutoMerge }; - hostClients.set(serverId, client as unknown as DaemonClient); useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient); useSessionStore.getState().updateSessionServerInfo(serverId, { serverId, @@ -252,7 +284,6 @@ describe("checkout-git-actions-store", () => { error: null, })), }; - hostClients.set(serverId, client as unknown as DaemonClient); useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient); useSessionStore.getState().updateSessionServerInfo(serverId, { serverId, diff --git a/packages/app/src/git/actions-store.ts b/packages/app/src/git/actions-store.ts index 30705461c0..0e38f04811 100644 --- a/packages/app/src/git/actions-store.ts +++ b/packages/app/src/git/actions-store.ts @@ -1,8 +1,7 @@ import type { CheckoutPrMergeMethod } from "@getpaseo/protocol/messages"; import { create } from "zustand"; import { queryClient as appQueryClient } from "@/data/query-client"; -import { getHostClient } from "@/runtime/host-runtime"; -import { hostSupports } from "@/runtime/session-data"; +import { useSessionStore } from "@/stores/session-store"; import { invalidateCheckoutGitQueriesForClient } from "@/git/query-keys"; import { i18n } from "@/i18n/i18next"; @@ -36,7 +35,8 @@ function checkoutKey(serverId: string, cwd: string): CheckoutKey { } function resolveClient(serverId: string) { - const client = getHostClient(serverId); + const session = useSessionStore.getState().sessions[serverId]; + const client = session?.client ?? null; if (!client) { throw new Error(i18n.t("common.errors.daemonClientUnavailable")); } @@ -46,13 +46,14 @@ function resolveClient(serverId: string) { type AutoMergeActionsRpc = "forge" | "github"; function resolveAutoMergeActionsRpc(serverId: string): AutoMergeActionsRpc { - if (hostSupports(serverId, "checkoutForgeSetAutoMerge")) { + const session = useSessionStore.getState().sessions[serverId]; + if (session?.serverInfo?.features?.checkoutForgeSetAutoMerge === true) { 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 (hostSupports(serverId, "checkoutGithubSetAutoMerge")) { + if (session?.serverInfo?.features?.checkoutGithubSetAutoMerge === true) { 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 e372f59f48..b934d56b25 100644 --- a/packages/app/src/git/diff-pane.tsx +++ b/packages/app/src/git/diff-pane.tsx @@ -72,8 +72,7 @@ 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 { useHostRuntimeClient } from "@/runtime/host-runtime"; -import { useServerFeature } from "@/stores/session-store-hooks"; +import { useSessionStore } from "@/stores/session-store"; import { confirmDialog } from "@/utils/confirm-dialog"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { Button } from "@/components/ui/button"; @@ -144,7 +143,9 @@ 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 = useServerFeature(serverId, "checkoutDiscardChanges"); + const discardSupported = useSessionStore( + (s) => s.sessions[serverId]?.serverInfo?.features?.checkoutDiscardChanges === true, + ); const discardPath = useCallback( async (path: string, oldPath?: string) => { const confirmed = await confirmDialog({ @@ -1631,10 +1632,14 @@ export function ChangesSurface({ isMobile, pullRequestOpenLocation: appSettings.pullRequestOpenLocation, }); - const refreshSupported = useServerFeature(serverId, "checkoutRefresh"); - const client = useHostRuntimeClient(serverId); + const refreshSupported = useSessionStore( + (s) => s.sessions[serverId]?.serverInfo?.features?.checkoutRefresh === true, + ); + const client = useSessionStore((state) => state.sessions[serverId]?.client); // COMPAT(fsEntryDuplicate): added in v0.3.0, remove gate after 2027-02-09. - const fsEntryDuplicateEnabled = useServerFeature(serverId, "fsEntryDuplicate"); + const fsEntryDuplicateEnabled = useSessionStore( + (state) => state.sessions[serverId]?.serverInfo?.features?.fsEntryDuplicate === true, + ); const runRefresh = useCheckoutGitActionsStore((s) => s.refresh); const isRefreshing = useCheckoutGitActionsStore((s) => s.getStatus({ serverId, cwd, actionId: "refresh" })) === @@ -1691,7 +1696,9 @@ export function ChangesSurface({ cwd, enabled: isGit, }); - const forgeProvidersSupported = useServerFeature(serverId, "forgeProviders"); + const forgeProvidersSupported = useSessionStore( + (s) => s.sessions[serverId]?.serverInfo?.features?.forgeProviders === true, + ); 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 b3b184fef9..b7c0890c3a 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 { useServerFeature } from "@/stores/session-store-hooks"; +import { useSessionStore } from "@/stores/session-store"; import { useWorkspaceAttachmentsStore } from "@/attachments/workspace-attachments-store"; import { useToast } from "@/contexts/toast-context"; import { useCheckoutGitActionsStore } from "@/git/actions-store"; @@ -209,9 +209,15 @@ 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 = useServerFeature(serverId, "githubCheckDetails"); - const canFetchForgeCheckDetails = useServerFeature(serverId, "forgeCheckDetails"); - const forgeProvidersEnabled = useServerFeature(serverId, "forgeProviders"); + 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 addWorkspaceAttachment = useWorkspaceAttachmentsStore( (state) => state.addWorkspaceAttachment, ); @@ -224,7 +230,9 @@ export function PullRequestPane({ void openExternalUrl(data.url); }, [data.url]); - const refreshSupported = useServerFeature(serverId, "checkoutRefresh"); + const refreshSupported = useSessionStore( + (state) => state.sessions[serverId]?.serverInfo?.features?.checkoutRefresh === true, + ); 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 4df7bfbbf6..05e65a21a5 100644 --- a/packages/app/src/git/use-actions.tsx +++ b/packages/app/src/git/use-actions.tsx @@ -21,12 +21,7 @@ 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 { - useServerFeature, - useWorkspaceByDirectoryFields, - useWorkspaceFields, - type WorkspaceDescriptor, -} from "@/stores/session-store-hooks"; +import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store"; import { useActiveWorkspaceSelection, type ActiveWorkspaceSelection, @@ -34,6 +29,7 @@ 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"; @@ -220,13 +216,33 @@ interface UseWorkspaceScreenArchiveControllerInput { t: (key: string, options?: Record) => string; } -type ArchiveWorkspaceProjection = Pick< - WorkspaceDescriptor, - "id" | "workspaceKind" | "name" | "diffStat" | "archivingAt" | "gitRuntime" ->; +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; +} function resolveWorkspaceArchiveRisk( - workspace: ArchiveWorkspaceProjection | null, + workspace: WorkspaceDescriptor | null, gitStatus: CheckoutStatusPayload | null, ): { isDirty: boolean | null | undefined; aheadOfOrigin: number | null | undefined } { return { @@ -236,7 +252,7 @@ function resolveWorkspaceArchiveRisk( } function canArchiveWorkspace( - workspace: ArchiveWorkspaceProjection | null, + workspace: WorkspaceDescriptor | null, risk: ReturnType, ): boolean { return ( @@ -254,29 +270,17 @@ function useWorkspaceScreenArchiveController({ gitStatus, t, }: UseWorkspaceScreenArchiveControllerInput) { + const sessionWorkspaces = useSessionStore((state) => state.sessions[serverId]?.workspaces); const [isHidingWorkspace, setIsHidingWorkspace] = useState(false); - const projectArchiveFields = useCallback( - (workspace: WorkspaceDescriptor) => ({ - id: workspace.id, - workspaceKind: workspace.workspaceKind, - name: workspace.name, - diffStat: workspace.diffStat, - archivingAt: workspace.archivingAt, - gitRuntime: workspace.gitRuntime, - }), - [], + const workspaceDescriptor = useMemo( + () => + resolveArchiveWorkspaceDescriptor({ + workspaces: sessionWorkspaces, + activeWorkspaceSelection, + workspaceDirectory, + }), + [activeWorkspaceSelection, sessionWorkspaces, workspaceDirectory], ); - 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({ @@ -449,9 +453,11 @@ 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 supportsForgeAutoMerge = useServerFeature(serverId, "checkoutForgeSetAutoMerge"); - const supportsGithubAutoMerge = useServerFeature(serverId, "checkoutGithubSetAutoMerge"); - const githubAutoMergeActionsEnabled = supportsForgeAutoMerge || supportsGithubAutoMerge; + const githubAutoMergeActionsEnabled = useSessionStore( + (s) => + s.sessions[serverId]?.serverInfo?.features?.checkoutForgeSetAutoMerge === true || + s.sessions[serverId]?.serverInfo?.features?.checkoutGithubSetAutoMerge === true, + ); 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 b369f93c3b..bb77f50942 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 { useServerFeature } from "@/stores/session-store-hooks"; +import { useSessionStore } from "@/stores/session-store"; // 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,9 +80,11 @@ 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 supportsCommitsList = useServerFeature(serverId, "commitsList"); - const supportsBaseClassification = useServerFeature(serverId, "commitBaseClassification"); - const capabilityPresent = supportsCommitsList && supportsBaseClassification; + const capabilityPresent = useSessionStore( + (state) => + state.sessions[serverId]?.serverInfo?.features?.commitsList === true && + state.sessions[serverId]?.serverInfo?.features?.commitBaseClassification === true, + ); 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 03f53f471b..aae61c8b1d 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-hooks"; +import { normalizeProjectDescriptor, type ProjectDescriptor } from "@/stores/session-store"; 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 7381dbbc3a..637d3a035b 100644 --- a/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts +++ b/packages/app/src/hooks/sidebar-workspaces-view-model.test.ts @@ -692,7 +692,6 @@ function agent(input: { id: string; workspaceId: string; status: Agent["status"]; - turn?: Agent["turn"]; updatedAt?: Date; parentAgentId?: string | null; archivedAt?: Date | null; @@ -702,7 +701,10 @@ function agent(input: { id: input.id, provider: "claude" as Agent["provider"], status: input.status, - turn: input.turn ?? { phase: "idle", cancellationRequestId: null }, + turn: + input.status === "running" + ? { phase: "open", turnId: null, startedAt: null, cancellationRequestId: null } + : { phase: "idle", cancellationRequestId: null }, createdAt: new Date(0), updatedAt: input.updatedAt ?? new Date(1_000), lastUserMessageAt: null, @@ -892,19 +894,7 @@ describe("deriveProjectStatusBucket", () => { sessions: { srv: sessionWith({ workspaces: [projectWorkspace("ws-1", "done")], - agents: [ - agent({ - id: "a1", - workspaceId: "ws-1", - status: "running", - turn: { - phase: "open", - turnId: "turn-1", - startedAt: new Date(1_000), - cancellationRequestId: null, - }, - }), - ], + agents: [agent({ id: "a1", workspaceId: "ws-1", status: "running" })], }), }, }), diff --git a/packages/app/src/hooks/sidebar-workspaces-view-model.ts b/packages/app/src/hooks/sidebar-workspaces-view-model.ts index 6a6f1ae7fd..842844cdc2 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-hooks"; +import type { WorkspaceDescriptor } from "@/stores/session-store"; import type { WorkspaceStructureHostPlacement, WorkspaceStructureProject, @@ -71,13 +71,13 @@ export interface SidebarWorkspacePlacementModel { export interface SidebarWorkspaceSession { serverId: string; - workspaces: ReadonlyMap; - workspaceAgentActivity: ReadonlyMap; + workspaces: Map; + workspaceAgentActivity: Map; } interface SidebarWorkspaceSessionSource { - workspaces: ReadonlyMap; - workspaceAgentActivity: ReadonlyMap; + workspaces: Map; + workspaceAgentActivity: Map; } export function selectSidebarWorkspaceSessions( @@ -232,8 +232,8 @@ function getPendingInitialAgentCreateStartedAt(input: { } export interface ProjectStatusSession { - workspaces: ReadonlyMap; - workspaceAgentActivity: ReadonlyMap; + workspaces: Map; + workspaceAgentActivity: Map; } /** diff --git a/packages/app/src/hooks/use-agent-autocomplete.ts b/packages/app/src/hooks/use-agent-autocomplete.ts index 386e055dd1..5ab515d8b1 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 { useActiveAgentFields } from "@/stores/session-store-hooks"; +import { useSessionStore } from "@/stores/session-store"; 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,7 +389,9 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut const queryDraftConfig = normalizedDraftConfig; const canLoadCommands = resolveCanLoadCommands({ serverId, agentId, isDraftContext }); - const agentCwd = useActiveAgentFields(serverId, agentId, (agent) => agent.cwd, Object.is) ?? ""; + const agentCwd = useSessionStore( + (state) => state.sessions[serverId]?.agents?.get(agentId)?.cwd ?? "", + ); 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 fb80c9f5cd..3ac397e719 100644 --- a/packages/app/src/hooks/use-agent-history.ts +++ b/packages/app/src/hooks/use-agent-history.ts @@ -5,17 +5,11 @@ import type { } from "@getpaseo/client/internal/daemon-client"; import type { AgentSearchMatch } from "@getpaseo/protocol/messages"; import { useInfiniteQuery } from "@tanstack/react-query"; -import { useCallback, useMemo } from "react"; +import { useCallback, useMemo, useSyncExternalStore } from "react"; import { useTranslation } from "react-i18next"; import type { AggregatedAgent } from "@/hooks/use-aggregated-agents"; -import { - getHostClient, - isHostRuntimeConnected, - readHostRuntimeSnapshot, - useHostRuntimeVersion, - useHosts, -} from "@/runtime/host-runtime"; -import { useConnections } from "@/stores/session-store-hooks"; +import { getHostRuntimeStore, isHostRuntimeConnected, useHosts } from "@/runtime/host-runtime"; +import { useSessionStore } from "@/stores/session-store"; import { buildAgentDirectoryState } from "@/utils/agent-directory-sync"; import { agentHistoryQueryKey, allAgentHistoryQueryKey } from "./agent-history-query-key"; @@ -305,7 +299,12 @@ export function useAgentHistory(options: { }): AgentHistoryResult { const { t } = useTranslation(); const daemons = useHosts(); - const runtimeVersion = useHostRuntimeVersion(); + const runtime = getHostRuntimeStore(); + const runtimeVersion = useSyncExternalStore( + (onStoreChange) => runtime.subscribeAll(onStoreChange), + () => runtime.getVersion(), + () => runtime.getVersion(), + ); const serverId = useMemo(() => { const value = options.serverId; return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; @@ -323,8 +322,8 @@ export function useAgentHistory(options: { const unreachable: AgentHistoryHostError[] = []; for (const targetServerId of serverIds) { - const snapshot = readHostRuntimeSnapshot(targetServerId); - const client = getHostClient(targetServerId); + const snapshot = runtime.getSnapshot(targetServerId); + const client = runtime.getClient(targetServerId); const serverName = serverLabelById.get(targetServerId) ?? targetServerId; if (!client || !isHostRuntimeConnected(snapshot)) { unreachable.push({ serverId: targetServerId, serverName }); @@ -334,18 +333,17 @@ export function useAgentHistory(options: { } return { targetHosts: hosts, unreachableHosts: unreachable }; - }, [daemons, runtimeVersion, serverId]); + }, [daemons, runtime, 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 = useConnections( - targetServerIds, + const isSearchSupported = useSessionStore( useCallback( - (connections) => + (state) => targetServerIds.length > 0 && targetServerIds.every( - (id) => connections.get(id)?.serverInfo?.features?.agentHistorySearch === true, + (id) => state.sessions[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 22f44c7950..a19f36bf20 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 { getAgentTimelineSnapshot, publishAgentInitialization } from "@/runtime/session-data"; +import { selectAgentTimelineState, useSessionStore } from "@/stores/session-store"; import { createInitDeferred, getInitDeferred, @@ -10,7 +10,7 @@ import { rejectInitDeferred, refreshInitTimeout, } from "@/utils/agent-initialization"; -import { agentTimelineRuntime } from "@/runtime/host-runtime"; +import { getHostRuntimeStore, type HostRuntimeStore } 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: typeof agentTimelineRuntime; + runtime: Pick; setAgentInitializing: SetAgentInitializing; hostDisconnectedMessage?: string; } @@ -51,7 +51,7 @@ export function ensureAgentIsInitialized(input: EnsureAgentIsInitializedInput): return existing.promise; } - const timeline = getAgentTimelineSnapshot(serverId, agentId); + const timeline = selectAgentTimelineState(useSessionStore.getState().sessions[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: typeof agentTimelineRuntime; + runtime: Pick; setAgentInitializing: SetAgentInitializing; hostDisconnectedMessage?: string; } @@ -105,7 +105,7 @@ export async function refreshAgent(input: RefreshAgentInput): Promise { export function createSetAgentInitializing( serverId: string, - setInitializingAgents: typeof publishAgentInitialization, + setInitializingAgents: ReturnType["setInitializingAgents"], ): SetAgentInitializing { return (agentId, initializing) => { setInitializingAgents(serverId, (prev) => { @@ -127,9 +127,10 @@ export function useAgentInitialization({ client: DaemonClient | null; }) { const { t } = useTranslation(); + const setInitializingAgents = useSessionStore((state) => state.setInitializingAgents); const setAgentInitializing = useMemo( - () => createSetAgentInitializing(serverId, publishAgentInitialization), - [serverId], + () => createSetAgentInitializing(serverId, setInitializingAgents), + [serverId, setInitializingAgents], ); const ensureAgentIsInitializedCallback = useCallback( @@ -138,7 +139,7 @@ export function useAgentInitialization({ serverId, agentId, client, - runtime: agentTimelineRuntime, + runtime: getHostRuntimeStore(), setAgentInitializing, hostDisconnectedMessage: t("workspace.terminal.hostDisconnected"), }), @@ -151,7 +152,7 @@ export function useAgentInitialization({ serverId, agentId, client, - runtime: agentTimelineRuntime, + runtime: getHostRuntimeStore(), 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 6c188d3e54..c23512758e 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,12 +17,7 @@ function createAgent(id: string): Agent { id, provider: "claude", status: "running", - turn: { - phase: "open", - turnId: "turn-1", - startedAt: now, - cancellationRequestId: null, - }, + 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 020617e141..78cca7fbb3 100644 --- a/packages/app/src/hooks/use-aggregated-agents.ts +++ b/packages/app/src/hooks/use-aggregated-agents.ts @@ -1,38 +1,16 @@ -import { useMemo, useCallback, useEffect, useRef } from "react"; +import { useMemo, useCallback, useEffect, useRef, useSyncExternalStore } from "react"; import equal from "fast-deep-equal"; -import { useAgentDirectories } from "@/stores/session-store-hooks"; -import type { Agent } from "@/stores/session-store-hooks"; +import { useShallow } from "zustand/shallow"; +import { useSessionStore } from "@/stores/session-store"; import type { AgentDirectoryEntry } from "@/types/agent-directory"; -import { - acquireDirectoryDemand, - readHostRuntimeSnapshot, - refreshHostDirectories, - useHostRuntimeVersion, - useHosts, -} from "@/runtime/host-runtime"; +import type { Agent } from "@/stores/session-store"; +import { getHostRuntimeStore, 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; @@ -46,30 +24,35 @@ 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(acquireDirectoryDemand); + const releases = serverIds.map((serverId) => runtime.acquireDirectoryDemand(serverId)); return () => releases.forEach((release) => release()); - }, [demand, serverIds]); - const runtimeVersion = useHostRuntimeVersion(); + }, [demand, runtime, serverIds]); + const runtimeVersion = useSyncExternalStore( + (onStoreChange) => runtime.subscribeAll(onStoreChange), + () => runtime.getVersion(), + () => runtime.getVersion(), + ); - const sessionAgents = useAgentDirectories( - serverIds, - (directories) => - serverIds.map((serverId) => ({ - serverId, - agents: directories.get(serverId)?.agents ?? EMPTY_AGENT_MAP, - })), - agentSourcesEqual, + 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 refreshAll = useCallback(() => { if (!demand) return; - for (const serverId of serverIds) void refreshHostDirectories(serverId); - }, [demand, serverIds]); + for (const serverId of serverIds) void runtime.refreshDirectories(serverId); + }, [demand, runtime, serverIds]); // Keyed by "serverId:agentId" — reuse the previous AggregatedAgent object when // none of its fields changed, so downstream memo/shallow comparisons can bail early. @@ -87,8 +70,8 @@ export function useAggregatedAgents(options?: { ); // Derive agent directory from all sessions - for (const { serverId, agents } of sessionAgents) { - if (agents.size === 0) { + for (const [serverId, agents] of Object.entries(sessionAgents)) { + if (!agents || agents.size === 0) { continue; } const serverLabel = serverLabelById.get(serverId) ?? serverId; @@ -162,7 +145,7 @@ export function useAggregatedAgents(options?: { // Align list loading with the runtime directory-sync machine. const isLoading = daemons.some((daemon) => { const status = - readHostRuntimeSnapshot(daemon.serverId)?.agentDirectoryStatus ?? "initial_loading"; + runtime.getSnapshot(daemon.serverId)?.agentDirectoryStatus ?? "initial_loading"; return status === "initial_loading" || status === "revalidating"; }); const isInitialLoad = isLoading && !hasAnyData; @@ -174,7 +157,7 @@ export function useAggregatedAgents(options?: { isInitialLoad, isRevalidating, }; - }, [daemons, includeArchived, runtimeVersion, sessionAgents]); + }, [daemons, includeArchived, runtime, 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 9bda09b9fd..df14f2df38 100644 --- a/packages/app/src/hooks/use-archive-agent.test.ts +++ b/packages/app/src/hooks/use-archive-agent.test.ts @@ -1,10 +1,9 @@ +import { seedSessionHosts } from "@/test/seed-session"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { QueryClient } from "@tanstack/react-query"; -import { beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; import type { Agent } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store"; -import { acceptAgentSnapshot } from "@/runtime/session-data"; -import { installSessionDataTestOwner } from "@/test/seed-session"; import { agentHistoryQueryKey, allAgentHistoryQueryKey } from "./agent-history-query-key"; import { applyArchivedAgentCloseResults, @@ -48,9 +47,10 @@ function makeAgent(overrides: Partial = {}): Agent { } describe("useArchiveAgent", () => { + afterEach(() => seedSessionHosts([])); beforeEach(() => { useSessionStore.setState((state) => ({ ...state, sessions: {} })); - installSessionDataTestOwner(["server-a"]); + seedSessionHosts(["server-a"]); }); it("tracks pending archive state in shared react-query cache", () => { @@ -130,7 +130,7 @@ describe("useArchiveAgent", () => { it("applies archived agent close results to session state and cached lists", async () => { const queryClient = new QueryClient(); useSessionStore.getState().initializeSession("server-a", {} as DaemonClient); - acceptAgentSnapshot("server-a", makeAgent()); + useSessionStore.getState().setAgents("server-a", new Map([["agent-1", makeAgent()]])); queryClient.setQueryData(["sidebarAgentsList", "server-a"], { entries: [{ agent: { id: "agent-1" } }, { agent: { id: "agent-2" } }], }); @@ -212,7 +212,7 @@ describe("useArchiveAgent", () => { it("can apply archived agent close results without invalidating cached lists", () => { const queryClient = new QueryClient(); useSessionStore.getState().initializeSession("server-a", {} as DaemonClient); - acceptAgentSnapshot("server-a", makeAgent()); + useSessionStore.getState().setAgents("server-a", new Map([["agent-1", makeAgent()]])); queryClient.setQueryData(["sidebarAgentsList", "server-a"], { entries: [{ agent: { id: "agent-1" } }, { agent: { id: "agent-2" } }], }); diff --git a/packages/app/src/hooks/use-archive-agent.ts b/packages/app/src/hooks/use-archive-agent.ts index 66ed312635..92d8e4d2d6 100644 --- a/packages/app/src/hooks/use-archive-agent.ts +++ b/packages/app/src/hooks/use-archive-agent.ts @@ -1,3 +1,4 @@ +import { getHostRuntimeStore } from "@/runtime/host-runtime"; import { useCallback, useMemo } from "react"; import { useMutation, @@ -7,12 +8,7 @@ import { type QueryKey, } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; -import { getActiveAgentSnapshot } from "@/runtime/session-data"; -import { - archiveAgentSnapshot, - restoreAgentSnapshot as restoreStoredAgent, -} from "@/runtime/session-data"; -import { getHostClient } from "@/runtime/host-runtime"; +import { useSessionStore } from "@/stores/session-store"; import { agentHistoryQueryKey, allAgentHistoryQueryRootKey } from "./agent-history-query-key"; export const ARCHIVE_AGENT_PENDING_QUERY_KEY = ["archive-agent-pending"] as const; @@ -246,13 +242,13 @@ interface ArchiveAgentMutationContext { } function getStoredAgentSnapshot(input: ArchiveAgentInput) { - return getActiveAgentSnapshot(input.serverId, input.agentId) ?? undefined; + return useSessionStore.getState().sessions[input.serverId]?.agents.get(input.agentId); } function restoreAgentSnapshot( input: ArchiveAgentInput & { agent: ReturnType }, ): void { - restoreStoredAgent(input.serverId, input.agentId, input.agent); + getHostRuntimeStore().restoreAgentSnapshot(input.serverId, input.agentId, input.agent); } function getArchivedAgentListCacheSnapshot( @@ -309,7 +305,11 @@ function markAgentArchivedInStore(input: ArchiveAgentInput & { archivedAt: strin return; } - archiveAgentSnapshot(input.serverId, input.agentId, archivedAt.toISOString()); + getHostRuntimeStore().archiveAgentSnapshot( + input.serverId, + input.agentId, + archivedAt.toISOString(), + ); } interface ApplyArchivedAgentCloseResultsInput { @@ -383,7 +383,7 @@ export function useArchiveAgent() { const archiveMutation = useMutation({ mutationFn: async (input: ArchiveAgentInput): Promise<{ archivedAt: string }> => { - const client = getHostClient(input.serverId); + const client = useSessionStore.getState().sessions[input.serverId]?.client ?? null; 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 0c4d3253d5..0ba74c97e6 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 { getHostClient } from "@/runtime/host-runtime"; -import { useWorkspaceFields } from "@/stores/session-store-hooks"; +import { getHostRuntimeStore } from "@/runtime/host-runtime"; +import { useSessionStore } from "@/stores/session-store"; export interface ClearWorkspaceAttentionController { hasClearableAttention: boolean; @@ -15,18 +15,16 @@ export function useClearWorkspaceAttention({ serverId: string; workspaceId: string; }): ClearWorkspaceAttentionController { - const hasClearableAttention = - useWorkspaceFields( - serverId, - workspaceId, - (workspace) => workspace.status === "attention" || workspace.status === "failed", - ) ?? false; + const hasClearableAttention = useSessionStore((state) => { + const workspace = state.sessions[serverId]?.workspaces.get(workspaceId); + return workspace?.status === "attention" || workspace?.status === "failed"; + }); const clearAttention = useCallback(async () => { if (!hasClearableAttention) { return; } - const client = getHostClient(serverId); + const client = getHostRuntimeStore().getClient(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 12b954cb16..5fcce9a24c 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.turn.phase === "open"); + const hasRunning = agents.some((agent) => agent.status === "running"); 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 4bbfd3eccd..53714e37b3 100644 --- a/packages/app/src/hooks/use-file-explorer-actions.ts +++ b/packages/app/src/hooks/use-file-explorer-actions.ts @@ -1,8 +1,10 @@ import { useCallback, useMemo } from "react"; import { useTranslation } from "react-i18next"; -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 { + useSessionStore, + type AgentFileExplorerState, + type ExplorerDirectory, +} from "@/stores/session-store"; import { explorerFileFromReadResult } from "@/file-explorer/read-result"; import { parentExplorerPath } from "@/utils/explorer-paths"; @@ -57,7 +59,8 @@ export function buildWorkspaceExplorerStateKey(scope: FileExplorerWorkspaceScope export function useFileExplorerActions(params: { serverId: string } & FileExplorerWorkspaceScope) { const { t } = useTranslation(); const { serverId, workspaceId, workspaceRoot } = params; - const client = useHostRuntimeClient(serverId); + const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null); + const setFileExplorer = useSessionStore((state) => state.setFileExplorer); const normalizedWorkspaceRoot = useMemo( () => normalizeWorkspaceValue(workspaceRoot), [workspaceRoot], @@ -83,7 +86,7 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor return next; }); }, - [serverId, workspaceStateKey], + [serverId, setFileExplorer, 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 d567fa256c..f2cdf3f911 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 { useHostRuntimeClient } from "@/runtime/host-runtime"; +import { useSessionStore } from "@/stores/session-store"; import { buildDraftWorkspaceAttachmentScopeKey, useWorkspaceAttachmentsStore, @@ -129,7 +129,7 @@ export function useForkAgent( const { serverId, toast, readOnly = false } = input; const { t } = useTranslation(); const router = useRouter(); - const client = useHostRuntimeClient(serverId); + const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null); 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 cad33d2c05..ee74ee3886 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 { useConnection } from "@/stores/session-store-hooks"; +import { useSessionStore } from "@/stores/session-store"; import { getVoiceReadinessState } from "@/utils/server-info-capabilities"; function isLegacyDictationReady(agentDirectoryStatus: HostRuntimeAgentDirectoryStatus): boolean { @@ -21,16 +21,19 @@ export function useIsDictationReady({ isConnected: boolean; agentDirectoryStatus: HostRuntimeAgentDirectoryStatus; }): boolean { - const dictationCapabilityEnabled = useConnection( - serverId, - useCallback(({ serverInfo }) => { - return ( - getVoiceReadinessState({ - serverInfo, - mode: "dictation", - })?.enabled ?? null - ); - }, []), + const dictationCapabilityEnabled = useSessionStore( + useCallback( + (state) => { + const serverInfo = state.sessions[serverId]?.serverInfo ?? null; + return ( + getVoiceReadinessState({ + serverInfo, + mode: "dictation", + })?.enabled ?? null + ); + }, + [serverId], + ), ); 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 af4a3f3a5a..2bc3f5c9df 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 { - getAgentTimelineLoadSnapshot, - publishOlderTimelineFetch as setOlderFetchInFlight, -} from "@/runtime/session-data"; + selectAgentTimelineState, + useSessionStore, + type AgentTimelineCursorState, +} from "@/stores/session-store"; import { planTimelineOlderFetch } from "@/timeline/timeline-sync-plan"; -import { fetchAgentTimeline, getHostClient } from "@/runtime/host-runtime"; +import { getHostRuntimeStore } from "@/runtime/host-runtime"; export interface LoadOlderAgentHistoryClient { fetchAgentTimeline: ( @@ -78,15 +78,22 @@ export function useLoadOlderAgentHistory({ toast?: ToastApi | null; }) { const { t } = useTranslation(); - const timelineState = useAgentTimeline(serverId, agentId, (timeline, meta) => { - const cursor = timeline.status === "synced" ? timeline.range : null; - return { - hasOlder: timeline.status === "synced" && timeline.older === "available", - isLoadingOlder: meta.isLoadingOlder, - progressKey: cursor ? `${cursor.epoch}:${cursor.startSeq}` : null, - }; + const hasOlder = useSessionStore((state) => { + const timeline = selectAgentTimelineState(state.sessions[serverId], agentId); + return timeline.status === "synced" && timeline.older === "available"; }); - const { hasOlder, isLoadingOlder, progressKey } = timelineState; + const isLoadingOlder = + useSessionStore((state) => + state.sessions[serverId]?.agentTimelineOlderFetchInFlight.get(agentId), + ) === true; + const progressKey = useSessionStore((state) => { + const timeline = selectAgentTimelineState(state.sessions[serverId], agentId); + const cursor = timeline.status === "synced" ? timeline.range : null; + return cursor ? `${cursor.epoch}:${cursor.startSeq}` : null; + }); + const setOlderFetchInFlight = useSessionStore( + (state) => state.setAgentTimelineOlderFetchInFlight, + ); const setInFlight = useCallback( (value: boolean) => { @@ -99,24 +106,22 @@ export function useLoadOlderAgentHistory({ return next; }); }, - [agentId, serverId], + [agentId, serverId, setOlderFetchInFlight], ); const loadOlder = useCallback(async (): Promise => { - const { timeline, isLoadingOlder: currentIsLoadingOlder } = getAgentTimelineLoadSnapshot( - serverId, - agentId, - ); + const session = useSessionStore.getState().sessions[serverId]; + const timeline = selectAgentTimelineState(session, agentId); return await loadOlderAgentHistory(agentId, { - client: getHostClient(serverId) + client: session?.client ? { fetchAgentTimeline: (timelineAgentId, request) => - fetchAgentTimeline(serverId, timelineAgentId, request), + getHostRuntimeStore().fetchAgentTimeline(serverId, timelineAgentId, request), } : null, cursor: timeline.status === "synced" ? (timeline.range ?? undefined) : undefined, hasOlder: timeline.status === "synced" && timeline.older === "available", - isLoadingOlder: currentIsLoadingOlder, + isLoadingOlder: session?.agentTimelineOlderFetchInFlight.get(agentId) === true, 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 3bd546a9e4..18dc5064f3 100644 --- a/packages/app/src/hooks/use-open-project.ts +++ b/packages/app/src/hooks/use-open-project.ts @@ -1,10 +1,7 @@ +import { getHostRuntimeStore } from "@/runtime/host-runtime"; import { useCallback } from "react"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; -import { useServerFeature } from "@/stores/session-store-hooks"; -import { - acceptProjectSnapshot as upsertProject, - publishWorkspaceHydration as setHasHydratedWorkspaces, -} from "@/runtime/session-data"; +import { useSessionStore } from "@/stores/session-store"; import { cloneGithubProjectDirectly, openProjectDirectly, @@ -18,12 +15,22 @@ export function useOpenProject( const normalizedServerId = serverId?.trim() ?? ""; const client = useHostRuntimeClient(normalizedServerId); const isConnected = useHostRuntimeIsConnected(normalizedServerId); - const supportsProjectAdd = useServerFeature(normalizedServerId, "projectAdd"); - const supportsStableProjectIdentity = useServerFeature( - normalizedServerId, - "stableProjectIdentity", + const canAddProject = useSessionStore((state) => + normalizedServerId + ? state.sessions[normalizedServerId]?.serverInfo?.features?.projectAdd === true && + state.sessions[normalizedServerId]?.serverInfo?.features?.stableProjectIdentity === true + : false, ); - const canAddProject = supportsProjectAdd && supportsStableProjectIdentity; + const upsertProject = useCallback( + ( + targetServerId: string, + project: Parameters["acceptProjectSnapshot"]>[1], + ) => { + getHostRuntimeStore().acceptProjectSnapshot(targetServerId, project); + }, + [], + ); + const setHasHydratedWorkspaces = useSessionStore((state) => state.setHasHydratedWorkspaces); return useCallback( async (path: string) => { @@ -38,7 +45,14 @@ export function useOpenProject( }); return result; }, - [canAddProject, client, isConnected, normalizedServerId], + [ + upsertProject, + canAddProject, + client, + isConnected, + normalizedServerId, + setHasHydratedWorkspaces, + ], ); } @@ -52,6 +66,16 @@ export function useCloneGithubProject( const normalizedServerId = serverId?.trim() ?? ""; const client = useHostRuntimeClient(normalizedServerId); const isConnected = useHostRuntimeIsConnected(normalizedServerId); + const upsertProject = useCallback( + ( + targetServerId: string, + project: Parameters["acceptProjectSnapshot"]>[1], + ) => { + getHostRuntimeStore().acceptProjectSnapshot(targetServerId, project); + }, + [], + ); + const setHasHydratedWorkspaces = useSessionStore((state) => state.setHasHydratedWorkspaces); return useCallback( async (repo: string, targetDirectory: string, cloneProtocol?: ProjectGithubCloneProtocol) => { @@ -66,6 +90,6 @@ export function useCloneGithubProject( setHasHydratedWorkspaces, }); }, - [client, isConnected, normalizedServerId], + [client, isConnected, normalizedServerId, setHasHydratedWorkspaces, upsertProject], ); } diff --git a/packages/app/src/hooks/use-projects.ts b/packages/app/src/hooks/use-projects.ts index fca8207fff..ecf44a91b2 100644 --- a/packages/app/src/hooks/use-projects.ts +++ b/packages/app/src/hooks/use-projects.ts @@ -1,20 +1,17 @@ -import { useCallback, useEffect, useMemo, useRef } from "react"; +import { useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from "react"; import equal from "fast-deep-equal"; +import { useStoreWithEqualityFn } from "zustand/traditional"; import { - acquireDirectoryDemand, + getHostRuntimeStore, isHostRuntimeDirectoryLoading, - readHostRuntimeSnapshot, - refreshHostDirectories, - useHostRuntimeVersion, useHosts, type HostRuntimeSnapshot, } from "@/runtime/host-runtime"; import { - useWorkspaceDirectories, - type WorkspaceDirectoryView, + useSessionStore, type ProjectDescriptor, type WorkspaceDescriptor, -} from "@/stores/session-store-hooks"; +} from "@/stores/session-store"; import { buildProjects, type ProjectHost, type ProjectSummary } from "@/utils/projects"; export interface ProjectHostError { @@ -76,41 +73,23 @@ function toProjectHostRuntimeState( }; } -function createProjectHostReplicasSelector( +function selectProjectHostReplicas( hosts: readonly { serverId: string; label: string }[], enabled: boolean, -): (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 }; +): (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() ?? []), + }; }); - 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: { @@ -155,8 +134,15 @@ function useProjectHostRuntimeStates( serverIds: readonly string[], enabled: boolean, ): ProjectHostRuntimeState[] { + const runtime = getHostRuntimeStore(); const previousStatesRef = useRef([]); - const runtimeSnapshotTick = useHostRuntimeVersion(); + 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); return useMemo(() => { if (!enabled) { previousStatesRef.current = EMPTY_PROJECT_HOST_RUNTIME_STATES; @@ -164,32 +150,33 @@ function useProjectHostRuntimeStates( } void runtimeSnapshotTick; const nextStates = serverIds.map((serverId) => - toProjectHostRuntimeState(serverId, readHostRuntimeSnapshot(serverId)), + toProjectHostRuntimeState(serverId, runtime.getSnapshot(serverId)), ); if (equal(previousStatesRef.current, nextStates)) { return previousStatesRef.current; } previousStatesRef.current = nextStates; return nextStates; - }, [enabled, runtimeSnapshotTick, serverIds]); + }, [enabled, runtime, 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(acquireDirectoryDemand); + const releases = serverIds.map((serverId) => runtime.acquireDirectoryDemand(serverId)); return () => releases.forEach((release) => release()); - }, [serverIds]); - const selectReplicas = useMemo( - () => createProjectHostReplicasSelector(hosts, enabled), + }, [runtime, serverIds]); + const replicaSelector = useMemo( + () => selectProjectHostReplicas(hosts, enabled), [enabled, hosts], ); - const replicas = useWorkspaceDirectories(serverIds, selectReplicas); + const replicas = useStoreWithEqualityFn(useSessionStore, replicaSelector, equal); const runtimeStates = useProjectHostRuntimeStates(serverIds, enabled); const derived = useMemo( () => deriveProjectsFromReplica({ replicas, runtimeStates }), @@ -197,8 +184,8 @@ export function useProjects(options: UseProjectsOptions = {}): UseProjectsResult ); const refetch = useCallback(() => { if (!enabled) return; - for (const serverId of serverIds) void refreshHostDirectories(serverId); - }, [enabled, serverIds]); + for (const serverId of serverIds) void runtime.refreshDirectories(serverId); + }, [enabled, runtime, serverIds]); return { ...derived, diff --git a/packages/app/src/hooks/use-providers-snapshot.ts b/packages/app/src/hooks/use-providers-snapshot.ts index b04ec8025b..52fff6e36a 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 { useServerFeature } from "@/stores/session-store-hooks"; +import { useSessionStore } from "@/stores/session-store"; import { useReplicaQuery } from "@/data/query"; import { queryClient as singletonQueryClient } from "@/data/query-client"; import { @@ -138,7 +138,9 @@ export function useProvidersSnapshot( const enabled = (options.enabled ?? true) && retainedPanelActive; const isConnected = useHostRuntimeIsConnected(serverId ?? ""); const cwd = normalizeProvidersSnapshotCwd(options.cwd); - const supportsSnapshot = useServerFeature(serverId, "providersSnapshot"); + const supportsSnapshot = useSessionStore( + (state) => state.sessions[serverId ?? ""]?.serverInfo?.features?.providersSnapshot === true, + ); 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 794e2ad656..9c0ff9d196 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 { getHostClient } from "@/runtime/host-runtime"; +import { useSessionStore } from "@/stores/session-store"; export type CreateScheduleInput = Omit; export type UpdateScheduleInput = Omit; @@ -52,7 +52,7 @@ export function updateAggregatedSchedulesData( } function requireClient(serverId: string, unavailableMessage: string): DaemonClient { - const client = getHostClient(serverId); + const client = useSessionStore.getState().sessions[serverId]?.client ?? null; 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 5d2cd52d19..e83d87c1a9 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 { - scheduleRuntime, + getHostRuntimeStore, useHostRuntimeConnectionStatuses, useHosts, } from "@/runtime/host-runtime"; @@ -35,6 +35,7 @@ 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], @@ -48,7 +49,7 @@ export function useSchedules(): UseSchedulesResult { const query = useFetchQuery({ queryKey: [...schedulesQueryKey(serverIds), connectionStatusKey], - queryFn: () => fetchAggregatedSchedules({ hosts: hostInputs, runtime: scheduleRuntime }), + queryFn: () => fetchAggregatedSchedules({ hosts: hostInputs, runtime }), 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 f9bc8e4908..7b88990812 100644 --- a/packages/app/src/hooks/use-sidebar-pins.ts +++ b/packages/app/src/hooks/use-sidebar-pins.ts @@ -1,10 +1,12 @@ 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 { useWorkspaceDirectories } from "@/stores/session-store-hooks"; +import { useSessionStore } from "@/stores/session-store"; export interface PinnedSidebarKeys { pinnedWorkspaceKeys: string[]; @@ -81,25 +83,30 @@ export function usePinnedSidebarKeys(projects: SidebarProjectEntry[]): PinnedSid ), [projects], ); - 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; - }, - arePinnedSidebarKeysEqual, + 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); + } + } + const nextKeys = buildPinnedSidebarKeys(projects, workspaceMapByServerId); + if (arePinnedSidebarKeysEqual(previousKeysRef.current, nextKeys)) { + return previousKeysRef.current; + } + previousKeysRef.current = nextKeys; + return nextKeys; + }, [projects, serverIds, workspaceMaps]); } // 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 b3ddb30cbb..c2eb65ac76 100644 --- a/packages/app/src/hooks/use-sidebar-workspace-entries.ts +++ b/packages/app/src/hooks/use-sidebar-workspace-entries.ts @@ -1,6 +1,7 @@ import { useMemo, useRef } from "react"; +import { useStoreWithEqualityFn } from "zustand/traditional"; import { useCreateFlowStore } from "@/stores/create-flow-store"; -import { useWorkspaceDirectories } from "@/stores/session-store-hooks"; +import { useSessionStore } from "@/stores/session-store"; import { areSidebarWorkspaceSessionsEqual, buildSidebarWorkspaceEntries, @@ -22,26 +23,10 @@ export function useSidebarWorkspaceEntries( () => Array.from(new Set(placements.map((placement) => placement.serverId))), [placements], ); - 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, + const sessions = useStoreWithEqualityFn( + useSessionStore, + (state) => + enabled ? selectSidebarWorkspaceSessions(state.sessions, 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 f131c62506..9edb09f8b5 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 { getHostClient } from "@/runtime/host-runtime"; +import { getHostRuntimeStore } 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 = getHostClient(workspace.serverId); + const client = getHostRuntimeStore().getClient(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 c177653e67..d8cc3383e5 100644 --- a/packages/app/src/hooks/use-sidebar-workspaces-list.ts +++ b/packages/app/src/hooks/use-sidebar-workspaces-list.ts @@ -1,18 +1,11 @@ import { useCallback, useEffect, useMemo } from "react"; import { useStoreWithEqualityFn } from "zustand/traditional"; import { useCreateFlowStore } from "@/stores/create-flow-store"; -import { - useWorkspaceDirectories, - useWorkspaceDirectoryServerIds, -} from "@/stores/session-store-hooks"; +import { useSessionStore } from "@/stores/session-store"; +import { useWorkspaceDirectoryServerIds } from "@/stores/session-store-hooks"; import { workspaceEqualityFns } from "@/stores/session-store-hooks/selectors"; import { useHostProjects } from "@/projects/host-projects"; -import { - acquireDirectoryDemand, - refreshHostDirectories, - useHostRegistryLoaded, - useHosts, -} from "@/runtime/host-runtime"; +import { getHostRuntimeStore, useHostRegistryLoaded, useHosts } from "@/runtime/host-runtime"; import { useSidebarOrderStore } from "@/stores/sidebar-order-store"; import { useSidebarViewStore } from "@/stores/sidebar-view-store"; import { @@ -71,30 +64,19 @@ export function useSidebarProjectStatusBucket(input: { workspaceEqualityFns.deep, ); - const serverIds = useMemo( - () => Array.from(new Set(workspaces.map((workspace) => workspace.serverId))), - [workspaces], - ); - return useWorkspaceDirectories( - serverIds, - (directories) => { + const selector = useCallback( + (state: { sessions: Record }) => { if (!enabled) return null; - 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 }); + return deriveProjectStatusBucket({ + workspaces, + sessions: state.sessions, + pendingCreateAttempts, + }); }, - Object.is, + [enabled, pendingCreateAttempts, workspaces], ); + + return useStoreWithEqualityFn(useSessionStore, selector, Object.is); } const EMPTY_ORDER: string[] = []; @@ -116,6 +98,7 @@ 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]); @@ -140,9 +123,9 @@ export function useSidebarWorkspacesList(options?: { }, [allServerIds, hostFilters, hostRegistryLoaded]); useEffect(() => { if (!isActive) return; - const releases = serverIds.map(acquireDirectoryDemand); + const releases = serverIds.map((serverId) => runtime.acquireDirectoryDemand(serverId)); return () => releases.forEach((release) => release()); - }, [isActive, serverIds]); + }, [isActive, runtime, serverIds]); useEffect(() => { if (!hostRegistryLoaded) { @@ -193,14 +176,14 @@ export function useSidebarWorkspacesList(options?: { const refreshAll = useCallback(() => { if (!isActive) return; for (const serverId of serverIds) { - void refreshHostDirectories(serverId).catch((error) => { + void runtime.refreshDirectories(serverId).catch((error) => { console.error("[WorkspaceFetch][sidebar-refresh] failed", { serverId, error, }); }); } - }, [isActive, serverIds]); + }, [isActive, runtime, 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 ce97820aec..6fbceb8a21 100644 --- a/packages/app/src/navigation/host-runtime-bootstrap.ts +++ b/packages/app/src/navigation/host-runtime-bootstrap.ts @@ -10,8 +10,6 @@ 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; @@ -39,14 +37,6 @@ 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 f6e78c2ec8..c2129c654c 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -1,3 +1,4 @@ +import { getHostRuntimeStore } from "@/runtime/host-runtime"; import { Button } from "@/components/ui/button"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; @@ -18,7 +19,8 @@ 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 { useShallow } from "zustand/shallow"; +import { shallow, useShallow } from "zustand/shallow"; +import { useStoreWithEqualityFn } from "zustand/traditional"; import { AgentStreamView, type AgentStreamViewHandle } from "@/agent-stream/view"; import { ArchivedAgentCallout } from "@/components/archived-agent-callout"; import { KeyboardDock } from "@/components/keyboard-dock"; @@ -77,12 +79,6 @@ import { useHostRuntimeLastError, useHosts, } from "@/runtime/host-runtime"; -import { - acceptAgentSnapshot, - getAgentSnapshot, - publishAgentDetails, - replaceAgentPendingPermissions, -} from "@/runtime/session-data"; import { deriveRouteBottomAnchorIntent, deriveRouteBottomAnchorRequest, @@ -92,19 +88,18 @@ import { AgentTracks, hasAgentTracks } from "@/panels/agent-tracks"; import { useCreateFlowStore } from "@/stores/create-flow-store"; import { buildDraftStoreKey, generateDraftId } from "@/stores/draft-keys"; import { - useAgentFields, - useAgentConversation, - useAgentTurn, - useConnection, + selectAgentTimelineState, + selectAgentTurnPresentation, type Agent, -} from "@/stores/session-store-hooks"; + useSessionStore, +} from "@/stores/session-store"; 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 } from "@/types/stream"; +import type { StreamItem, TodoEntry } from "@/types/stream"; import type { ViewedTimelineStatus, ViewedTimelineUiBridge } from "@/timeline/viewed-timeline-sync"; import { useArchiveFinishedSubagents, useSubagentsForParent } from "@/subagents"; import { getInitDeferred, getInitKey } from "@/utils/agent-initialization"; @@ -140,6 +135,16 @@ 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; @@ -160,7 +165,12 @@ const EMPTY_CHAT_AGENT_STATE: ChatAgentSelectedState = { attentionReason: null, }; -function selectChatAgentState(agent: Agent | null): ChatAgentSelectedState { +function selectChatAgentState( + state: ReturnType, + serverId: string, + agentId: string | undefined, +): ChatAgentSelectedState { + const agent = resolveChatAgentFromSession(state, serverId, agentId); if (!agent) return EMPTY_CHAT_AGENT_STATE; return { serverId: agent.serverId, @@ -182,26 +192,6 @@ function selectChatAgentState(agent: Agent | null): ChatAgentSelectedState { }; } -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, @@ -318,24 +308,31 @@ function storeFetchedAgentDetail(input: { projectPlacement: input.result.project, }, }); + const store = useSessionStore.getState(); + if (shouldStoreFetchedAgentInActiveDirectory(hydrated)) { - acceptAgentSnapshot(input.serverId, hydrated); + getHostRuntimeStore().acceptAgentSnapshot(input.serverId, hydrated); } else { - publishAgentDetails(input.serverId, (previous) => { + store.setAgentDetails(input.serverId, (previous) => { const next = new Map(previous); next.set(hydrated.id, hydrated); return next; }); } - replaceAgentPendingPermissions( - input.serverId, - hydrated.id, - hydrated.pendingPermissions.map((request) => { + 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) { const key = derivePendingPermissionKey(hydrated.id, request); - return { key, agentId: hydrated.id, request }; - }), - ); + next.set(key, { key, agentId: hydrated.id, request }); + } + return next; + }); return hydrated; } @@ -344,17 +341,24 @@ function useAgentPanelDescriptor( target: { kind: "agent"; agentId: string }, context: { serverId: string }, ): PanelDescriptor { - 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 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 icon = getProviderIcon(provider, context.serverId); return { @@ -363,9 +367,9 @@ function useAgentPanelDescriptor( tooltip: label ?? `${formatProviderLabel(provider)} agent`, titleState: label ? "ready" : "loading", icon, - statusBucket: descriptorState?.status + statusBucket: descriptorState.status ? deriveSidebarStateBucket({ - status: isTurnActive ? "running" : descriptorState.status, + status: descriptorState.isTurnActive ? "running" : descriptorState.status, pendingPermissionCount: descriptorState.pendingPermissionCount, requiresAttention: descriptorState.requiresAttention, attentionReason: descriptorState.attentionReason, @@ -407,7 +411,7 @@ function DraftPanel() { (agentSnapshot: Parameters[0]) => { const normalized = normalizeAgentSnapshot(agentSnapshot, serverId); const agent = applyLegacyDaemonWorkspaceOwnership({ serverId, agent: normalized }); - acceptAgentSnapshot(serverId, agent); + getHostRuntimeStore().acceptAgentSnapshot(serverId, agent); retargetCurrentTab({ kind: "agent", agentId: agentSnapshot.id }); }, [retargetCurrentTab, serverId], @@ -532,14 +536,13 @@ function AgentPanelContent({ const runtimeIsConnected = useHostRuntimeIsConnected(runtimeServerId); const runtimeConnectionStatus = useHostRuntimeConnectionStatus(runtimeServerId); const runtimeLastError = useHostRuntimeLastError(runtimeServerId); - const hasCachedAgent = Boolean( - useAgentFields( - resolvedServerId ?? null, - resolvedAgentId ?? null, - (agent) => agent.id, - Object.is, - ), - ); + 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 connectionServerId = resolvedServerId ?? null; const daemon = connectionServerId @@ -601,13 +604,25 @@ function AgentPanelBody({ }) { const { t } = useTranslation(); const { isArchivingAgent: _isArchivingAgent } = useArchiveAgent(); - 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 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 [lookupState, setLookupState] = useState({ tag: "idle" }); const lookupAttemptTokenRef = useRef(0); const retryAgentLookup = useCallback(() => setLookupState({ tag: "idle" }), []); @@ -782,22 +797,42 @@ function ChatAgentContent({ routeKey: string; reason: "initial-entry" | "resume"; } | null>(null); - const { agentState, projectPlacement } = resolveReadyAgent( - useAgentFields(serverId, agentId, selectReadyAgent), + 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 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 = conversation.agentHistorySyncGeneration; - const viewedTimelineSync = conversation.viewedTimelineSync; + const agentHistorySyncGeneration = useSessionStore((state) => + agentId ? (state.sessions[serverId]?.agentHistorySyncGeneration?.get(agentId) ?? -1) : -1, + ); + const viewedTimelineSync = useSessionStore( + (state) => state.sessions[serverId]?.viewedTimelineSync ?? null, + ); const subscribeToVisibilityCatchUp = useCallback( (listener: () => void) => viewedTimelineSync?.subscribe(listener) ?? (() => {}), [viewedTimelineSync], @@ -823,7 +858,7 @@ function ChatAgentContent({ const hasActiveCreateHandoff = useCreateFlowStore((state) => findActiveCreateHandoff({ pendingByDraftId: state.pendingByDraftId, serverId, agentId }), ); - const hasSession = useConnection(serverId, (connection) => connection.exists); + const hasSession = useSessionStore((state) => Boolean(state.sessions[serverId])); const [missingAgentState, setMissingAgentState] = useState({ kind: "idle", }); @@ -1075,7 +1110,9 @@ function ChatAgentContent({ if (attemptToken !== initAttemptTokenRef.current) { return; } - const currentAgent = getAgentSnapshot(serverId, agentId); + const currentSession = useSessionStore.getState().sessions[serverId]; + const currentAgent = + currentSession?.agents.get(agentId) ?? currentSession?.agentDetails.get(agentId); if (!currentAgent) { const result = await client.fetchAgent({ agentId }); if (attemptToken !== initAttemptTokenRef.current) { @@ -1226,7 +1263,9 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({ }) { const { t } = useTranslation(); const subagentRows = useSubagentsForParent({ serverId, parentAgentId: agentId }); - const tasks = useAgentConversation(serverId, agentId, (value) => value.tasks, Object.is); + const tasks = useSessionStore((state): TodoEntry[] | undefined => + state.sessions[serverId]?.agentTasks.get(agentId), + ); const archiveFinishedSubagents = useArchiveFinishedSubagents({ serverId, parentAgentId: agentId, @@ -1439,21 +1478,44 @@ const AgentStreamSection = memo(function AgentStreamSection({ const bottomOverlayControlClearance = hasVisibleComposerTracks ? resolveComposerTrackControlClearance(isCompactFormFactor) : 0; - 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 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 streamItems = streamItemsRaw ?? EMPTY_STREAM_ITEMS; - const pendingPermissionList = agentId - ? conversation.pendingPermissions - : EMPTY_PENDING_PERMISSION_LIST; + 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 pendingPermissions = useMemo(() => { if (pendingPermissionList.length === 0) { return EMPTY_PENDING_PERMISSIONS; @@ -1596,7 +1658,7 @@ function ActiveAgentComposer({ const handleClientSlashCommand = useCallback( async (command: ClientSlashCommand) => { - const agent = getAgentSnapshot(serverId, agentId); + const agent = resolveChatAgentFromSession(useSessionStore.getState(), 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 2a918ea222..962e095b89 100644 --- a/packages/app/src/panels/agent-tracks.tsx +++ b/packages/app/src/panels/agent-tracks.tsx @@ -7,8 +7,7 @@ import { supportsDesktopPaneSplits, useIsCompactFormFactor } from "@/constants/l import { usePaneContext } from "@/panels/pane-context"; import { useSettings } from "@/hooks/use-settings"; import { PluginComposerPills } from "@/plugins"; -import { useServerFeature } from "@/stores/session-store-hooks"; -import { getAgentSnapshot } from "@/runtime/session-data"; +import { useSessionStore } from "@/stores/session-store"; import { type ArchiveFinishedStatus, useArchiveSubagent, @@ -56,12 +55,15 @@ export const AgentTracks = memo(function AgentTracks({ const canSplit = supportsDesktopPaneSplits() && !isCompact; const openInSidePane = useSettings((settings) => settings.openInSidePane); const workspaceKey = buildWorkspaceTabPersistenceKey({ serverId, workspaceId }); - const canDetachSubagents = useServerFeature(serverId, "agentDetach"); + const canDetachSubagents = useSessionStore( + (state) => state.sessions[serverId]?.serverInfo?.features?.agentDetach === true, + ); const archiveSubagent = useArchiveSubagent({ serverId }); const detachSubagent = useDetachSubagent({ serverId }); const handleOpenSubagent = useCallback( (subagentId: string) => { - const agent = getAgentSnapshot(serverId, subagentId); + const session = useSessionStore.getState().sessions[serverId]; + const agent = session?.agents.get(subagentId) ?? session?.agentDetails.get(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 c93fd70032..80323561ba 100644 --- a/packages/app/src/panels/provider-subagent-panel.tsx +++ b/packages/app/src/panels/provider-subagent-panel.tsx @@ -14,8 +14,7 @@ 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 { useActiveAgentFields, useAgentFields, useConnection } from "@/stores/session-store-hooks"; -import { useHostRuntimeClient } from "@/runtime/host-runtime"; +import { useSessionStore } from "@/stores/session-store"; import { useSubagentsForParent } from "@/subagents/select"; import { SubagentsTrack } from "@/subagents/track"; import { @@ -83,11 +82,8 @@ function useProviderSubagentDescriptor( providerSubagentKey(context.serverId, target.parentAgentId, target.subagentId), ), ); - const parentProvider = useActiveAgentFields( - context.serverId, - target.parentAgentId, - (agent) => agent.provider, - Object.is, + const parentProvider = useSessionStore( + (state) => state.sessions[context.serverId]?.agents.get(target.parentAgentId)?.provider, ); const provider = descriptor?.provider ?? parentProvider ?? "agent"; // The task names the tab; the subagent type is supporting detail beside the provider. @@ -122,14 +118,14 @@ function ProviderSubagentPanel() { timeline: state.timelines.get(key) ?? 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); + 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); // 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 0953b96215..32dd1f188d 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 = useHostRuntimeClient(context.serverId); + const client = useSessionStore((state) => state.sessions[context.serverId]?.client ?? null); 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, (workspace) => ({ - workspaceDirectory: workspace.workspaceDirectory, - isGitCheckout: workspace.projectKind === "git", + const workspaceFields = useWorkspaceFields(serverId, workspaceId, (w) => ({ + workspaceDirectory: w.workspaceDirectory, + isGitCheckout: w.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 5d40d2bdf7..3647334f73 100644 --- a/packages/app/src/plugins/catalog-sync.tsx +++ b/packages/app/src/plugins/catalog-sync.tsx @@ -11,7 +11,6 @@ export function PluginCatalogSync({ serverId: string; client: DaemonClient; }) { - const viewedTimeline = useViewedTimelineSync(serverId); const connected = useHostRuntimeIsConnected(serverId); const supported = useHostFeature(serverId, "plugins"); @@ -59,7 +58,7 @@ export function PluginCatalogSync({ cancelled = true; unsubscribe(); }; - }, [client, connected, serverId, supported, viewedTimeline]); + }, [client, connected, serverId, supported]); useEffect(() => () => pluginRegistry.removeHost(serverId), [serverId]); return null; diff --git a/packages/app/src/plugins/command-center/registration.tsx b/packages/app/src/plugins/command-center/registration.tsx index 0248a07a46..2ee0d24ff2 100644 --- a/packages/app/src/plugins/command-center/registration.tsx +++ b/packages/app/src/plugins/command-center/registration.tsx @@ -4,8 +4,10 @@ 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 { useAgentBelongsToWorkspace, useWorkspaceExists } from "@/stores/session-store-hooks"; +import { useSessionStore } from "@/stores/session-store"; +import { 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"; @@ -25,7 +27,17 @@ export function PluginCommandCenterActions() { workspaceKey ? (state.layoutByWorkspace[workspaceKey] ?? null) : null, ); const focusedAgentId = getFocusedAgentId(layout); - const agentExists = useAgentBelongsToWorkspace(serverId, focusedAgentId, workspaceId); + 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 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 8d31d6cb3d..2acca92bd2 100644 --- a/packages/app/src/plugins/workspace-panels/panel.tsx +++ b/packages/app/src/plugins/workspace-panels/panel.tsx @@ -14,8 +14,10 @@ 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 { useAgentBelongsToWorkspace, useWorkspaceExists } from "@/stores/session-store-hooks"; +import { useSessionStore } from "@/stores/session-store"; +import { 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"; @@ -42,11 +44,15 @@ function PluginPanelBody({ theme }: { theme: PluginTheme }) { const plugin = useInstalledPlugin(serverId, target.pluginId); const contribution = resolvePluginWorkspacePanel(plugin, target); const workspaceExists = useWorkspaceExists(serverId, workspaceId); - const agentExists = useAgentBelongsToWorkspace( - serverId, - target.context === "agent" ? target.agentId : null, - 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 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 2b1042ff86..ee52b2b57f 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 { - getHostClient, - useHostRuntimeConnectionStatuses, + getHostRuntimeStore, + isHostRuntimeConnected, useHostRuntimeClient, useHostRuntimeIsConnected, } from "@/runtime/host-runtime"; @@ -80,7 +80,6 @@ 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) { @@ -96,8 +95,8 @@ export function useProjectIcons(input: { ...projectIconCache.query( request, supportsCustomIcons.get(request.serverId) ?? null, - () => getHostClient(request.serverId), - connectionStatuses.get(request.serverId) === "online", + () => getHostRuntimeStore().getClient(request.serverId), + isHostRuntimeConnected(getHostRuntimeStore().getSnapshot(request.serverId)), ), select: iconDataUri, }; diff --git a/packages/app/src/projects/project-remove.ts b/packages/app/src/projects/project-remove.ts index 92744eb2ff..f80b068cf0 100644 --- a/packages/app/src/projects/project-remove.ts +++ b/packages/app/src/projects/project-remove.ts @@ -1,5 +1,6 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; -import { hostSupports } from "@/runtime/session-data"; +import { useSessionStore } from "@/stores/session-store"; +import { selectHostFeature } from "@/runtime/host-features"; interface ProjectRemoveHost { serverId: string; @@ -54,9 +55,10 @@ export function getProjectRemoveReadiness(input: { export function getCurrentProjectRemoveReadiness( project: ProjectRemoveProject, ): ProjectRemoveReadiness { + const sessionState = useSessionStore.getState(); return getProjectRemoveReadiness({ project, - supportsProjectRemove: (serverId) => hostSupports(serverId, "projectRemove"), + supportsProjectRemove: (serverId) => selectHostFeature(sessionState, 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 da5bad29cf..ce133726c3 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 { useServerFeature } from "@/stores/session-store-hooks"; +import { useSessionStore } from "@/stores/session-store"; import { providerUsageCopy } from "./copy"; import type { ProviderUsageListPayload, ProviderUsageView } from "./types"; @@ -33,7 +33,9 @@ export function useProviderUsage( const queryClient = useQueryClient(); const client = useHostRuntimeClient(serverId ?? ""); const isConnected = useHostRuntimeIsConnected(serverId ?? ""); - const supportsProviderUsage = useServerFeature(serverId, "providerUsageList"); + const supportsProviderUsage = useSessionStore( + (state) => state.sessions[serverId ?? ""]?.serverInfo?.features?.providerUsageList === true, + ); 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 deleted file mode 100644 index 73344e3fad..0000000000 --- a/packages/app/src/runtime/client-replica/index.ts +++ /dev/null @@ -1,85 +0,0 @@ -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/daemon-start-service.ts b/packages/app/src/runtime/daemon-start-service.ts index f916a8c81c..093158e9ea 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 { getHostRuntimeStore, type HostRuntimeStore } from "@/runtime/host-runtime"; +import type { HostRuntimeStore } from "@/runtime/host-runtime"; export type DaemonStartResult = { ok: true } | { ok: false; error: string }; export type DaemonStartCondition = boolean | (() => boolean | Promise); @@ -46,12 +46,6 @@ 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; @@ -172,7 +166,3 @@ 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 489de68d3f..d7deff40a7 100644 --- a/packages/app/src/runtime/directory-sync/agent-replica.test.ts +++ b/packages/app/src/runtime/directory-sync/agent-replica.test.ts @@ -3,7 +3,7 @@ import type { DaemonClient, FetchAgentsEntry } from "@getpaseo/client/internal/d import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages"; import { selectAgentTurnPresentation, useSessionStore } from "@/stores/session-store"; import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; -import type { DirectoryReplicaMutation } from "@/runtime/client-replica"; +import type { DirectoryReplicaMutation } from "@/runtime/replica-cache"; import { AgentDirectoryReplica } from "./agent-replica"; function payload(title: string): AgentSnapshotPayload { diff --git a/packages/app/src/runtime/directory-sync/agent-replica.ts b/packages/app/src/runtime/directory-sync/agent-replica.ts index 5ddb4dce3e..1bdf3ea3de 100644 --- a/packages/app/src/runtime/directory-sync/agent-replica.ts +++ b/packages/app/src/runtime/directory-sync/agent-replica.ts @@ -7,7 +7,7 @@ import { normalizeAgentSnapshot, projectAgentSnapshot } from "@/utils/agent-snap 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 { DirectoryReplicaMutation } from "@/runtime/replica-cache"; import type { TurnLivenessTransition } from "@/timeline/turn-liveness"; import { AgentStoreProjection } from "./internal/agent-store"; diff --git a/packages/app/src/runtime/directory-sync/index.test.ts b/packages/app/src/runtime/directory-sync/index.test.ts index 5fca8262d3..1593c4752f 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 { DirectoryReplicaMutation } from "@/runtime/client-replica"; +import type { DirectoryReplicaMutation } from "@/runtime/replica-cache"; import { DirectoryRefreshSupersededError, DirectorySync, diff --git a/packages/app/src/runtime/directory-sync/index.ts b/packages/app/src/runtime/directory-sync/index.ts index 4c663cf025..abcc98d5a7 100644 --- a/packages/app/src/runtime/directory-sync/index.ts +++ b/packages/app/src/runtime/directory-sync/index.ts @@ -37,7 +37,7 @@ import type { DirectoryCheckpoint, DirectoryCursor, DirectoryReplicaMutation, -} from "@/runtime/client-replica"; +} from "@/runtime/replica-cache"; import type { TurnLivenessTransition } from "@/timeline/turn-liveness"; const PAGE_LIMIT = 200; diff --git a/packages/app/src/runtime/directory-sync/workspace-replica.ts b/packages/app/src/runtime/directory-sync/workspace-replica.ts index 4d35c765db..d229c196a2 100644 --- a/packages/app/src/runtime/directory-sync/workspace-replica.ts +++ b/packages/app/src/runtime/directory-sync/workspace-replica.ts @@ -16,7 +16,7 @@ import { shouldSuppressWorkspaceForLocalArchive, } from "@/contexts/session-workspace-upserts"; import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity"; -import type { DirectoryReplicaMutation } from "@/runtime/client-replica"; +import type { DirectoryReplicaMutation } from "@/runtime/replica-cache"; export type WorkspaceDirectoryDelta = | Extract["payload"] diff --git a/packages/app/src/runtime/host-runtime.test.ts b/packages/app/src/runtime/host-runtime.test.ts index 26acf6f22b..b77d53f646 100644 --- a/packages/app/src/runtime/host-runtime.test.ts +++ b/packages/app/src/runtime/host-runtime.test.ts @@ -21,8 +21,7 @@ import { type HostRuntimeControllerDeps, type HostRuntimeStorage, } from "./host-runtime"; -import type { ClientReplica } from "./client-replica"; -import { drainQueuedAgentMessage } from "./session-data"; +import type { ReplicaRow, ReplicaRowStore } from "./replica-cache/row-store"; class FakeDaemonClient { private state: ConnectionState = { status: "idle" }; @@ -461,6 +460,50 @@ 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, @@ -1493,13 +1536,13 @@ describe("HostRuntimeStore", () => { }); await waitForHostOnline(store, hostA.serverId); await waitForHostOnline(store, hostB.serverId); - store.setAppVisible(false, null); + store.setAppVisible(false); 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); + store.setAppVisible(true); expect(clientA.reconnectEnabledChanges.at(-1)).toBe(true); expect(clientB.reconnectEnabledChanges.at(-1)).toBe(true); expect(clientA.ensureConnectedCalls).toBe(1); @@ -1560,25 +1603,12 @@ describe("HostRuntimeStore", () => { const host = makeHost(); const storage = createMemoryHostRuntimeStorage(); let fullScans = 0; - 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, + const backingStore = createMemoryReplicaRowStore(); + const replicaRowStore: ReplicaRowStore = { + ...backingStore, + read: async (...args) => { + fullScans += 1; + return backingStore.read(...args); }, }; await storage.setItem("@paseo:daemon-registry", JSON.stringify([host])); @@ -1587,7 +1617,7 @@ describe("HostRuntimeStore", () => { const store = new HostRuntimeStore({ storage, - clientReplica, + replicaRowStore, deps: { createClient: () => { throw new Error("createClient should not be called"); @@ -1602,7 +1632,7 @@ describe("HostRuntimeStore", () => { store.boot(); await registryLoaded; - expect(fullScans).toBe(1); + await vi.waitFor(() => expect(fullScans).toBe(1)); expect(useSessionStore.getState().sessions[host.serverId]).toMatchObject({ client: null, hasHydratedAgents: false, @@ -2714,7 +2744,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 () => ({ @@ -2755,7 +2785,7 @@ describe("HostRuntimeStore", () => { ]), ); - drainQueuedAgentMessage(host.serverId, "agent"); + store.drainQueuedAgentMessage(host.serverId, "agent"); await fakeClient.waitForSentMessages(1); // The row and the pending submission must exist while the RPC is still in flight — @@ -2779,7 +2809,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 () => ({ @@ -2805,7 +2835,7 @@ describe("HostRuntimeStore", () => { ]), ); - drainQueuedAgentMessage(host.serverId, "agent"); + store.drainQueuedAgentMessage(host.serverId, "agent"); await vi.waitFor(() => { expect(fakeClient.sentAgentMessages).toHaveLength(1); @@ -2825,7 +2855,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 () => ({ @@ -2843,8 +2873,8 @@ describe("HostRuntimeStore", () => { new Map([["agent", [{ id: "first", text: "send once", attachments: [] }]]]), ); - drainQueuedAgentMessage(host.serverId, "agent"); - drainQueuedAgentMessage(host.serverId, "agent"); + store.drainQueuedAgentMessage(host.serverId, "agent"); + store.drainQueuedAgentMessage(host.serverId, "agent"); await fakeClient.waitForSentMessages(1); expect(fakeClient.sentAgentMessages).toHaveLength(1); @@ -2860,7 +2890,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 () => ({ @@ -2910,7 +2940,7 @@ describe("HostRuntimeStore", () => { ]), ); - drainQueuedAgentMessage(host.serverId, "agent"); + store.drainQueuedAgentMessage(host.serverId, "agent"); await fakeClient.waitForSentMessages(1); expect(fakeClient.sentAgentMessages[0]?.[2]?.attachments).toEqual([ diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index d8f7f7f0fc..20fb842e06 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -45,7 +45,13 @@ import { import { getDesktopHost } from "@/desktop/host"; import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities"; import { BROWSER_AUTOMATION_COMMAND_NAMES } from "@getpaseo/protocol/browser-automation/rpc-schemas"; -import { useSessionStore } from "@/stores/session-store"; +import { + useSessionStore, + type Agent, + type WorkspaceDescriptor, + type ProjectDescriptor, +} from "@/stores/session-store"; +import type { TurnLivenessTransition } from "@/timeline/turn-liveness"; import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store"; import { invalidateCheckoutGitQueriesForServer } from "@/git/query-keys"; import { queryClient } from "@/data/query-client"; @@ -55,9 +61,14 @@ 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 { createClientReplica, type ClientReplica } from "@/runtime/client-replica"; -import { registerDefaultSessionDataOwner, SessionDataOwner } from "@/runtime/session-data"; +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 { createTimelineReplica, createViewedTimelineOwner, @@ -1388,27 +1399,26 @@ export class HostRuntimeStore { private deps: HostRuntimeControllerDeps; private lastConnectionStatusByServer = new Map(); private connectionStatusStartedAtByServer = new Map(); - private readonly sessionData: SessionDataOwner; + private queuedAgentDrainInFlight = new Set(); + private directorySyncByServer = new Map(); + private nextCancellationRequestId = 0; private timelineReplicaByServer = new Map(); private configuredOverrideBootstrapInFlight: Promise | null = null; private bootPromise: Promise | null = null; private appVisible = true; private storage: HostRuntimeStorage; - private clientReplica: ClientReplica; + private replicaCache: ReplicaCache; private readonly revokePushNotifications: typeof revokePushNotifications; constructor(input?: { deps?: HostRuntimeControllerDeps; storage?: HostRuntimeStorage; - clientReplica?: ClientReplica; - sessionData?: SessionDataOwner; + replicaRowStore?: ReplicaRowStore; revokePushNotifications?: typeof revokePushNotifications; }) { this.deps = input?.deps ?? createDefaultDeps(); this.storage = input?.storage ?? AsyncStorage; - this.clientReplica = input?.clientReplica ?? createClientReplica(); - this.sessionData = input?.sessionData ?? new SessionDataOwner(); - registerDefaultSessionDataOwner(this.sessionData); + this.replicaCache = new ReplicaCache(input?.replicaRowStore ?? createReplicaRowStore()); this.revokePushNotifications = input?.revokePushNotifications ?? revokePushNotifications; } @@ -1506,13 +1516,13 @@ export class HostRuntimeStore { } } this.hosts = profiles; - this.clientReplica.hostLifecycle.setHosts(profiles.map((profile) => profile.serverId)); + this.replicaCache.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) + void this.directorySyncByServer + .get(profile.serverId) ?.restoreCachedDirectory() .catch(() => undefined); } @@ -1648,27 +1658,27 @@ export class HostRuntimeStore { rekeyMap(this.controllers, oldServerId, newServerId); rekeyMap(this.lastConnectionStatusByServer, oldServerId, newServerId); rekeyMap(this.connectionStatusStartedAtByServer, oldServerId, newServerId); - this.clientReplica.hostLifecycle.reconcileServerId(oldServerId, newServerId); + this.replicaCache.reconcileServerId(oldServerId, newServerId); projectIconCache.reconcileServerId(oldServerId, newServerId); - this.sessionData.removeDirectory(oldServerId); + this.directorySyncByServer.get(oldServerId)?.dispose(); + this.directorySyncByServer.delete(oldServerId); this.timelineReplicaByServer.delete(oldServerId); const directory = new DirectorySync( newServerId, { - onAgentStoppedRunning: (agentId) => - this.sessionData.drainQueuedAgentMessage(newServerId, agentId), + onAgentStoppedRunning: (agentId) => this.drainQueuedAgentMessage(newServerId, agentId), markAgentLoading: () => controller.markAgentDirectorySyncLoading(), markAgentReady: () => controller.markAgentDirectorySyncReady(), markAgentError: (error) => controller.markAgentDirectorySyncError(error), }, - this.clientReplica.directory, + this.replicaCache, ); - this.sessionData.registerDirectory(newServerId, directory); + this.directorySyncByServer.set(newServerId, directory); this.timelineReplicaByServer.set( newServerId, createTimelineReplica({ serverId: newServerId, - storage: this.clientReplica.timeline, + storage: this.replicaCache, prepareAgent: (agentId) => directory.prepareAgentRoute(agentId), }), ); @@ -2034,7 +2044,7 @@ export class HostRuntimeStore { >; }, ): void { - this.clientReplica.hostLifecycle.setHosts(hosts.map((host) => host.serverId)); + this.replicaCache.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) { @@ -2044,7 +2054,8 @@ export class HostRuntimeStore { this.controllers.delete(serverId); this.lastConnectionStatusByServer.delete(serverId); this.connectionStatusStartedAtByServer.delete(serverId); - this.sessionData.removeDirectory(serverId); + this.directorySyncByServer.get(serverId)?.dispose(); + this.directorySyncByServer.delete(serverId); this.timelineReplicaByServer.delete(serverId); this.clearHostReplica(serverId); void controller.stop(); @@ -2074,20 +2085,19 @@ export class HostRuntimeStore { const directory = new DirectorySync( host.serverId, { - onAgentStoppedRunning: (agentId) => - this.sessionData.drainQueuedAgentMessage(host.serverId, agentId), + onAgentStoppedRunning: (agentId) => this.drainQueuedAgentMessage(host.serverId, agentId), markAgentLoading: () => controller.markAgentDirectorySyncLoading(), markAgentReady: () => controller.markAgentDirectorySyncReady(), markAgentError: (error) => controller.markAgentDirectorySyncError(error), }, - this.clientReplica.directory, + this.replicaCache, ); - this.sessionData.registerDirectory(host.serverId, directory); + this.directorySyncByServer.set(host.serverId, directory); this.timelineReplicaByServer.set( host.serverId, createTimelineReplica({ serverId: host.serverId, - storage: this.clientReplica.timeline, + storage: this.replicaCache, prepareAgent: (agentId) => directory.prepareAgentRoute(agentId), }), ); @@ -2138,7 +2148,7 @@ export class HostRuntimeStore { return; } const snapshot = controller.getSnapshot(); - const directory = this.sessionData.directory(serverId); + const directory = this.directorySyncByServer.get(serverId); directory?.connectionChanged({ client: snapshot.client, status: snapshot.connectionStatus === "online" ? "online" : "offline", @@ -2170,6 +2180,107 @@ 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); + }); + } + + applyAgentTurnLiveness( + serverId: string, + agentId: string, + transition: TurnLivenessTransition | readonly TurnLivenessTransition[], + ): void { + this.directorySyncByServer.get(serverId)?.applyAgentTurnLiveness(agentId, transition); + } + + beginAgentCancellation(serverId: string, agentId: string): number { + const requestId = ++this.nextCancellationRequestId; + this.applyAgentTurnLiveness(serverId, agentId, { type: "cancellation_started", requestId }); + return requestId; + } + + settleAgentCancellation(serverId: string, agentId: string, requestId: number): void { + this.applyAgentTurnLiveness(serverId, agentId, { type: "cancellation_settled", requestId }); + } + + acceptAgentSnapshot(serverId: string, agent: Agent): Agent { + return this.requireDirectory(serverId).acceptAgent(agent); + } + + archiveAgentSnapshot(serverId: string, agentId: string, archivedAt: string): void { + this.requireDirectory(serverId).archiveAgent(agentId, archivedAt); + } + + restoreAgentSnapshot(serverId: string, agentId: string, agent: Agent | undefined): void { + const directory = this.requireDirectory(serverId); + if (agent) directory.acceptAgent(agent); + else directory.removeAgent(agentId); + } + + acceptWorkspaceSnapshots(serverId: string, workspaces: readonly WorkspaceDescriptor[]): void { + this.requireDirectory(serverId).acceptWorkspaces(workspaces); + } + + acceptProjectSnapshot(serverId: string, project: ProjectDescriptor): void { + this.requireDirectory(serverId).acceptProject(project); + } + + removeWorkspaceSnapshot(serverId: string, workspaceId: string): void { + this.requireDirectory(serverId).removeWorkspace(workspaceId); + } + + private requireDirectory(serverId: string): DirectorySync { + const directory = this.directorySyncByServer.get(serverId); + if (!directory) throw new Error(`Unknown host runtime for serverId ${serverId}`); + return directory; + } + getSnapshot(serverId: string): HostRuntimeSnapshot | null { return this.controllers.get(serverId)?.getSnapshot() ?? null; } @@ -2229,14 +2340,13 @@ export class HostRuntimeStore { } } - setAppVisible(visible: boolean, preferredServerId: string | null): void { - void preferredServerId; + setAppVisible(visible: boolean): void { this.appVisible = visible; for (const controller of this.controllers.values()) { controller.setReconnectEnabled(visible); } if (!visible) { - void this.clientReplica.hostLifecycle.flush(); + void this.replicaCache.flush(); return; } @@ -2255,25 +2365,25 @@ export class HostRuntimeStore { async refreshAgentDirectory( input: AgentDirectoryRefreshInput, ): Promise { - const directory = this.sessionData.directory(input.serverId); + const directory = this.directorySyncByServer.get(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.sessionData.directory(input.serverId); + const directory = this.directorySyncByServer.get(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.sessionData.directory(serverId); + const directory = this.directorySyncByServer.get(serverId); if (!directory) throw new Error(`Unknown host runtime for serverId ${serverId}`); await directory.refreshDemand(); } acquireDirectoryDemand(serverId: string): () => void { - const directory = this.sessionData.directory(serverId); + const directory = this.directorySyncByServer.get(serverId); if (!directory) return () => undefined; const source = {}; directory.setDemand(source, true); @@ -2281,13 +2391,13 @@ export class HostRuntimeStore { } async prepareAgentRoute(serverId: string, agentId: string): Promise { - const directory = this.sessionData.directory(serverId); + const directory = this.directorySyncByServer.get(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.sessionData.directory(serverId); + const directory = this.directorySyncByServer.get(serverId); if (!directory) throw new Error(`Unknown host runtime for serverId ${serverId}`); await directory.prepareWorkspaceRoute(workspaceId); } @@ -2303,7 +2413,7 @@ export class HostRuntimeStore { agentId: string, request: Parameters[1], ): Promise>> { - const directory = this.sessionData.directory(serverId); + const directory = this.directorySyncByServer.get(serverId); if (!directory) throw new Error(`Unknown host runtime for serverId ${serverId}`); return directory.fetchTimeline(agentId, request); } @@ -2312,7 +2422,7 @@ export class HostRuntimeStore { serverId: string, ports: ViewedTimelineOwnerPorts, ): ViewedTimelineOwner { - const directory = this.sessionData.directory(serverId); + const directory = this.directorySyncByServer.get(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}`); @@ -2320,8 +2430,7 @@ export class HostRuntimeStore { serverId, replica, replaceDemandedAgentIds: (agentIds) => directory.setAgentRouteDemand(agentIds), - drainQueuedAgentMessage: (agentId) => - this.sessionData.drainQueuedAgentMessage(serverId, agentId), + drainQueuedAgentMessage: (agentId) => this.drainQueuedAgentMessage(serverId, agentId), ports, }); } @@ -2346,7 +2455,6 @@ 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) { @@ -2355,19 +2463,12 @@ 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; } - const sessionData = new SessionDataOwner(); - registerDefaultSessionDataOwner(sessionData); - singletonHostRuntimeStore = new HostRuntimeStore({ sessionData }); + singletonHostRuntimeStore = new HostRuntimeStore(); Reflect.set(globalThis, HOST_RUNTIME_STORE_GLOBAL_KEY, singletonHostRuntimeStore); - Reflect.set(globalThis, SESSION_DATA_OWNER_GLOBAL_KEY, sessionData); return singletonHostRuntimeStore; } @@ -2488,82 +2589,6 @@ 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/client-replica/internal/cache/index.test.ts b/packages/app/src/runtime/replica-cache/index.test.ts similarity index 95% rename from packages/app/src/runtime/client-replica/internal/cache/index.test.ts rename to packages/app/src/runtime/replica-cache/index.test.ts index ad5c24771b..df6c3a0aa0 100644 --- a/packages/app/src/runtime/client-replica/internal/cache/index.test.ts +++ b/packages/app/src/runtime/replica-cache/index.test.ts @@ -8,7 +8,7 @@ import { import type { StreamItem } from "@/types/stream"; import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; import { ReplicaCache } from "."; -import type { DirectoryCheckpoint } from "@/runtime/client-replica"; +import type { DirectoryCheckpoint } from "@/runtime/replica-cache"; import type { ReplicaHostRows, ReplicaRow, ReplicaRowChanges, ReplicaRowStore } from "./row-store"; const SERVER_ID = "cached-host"; @@ -269,6 +269,37 @@ describe("ReplicaCache", () => { expect(restoredTimeline).toEqual(timeline()); }); + it("preserves pending timeline updates across directory baseline replacement", async () => { + const storage = new MemoryStorage(); + const writer = createCache(storage); + writer.commitTimeline(SERVER_ID, "agent-1", timeline("Latest visible reply")); + writer.replaceDirectoryBaseline(SERVER_ID, directory()); + await writer.flush(); + + const reader = createCache(storage); + expect(await reader.readTimeline(SERVER_ID, "agent-1")).toEqual( + timeline("Latest visible reply"), + ); + }); + + it("preserves clearing a timeline across directory baseline replacement", async () => { + const storage = new MemoryStorage(); + const writer = createCache(storage); + writer.commitTimeline(SERVER_ID, "agent-1", timeline()); + await writer.flush(); + writer.commitTimeline(SERVER_ID, "agent-1", { ...timeline(), items: [], range: null }); + writer.replaceDirectoryBaseline(SERVER_ID, directory()); + await writer.flush(); + + const reader = createCache(storage); + expect(await reader.readTimeline(SERVER_ID, "agent-1")).toEqual({ + ...timeline(), + items: [], + range: null, + hasOlder: false, + }); + }); + it("serializes and writes only keyed directory mutations", async () => { const storage = new MemoryStorage(); const cache = createCache(storage); diff --git a/packages/app/src/runtime/client-replica/internal/cache/index.ts b/packages/app/src/runtime/replica-cache/index.ts similarity index 97% rename from packages/app/src/runtime/client-replica/internal/cache/index.ts rename to packages/app/src/runtime/replica-cache/index.ts index 00e1923476..c1db2f8418 100644 --- a/packages/app/src/runtime/client-replica/internal/cache/index.ts +++ b/packages/app/src/runtime/replica-cache/index.ts @@ -10,6 +10,7 @@ import { normalizeProjectDescriptor, normalizeWorkspaceDescriptor, type Agent, + type AgentTimelineCursorState, type ProjectDescriptor, type WorkspaceDescriptor, } from "@/stores/session-store"; @@ -18,13 +19,6 @@ 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 { @@ -36,6 +30,44 @@ import { type ReplicaRowStore, } from "./row-store"; +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; +} + const PERSIST_DELAY_MS = 1_000; const MAX_TIMELINE_ITEMS = 50; const MAX_CACHE_BYTES = 32 * 1024 * 1024; @@ -1071,7 +1103,13 @@ export class ReplicaCache { replaceDirectoryBaseline(serverId: string, directory: CachedDirectory): void { if (!this.activeServerIds.has(serverId)) return; this.advanceHostRevision(serverId); - this.dropPendingHostChanges(serverId); + // Replacing the directory must not discard independently accepted timeline changes. + for (const [key, row] of this.pendingUpserts) { + if (row.serverId === serverId && row.kind !== "timeline") this.pendingUpserts.delete(key); + } + for (const [key, row] of this.pendingDeletes) { + if (row.serverId === serverId && row.kind !== "timeline") this.pendingDeletes.delete(key); + } this.pendingBaselines.add(serverId); for (const [id, value] of directory.agents) { this.queueUpsert({ serverId, kind: "agent", id, value }); diff --git a/packages/app/src/runtime/client-replica/internal/cache/legacy-cleanup.ts b/packages/app/src/runtime/replica-cache/legacy-cleanup.ts similarity index 100% rename from packages/app/src/runtime/client-replica/internal/cache/legacy-cleanup.ts rename to packages/app/src/runtime/replica-cache/legacy-cleanup.ts diff --git a/packages/app/src/runtime/client-replica/internal/cache/legacy-cleanup.web.test.ts b/packages/app/src/runtime/replica-cache/legacy-cleanup.web.test.ts similarity index 100% rename from packages/app/src/runtime/client-replica/internal/cache/legacy-cleanup.web.test.ts rename to packages/app/src/runtime/replica-cache/legacy-cleanup.web.test.ts diff --git a/packages/app/src/runtime/client-replica/internal/cache/legacy-cleanup.web.ts b/packages/app/src/runtime/replica-cache/legacy-cleanup.web.ts similarity index 100% rename from packages/app/src/runtime/client-replica/internal/cache/legacy-cleanup.web.ts rename to packages/app/src/runtime/replica-cache/legacy-cleanup.web.ts diff --git a/packages/app/src/runtime/client-replica/internal/cache/row-store-factory.native.ts b/packages/app/src/runtime/replica-cache/row-store-factory.native.ts similarity index 100% rename from packages/app/src/runtime/client-replica/internal/cache/row-store-factory.native.ts rename to packages/app/src/runtime/replica-cache/row-store-factory.native.ts diff --git a/packages/app/src/runtime/client-replica/internal/cache/row-store-factory.ts b/packages/app/src/runtime/replica-cache/row-store-factory.ts similarity index 100% rename from packages/app/src/runtime/client-replica/internal/cache/row-store-factory.ts rename to packages/app/src/runtime/replica-cache/row-store-factory.ts diff --git a/packages/app/src/runtime/client-replica/internal/cache/row-store-factory.web.ts b/packages/app/src/runtime/replica-cache/row-store-factory.web.ts similarity index 100% rename from packages/app/src/runtime/client-replica/internal/cache/row-store-factory.web.ts rename to packages/app/src/runtime/replica-cache/row-store-factory.web.ts diff --git a/packages/app/src/runtime/client-replica/internal/cache/row-store-schema.ts b/packages/app/src/runtime/replica-cache/row-store-schema.ts similarity index 100% rename from packages/app/src/runtime/client-replica/internal/cache/row-store-schema.ts rename to packages/app/src/runtime/replica-cache/row-store-schema.ts diff --git a/packages/app/src/runtime/client-replica/internal/cache/row-store-sqlite.ts b/packages/app/src/runtime/replica-cache/row-store-sqlite.ts similarity index 100% rename from packages/app/src/runtime/client-replica/internal/cache/row-store-sqlite.ts rename to packages/app/src/runtime/replica-cache/row-store-sqlite.ts diff --git a/packages/app/src/runtime/client-replica/internal/cache/row-store.contract.ts b/packages/app/src/runtime/replica-cache/row-store.contract.ts similarity index 100% rename from packages/app/src/runtime/client-replica/internal/cache/row-store.contract.ts rename to packages/app/src/runtime/replica-cache/row-store.contract.ts diff --git a/packages/app/src/runtime/client-replica/internal/cache/row-store.native.test.ts b/packages/app/src/runtime/replica-cache/row-store.native.test.ts similarity index 100% rename from packages/app/src/runtime/client-replica/internal/cache/row-store.native.test.ts rename to packages/app/src/runtime/replica-cache/row-store.native.test.ts diff --git a/packages/app/src/runtime/client-replica/internal/cache/row-store.native.ts b/packages/app/src/runtime/replica-cache/row-store.native.ts similarity index 100% rename from packages/app/src/runtime/client-replica/internal/cache/row-store.native.ts rename to packages/app/src/runtime/replica-cache/row-store.native.ts diff --git a/packages/app/src/runtime/client-replica/internal/cache/row-store.ts b/packages/app/src/runtime/replica-cache/row-store.ts similarity index 100% rename from packages/app/src/runtime/client-replica/internal/cache/row-store.ts rename to packages/app/src/runtime/replica-cache/row-store.ts diff --git a/packages/app/src/runtime/client-replica/internal/cache/row-store.web.test.ts b/packages/app/src/runtime/replica-cache/row-store.web.test.ts similarity index 100% rename from packages/app/src/runtime/client-replica/internal/cache/row-store.web.test.ts rename to packages/app/src/runtime/replica-cache/row-store.web.test.ts diff --git a/packages/app/src/runtime/client-replica/internal/cache/row-store.web.ts b/packages/app/src/runtime/replica-cache/row-store.web.ts similarity index 100% rename from packages/app/src/runtime/client-replica/internal/cache/row-store.web.ts rename to packages/app/src/runtime/replica-cache/row-store.web.ts diff --git a/packages/app/src/runtime/session-data/index.ts b/packages/app/src/runtime/session-data/index.ts deleted file mode 100644 index 51ba4a636e..0000000000 --- a/packages/app/src/runtime/session-data/index.ts +++ /dev/null @@ -1,308 +0,0 @@ -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 deleted file mode 100644 index 73235a130e..0000000000 --- a/packages/app/src/runtime/session-data/internal/queued-message-drainer.ts +++ /dev/null @@ -1,56 +0,0 @@ -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 deleted file mode 100644 index 8b88cbf9de..0000000000 --- a/packages/app/src/runtime/session-data/queued-message-drainer.test.ts +++ /dev/null @@ -1,128 +0,0 @@ -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 618241a981..1c1566b6de 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-hooks"; +import type { normalizeWorkspaceDescriptor } from "@/stores/session-store"; 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 55f1ccff30..9096ae9e94 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -1,3 +1,4 @@ +import { getHostRuntimeStore } from "@/runtime/host-runtime"; import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react"; import type { ReactElement, RefObject } from "react"; import { useTranslation } from "react-i18next"; @@ -53,8 +54,7 @@ import { navigateToWorkspace, useLastWorkspaceSelection, } from "@/stores/navigation-active-workspace-store"; -import { normalizeWorkspaceDescriptor } from "@/stores/session-store-hooks"; -import { acceptWorkspaceSnapshots as mergeWorkspaces } from "@/runtime/session-data"; +import { normalizeWorkspaceDescriptor, type WorkspaceDescriptor } from "@/stores/session-store"; import { useWorkspace } from "@/stores/session-store-hooks"; import { buildNewWorkspaceDraftKey, generateDraftId } from "@/stores/draft-keys"; import { useOpenAddProject } from "@/hooks/use-open-add-project"; @@ -1556,6 +1556,12 @@ export function NewWorkspaceScreen({ const insets = useSafeAreaInsets(); const isCompact = useIsCompactFormFactor(); const toast = useToast(); + const mergeWorkspaces = useCallback( + (targetServerId: string, workspaces: Iterable) => { + getHostRuntimeStore().acceptWorkspaceSnapshots(targetServerId, Array.from(workspaces)); + }, + [], + ); const { allHosts, selectedServerId, @@ -2025,6 +2031,7 @@ 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 b169a2fcb2..1cd8707f3c 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-hooks"; +import type { normalizeWorkspaceDescriptor } from "@/stores/session-store"; 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 57911d72cd..ce2f8b9dfd 100644 --- a/packages/app/src/screens/schedules-screen.tsx +++ b/packages/app/src/screens/schedules-screen.tsx @@ -1,4 +1,11 @@ -import { useCallback, useEffect, useMemo, useState, type ReactElement } from "react"; +import { + useCallback, + useEffect, + useMemo, + useState, + useSyncExternalStore, + type ReactElement, +} from "react"; import { ScrollView, Text, View } from "react-native"; import { useIsFocused } from "@react-navigation/native"; import { CalendarClock, Plus } from "lucide-react-native"; @@ -20,7 +27,7 @@ import { type AggregatedSchedule, type ScheduleHostError, } from "@/hooks/use-schedules"; -import { readHostRuntimeSnapshot, useHostRuntimeVersion, useHosts } from "@/runtime/host-runtime"; +import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime"; import { resolveSchedule, type ScheduleBucket, @@ -61,7 +68,12 @@ function SchedulesScreenContent(): ReactElement { const { agents } = useAggregatedAgents({ includeArchived: true }); const { projects } = useProjects(); const hosts = useHosts(); - const runtimeVersion = useHostRuntimeVersion(); + const runtime = getHostRuntimeStore(); + const runtimeVersion = useSyncExternalStore( + (onStoreChange) => runtime.subscribeAll(onStoreChange), + () => runtime.getVersion(), + () => runtime.getVersion(), + ); // Per-host agent-directory readiness from the runtime, not the aggregate agent // flag: the aggregate `isInitialLoad` flips false as soon as *any* host has @@ -72,12 +84,12 @@ function SchedulesScreenContent(): ReactElement { void runtimeVersion; const ready = new Set(); for (const host of hosts) { - if (readHostRuntimeSnapshot(host.serverId)?.hasEverLoadedAgentDirectory) { + if (runtime.getSnapshot(host.serverId)?.hasEverLoadedAgentDirectory) { ready.add(host.serverId); } } return ready; - }, [hosts, runtimeVersion]); + }, [hosts, runtime, 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 82e85b7901..5e5e9be474 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 { useConnection } from "@/stores/session-store-hooks"; +import { useSessionStore } from "@/stores/session-store"; import { orderHostsLocalFirst, resolveActiveHostServerId, @@ -654,9 +654,8 @@ function HostVersionRow({ }) { const { t } = useTranslation(); const isConnected = useHostRuntimeIsConnected(host.serverId); - const daemonVersion = useConnection( - host.serverId, - ({ serverInfo }) => serverInfo?.version ?? null, + const daemonVersion = useSessionStore( + (state) => state.sessions[host.serverId]?.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 f47aff27f3..40eeb4fc77 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 { useConnection, useServerFeature } from "@/stores/session-store-hooks"; +import { useSessionStore } from "@/stores/session-store"; import { settingsStyles } from "@/styles/settings"; import type { HostConnection, HostProfile } from "@/types/host-connection"; import { confirmDialog } from "@/utils/confirm-dialog"; @@ -173,7 +173,9 @@ function HostStatusBadges({ serverId }: { serverId: string }) { const { t } = useTranslation(); const { theme } = useUnistyles(); const snapshot = useHostRuntimeSnapshot(serverId); - const daemonVersion = useConnection(serverId, ({ serverInfo }) => serverInfo?.version ?? null); + const daemonVersion = useSessionStore( + (state) => state.sessions[serverId]?.serverInfo?.version ?? null, + ); const connectionStatus = snapshot?.connectionStatus ?? "connecting"; const activeConnection = snapshot?.activeConnection ?? null; @@ -561,6 +563,7 @@ 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); @@ -571,8 +574,8 @@ function RestartDaemonCard({ host }: { host: HostProfile }) { }, []); const isHostConnected = useCallback( - () => isHostRuntimeConnected(readHostRuntimeSnapshot(host.serverId)), - [host.serverId], + () => isHostRuntimeConnected(runtime.getSnapshot(host.serverId)), + [host.serverId, runtime], ); const waitForCondition = useCallback( @@ -727,18 +730,19 @@ 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 = useConnection( - host.serverId, - ({ serverInfo }) => serverInfo?.version ?? null, + const daemonVersion = useSessionStore( + (state) => state.sessions[host.serverId]?.serverInfo?.version ?? null, ); - const supportsSelfUpdate = useServerFeature(host.serverId, "daemonSelfUpdate"); - const desktopManaged = useConnection( - host.serverId, - ({ serverInfo }) => serverInfo?.desktopManaged === true, + const supportsSelfUpdate = useSessionStore( + (state) => state.sessions[host.serverId]?.serverInfo?.features?.daemonSelfUpdate === true, + ); + const desktopManaged = useSessionStore( + (state) => state.sessions[host.serverId]?.serverInfo?.desktopManaged === true, ); const appVersion = resolveAppVersion(); @@ -752,13 +756,13 @@ function UpdateDaemonCard({ host }: { host: HostProfile }) { }, []); const isHostConnected = useCallback( - () => isHostRuntimeConnected(readHostRuntimeSnapshot(host.serverId)), - [host.serverId], + () => isHostRuntimeConnected(runtime.getSnapshot(host.serverId)), + [host.serverId, runtime], ); const hasReconnectedAfter = useCallback( (startMarker: DaemonConnectionMarker | null) => - hasDaemonReconnectedAfter(readHostRuntimeSnapshot(host.serverId), startMarker), - [host.serverId], + hasDaemonReconnectedAfter(runtime.getSnapshot(host.serverId), startMarker), + [host.serverId, runtime], ); const waitForCondition = useCallback( @@ -831,7 +835,7 @@ function UpdateDaemonCard({ host }: { host: HostProfile }) { }) .then((confirmed) => { if (!confirmed || !isMountedRef.current) return; - const startSnapshot = readHostRuntimeSnapshot(host.serverId); + const startSnapshot = runtime.getSnapshot(host.serverId); const startMarker = startSnapshot ? { clientGeneration: startSnapshot.clientGeneration, @@ -915,7 +919,7 @@ function UpdateDaemonCard({ host }: { host: HostProfile }) { message: t("settings.host.daemon.update.dialogFailedMessage"), }); }); - }, [daemonClient, host.label, host.serverId, isHostConnected, t, waitForDaemonRestart]); + }, [daemonClient, host.label, host.serverId, isHostConnected, runtime, 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 e9a616fda2..e35e9d44a4 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-hooks"; +import type { WorkspaceDescriptor } from "@/stores/session-store"; 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 9ad3840138..8141d508f4 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 { getActiveAgentSnapshot } from "@/runtime/session-data"; +import { useSessionStore } from "@/stores/session-store"; import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; interface RenamingTabState { @@ -46,7 +46,8 @@ export function useWorkspaceTabRename( } if (tab.target.kind === "agent") { const { agentId } = tab.target; - const agent = getActiveAgentSnapshot(normalizedServerId, agentId); + const agent = + useSessionStore.getState().sessions[normalizedServerId]?.agents?.get(agentId) ?? null; 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 ba3ede591d..86558b0b86 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-hooks"; +import type { WorkspaceDescriptor } from "@/stores/session-store"; 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 9724f27bb6..7f8137ace1 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-hooks"; +import type { WorkspaceDescriptor } from "@/stores/session-store"; 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 c8b87a29d1..a27a1ed418 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -12,6 +12,7 @@ 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"; @@ -59,19 +60,7 @@ import { import { openWorkspacePullRequest } from "@/workspace-tabs/open-supporting-view"; import { type ExplorerCheckoutContext } from "@/stores/explorer-checkout-context"; import { traceInstant } from "@/performance/native-trace"; -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 { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store"; import { canDismissPaneInLayout, collectAllTabs, @@ -101,11 +90,7 @@ import { useCreateFlowStore } from "@/stores/create-flow-store"; import { normalizeWorkspaceTabTarget, workspaceTabTargetsEqual } from "@/workspace-tabs/identity"; import { useVisibleAgentIds } from "./visible-agent-ids"; import { - prepareAgentTimeline, - prepareWorkspaceRoute, - probeHostConnections, - fetchAgentTimeline, - getHostClient, + getHostRuntimeStore, useHostRuntimeClient, useHostRuntimeIsConnected, useHostRuntimeSnapshot, @@ -1226,7 +1211,7 @@ function useWorkspaceRouteActions(normalizedServerId: string): { if (!normalizedServerId) { return; } - void probeHostConnections(normalizedServerId); + void getHostRuntimeStore().runProbeCycleNow(normalizedServerId); }, [normalizedServerId]); const handleManageHost = useCallback(() => { if (!normalizedServerId) { @@ -1563,7 +1548,9 @@ function WorkspaceScreenContent({ const workspaceDescriptor = useWorkspace(normalizedServerId, normalizedWorkspaceId); useEffect(() => { if (!normalizedServerId || !normalizedWorkspaceId || workspaceDescriptor) return; - void prepareWorkspaceRoute(normalizedServerId, normalizedWorkspaceId).catch(() => undefined); + void getHostRuntimeStore() + .prepareWorkspaceRoute(normalizedServerId, normalizedWorkspaceId) + .catch(() => undefined); }, [normalizedServerId, normalizedWorkspaceId, workspaceDescriptor]); const workspaceScripts = getWorkspaceScripts(workspaceDescriptor); const { handleRetryHost, handleManageHost, handleDismissMissingWorkspace } = @@ -1579,7 +1566,9 @@ function WorkspaceScreenContent({ const client = useHostRuntimeClient(normalizedServerId); const isConnected = useHostRuntimeIsConnected(normalizedServerId); - const supportsProvidersSnapshot = useServerFeature(normalizedServerId, "providersSnapshot"); + const supportsProvidersSnapshot = useSessionStore( + (state) => state.sessions[normalizedServerId]?.serverInfo?.features?.providersSnapshot === true, + ); const workspaceDirectory = workspaceDescriptor?.workspaceDirectory || null; const isMissingWorkspaceDirectory = Boolean(workspaceDescriptor) && !workspaceDirectory; const [isImportSheetVisible, setIsImportSheetVisible] = useState(false); @@ -1656,7 +1645,9 @@ function WorkspaceScreenContent({ })); }, []); const focusWorkspacePane = useWorkspaceLayoutStore((state) => state.focusPane); - const { hasHydratedWorkspaces } = useWorkspaceDirectoryStatus(normalizedServerId); + const hasHydratedWorkspaces = useSessionStore( + (state) => state.sessions[normalizedServerId]?.hasHydratedWorkspaces ?? false, + ); const workspaceRecovery = useWorkspaceRecovery({ serverId: normalizedServerId, workspaceId: normalizedWorkspaceId, @@ -1668,12 +1659,12 @@ function WorkspaceScreenContent({ ), }); - const workspaceAgentVisibility = useAgentDirectoryFields( - normalizedServerId, - (directory) => + const workspaceAgentVisibility = useStoreWithEqualityFn( + useSessionStore, + (state) => deriveWorkspaceAgentVisibility({ - sessionAgents: directory.agents, - agentDetails: directory.agentDetails, + sessionAgents: state.sessions[normalizedServerId]?.agents, + agentDetails: state.sessions[normalizedServerId]?.agentDetails, workspaceId: normalizedWorkspaceId, }), workspaceAgentVisibilityEqual, @@ -1736,9 +1727,8 @@ function WorkspaceScreenContent({ normalizedWorkspaceId, workspaceDirectory, }); - const hasHydratedAgents = useAgentDirectoryFields( - normalizedServerId, - (directory) => directory.hasHydratedAgents, + const hasHydratedAgents = useSessionStore( + (state) => state.sessions[normalizedServerId]?.hasHydratedAgents ?? false, ); const workspaceRouteState = useResolvedWorkspaceRouteState({ serverId: normalizedServerId, @@ -1911,7 +1901,9 @@ function WorkspaceScreenContent({ }), [uiTabs, workspaceLayout], ); - const viewedTimelineSync = useViewedTimelineSync(normalizedServerId); + const viewedTimelineSync = useSessionStore( + (state) => state.sessions[normalizedServerId]?.viewedTimelineSync ?? null, + ); const syncFocusedPaneOnly = useMemo( () => isMobile || isFocusModeEnabled || !supportsDesktopPaneSplits(), [isFocusModeEnabled, isMobile], @@ -1924,7 +1916,9 @@ function WorkspaceScreenContent({ }); useEffect(() => { for (const agentId of visibleAgentIds) { - void prepareAgentTimeline(normalizedServerId, agentId).catch(() => undefined); + void getHostRuntimeStore() + .prepareAgentTimeline(normalizedServerId, agentId) + .catch(() => undefined); } }, [normalizedServerId, visibleAgentIds]); useLayoutEffect(() => { @@ -1939,6 +1933,8 @@ 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") { @@ -1960,7 +1956,14 @@ function WorkspaceScreenContent({ } setFocusedAgentId(normalizedServerId, focusedPaneAgentId); setFocusedTerminalId(normalizedServerId, focusedPaneTerminalId); - }, [focusedPaneAgentId, focusedPaneTerminalId, isRouteFocused, normalizedServerId]); + }, [ + focusedPaneAgentId, + focusedPaneTerminalId, + isRouteFocused, + normalizedServerId, + setFocusedAgentId, + setFocusedTerminalId, + ]); useEffect(() => { if (!isRouteFocused) { @@ -1970,7 +1973,7 @@ function WorkspaceScreenContent({ setFocusedAgentId(normalizedServerId, null); setFocusedTerminalId(normalizedServerId, null); }; - }, [isRouteFocused, normalizedServerId]); + }, [isRouteFocused, normalizedServerId, setFocusedAgentId, setFocusedTerminalId]); const openWorkspaceDraftTab = useCallback( function openWorkspaceDraftTab(input?: { @@ -2527,9 +2530,10 @@ function WorkspaceScreenContent({ return; } - const agent = getActiveAgentSnapshot(normalizedServerId, agentId); + const agent = + useSessionStore.getState().sessions[normalizedServerId]?.agents?.get(agentId) ?? null; let closePolicy = resolveCloseAgentTabPolicy(agent); - const isRunning = agent?.turn.phase === "open"; + const isRunning = agent?.status === "running"; if (isRunning && closePolicy.kind === "archive-on-close") { const confirmed = await confirmDialog({ @@ -2545,7 +2549,7 @@ function WorkspaceScreenContent({ } if (closePolicy.kind === "layout-only") { - const sessionClient = getHostClient(normalizedServerId); + const sessionClient = useSessionStore.getState().sessions[normalizedServerId]?.client; if (!sessionClient) { toast.error(t("common.errors.daemonClientUnavailable")); return; @@ -2555,7 +2559,8 @@ function WorkspaceScreenContent({ await sessionClient.updateAgent(agentId, { labels: { [getOpenAgentTabLabel(clientId)]: "false" }, }); - const latestAgent = getActiveAgentSnapshot(normalizedServerId, agentId); + const latestAgent = + useSessionStore.getState().sessions[normalizedServerId]?.agents?.get(agentId) ?? null; closePolicy = resolveCloseAgentTabPolicy(latestAgent); } catch (error) { console.error("[WorkspaceScreen] Failed to close subagent tab", { error, agentId }); @@ -2693,7 +2698,8 @@ function WorkspaceScreenContent({ const handleCopyResumeCommand = useCallback( async (agentId: string) => { if (!agentId) return; - const agent = getActiveAgentSnapshot(normalizedServerId, agentId); + const agent = + useSessionStore.getState().sessions[normalizedServerId]?.agents?.get(agentId) ?? null; const providerSessionId = agent?.runtimeInfo?.sessionId ?? agent?.persistence?.sessionId ?? null; if (!agent || !providerSessionId) { @@ -2735,9 +2741,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 timeline = getAgentTimelineSnapshot(normalizedServerId, agentId); - const currentCursor = timeline.status === "synced" ? timeline.range : null; - await fetchAgentTimeline(normalizedServerId, agentId, { + const sessionState = useSessionStore.getState().sessions[normalizedServerId]; + const currentCursor = sessionState?.agentTimelineCursor.get(agentId); + await getHostRuntimeStore().fetchAgentTimeline(normalizedServerId, agentId, { direction: "tail", projection: "projected", ...(currentCursor @@ -2808,7 +2814,7 @@ function WorkspaceScreenContent({ } const groups = classifyBulkClosableTabs(tabsToClose, (agentId) => { - const agent = getActiveAgentSnapshot(normalizedServerId, agentId); + const agent = useSessionStore.getState().sessions[normalizedServerId]?.agents?.get(agentId); return resolveCloseAgentTabPolicy(agent).kind === "layout-only" ? "layout-only" : "archive"; }); const modifiedCount = tabsToClose.filter( @@ -2846,7 +2852,8 @@ function WorkspaceScreenContent({ await client.updateAgent(agentId, { labels: { [getOpenAgentTabLabel(clientId)]: "false" }, }); - const latestAgent = getActiveAgentSnapshot(normalizedServerId, agentId); + const latestAgent = + useSessionStore.getState().sessions[normalizedServerId]?.agents?.get(agentId) ?? null; if (resolveCloseAgentTabPolicy(latestAgent).kind === "archive-on-close") { await archiveAgent({ serverId: normalizedServerId, agentId }); } diff --git a/packages/app/src/screens/workspace/workspace-scripts-button.test.tsx b/packages/app/src/screens/workspace/workspace-scripts-button.test.tsx index 1cba95bd3a..82e0132cd3 100644 --- a/packages/app/src/screens/workspace/workspace-scripts-button.test.tsx +++ b/packages/app/src/screens/workspace/workspace-scripts-button.test.tsx @@ -91,10 +91,6 @@ vi.mock("@/constants/platform", () => ({ })); vi.mock("@/runtime/host-runtime", () => ({ - useHostRuntimeClient: () => ({ - startWorkspaceScript: startWorkspaceScriptMock, - killTerminal: killTerminalMock, - }), useHostRuntimeSnapshot: () => ({ activeConnection: null }), })); diff --git a/packages/app/src/screens/workspace/workspace-scripts-button.tsx b/packages/app/src/screens/workspace/workspace-scripts-button.tsx index aadcdb493d..59ddd9a0fb 100644 --- a/packages/app/src/screens/workspace/workspace-scripts-button.tsx +++ b/packages/app/src/screens/workspace/workspace-scripts-button.tsx @@ -15,8 +15,9 @@ import { } from "lucide-react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { useTranslation } from "react-i18next"; -import type { WorkspaceDescriptor } from "@/stores/session-store-hooks"; -import { useHostRuntimeClient, useHostRuntimeSnapshot } from "@/runtime/host-runtime"; +import type { WorkspaceDescriptor } from "@/stores/session-store"; +import { useSessionStore } from "@/stores/session-store"; +import { useHostRuntimeSnapshot } from "@/runtime/host-runtime"; import { DropdownMenu, DropdownMenuContent, @@ -546,7 +547,7 @@ export function WorkspaceScriptsButton({ }: WorkspaceScriptsButtonProps): ReactElement | null { const { t } = useTranslation(); const toast = useToast(); - const client = useHostRuntimeClient(serverId); + const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null); 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 e68aeb9c90..89978811ec 100644 --- a/packages/app/src/stores/session-store-hooks/index.ts +++ b/packages/app/src/stores/session-store-hooks/index.ts @@ -20,538 +20,19 @@ import { workspaceEqualityFns, type WorkspaceStructure, } from "./selectors"; -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 { useSessionStore, type WorkspaceDescriptor } from "../session-store"; 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, @@ -563,52 +44,15 @@ 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), - equality, + workspaceEqualityFns.deep, ); } diff --git a/packages/app/src/subagents/archive-subagent.test.ts b/packages/app/src/subagents/archive-subagent.test.ts index cb41ef12dd..9314961b16 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", - turn: { phase: "open", turnId: null, startedAt: null, cancellationRequestId: null }, + status: "running", }), ).toEqual({ title: "Archive running subagent?", @@ -78,7 +78,7 @@ describe("resolveArchiveSubagentDialog", () => { expect( resolveArchiveSubagentDialog({ title: "Starting child", - turn: { phase: "idle", cancellationRequestId: null }, + status: "initializing", }), ).toEqual({ title: "Archive subagent?", @@ -93,7 +93,7 @@ describe("resolveArchiveSubagentDialog", () => { expect( resolveArchiveSubagentDialog({ title: "Review branch", - turn: { phase: "idle", cancellationRequestId: null }, + status: "idle", }), ).toEqual({ title: "Archive subagent?", @@ -108,7 +108,7 @@ describe("resolveArchiveSubagentDialog", () => { expect( resolveArchiveSubagentDialog({ title: "New Agent", - turn: null, + status: null, }), ).toEqual({ title: "Archive subagent?", @@ -127,10 +127,7 @@ describe("requestArchiveSubagent", () => { initialSubagents: [ { id: "child-agent", - snapshot: { - title: "Review branch", - turn: { phase: "open", turnId: null, startedAt: null, cancellationRequestId: null }, - }, + snapshot: { title: "Review branch", status: "running" }, }, ], }); @@ -146,10 +143,7 @@ describe("requestArchiveSubagent", () => { initialSubagents: [ { id: "child-agent", - snapshot: { - title: "Review branch", - turn: { phase: "idle", cancellationRequestId: null }, - }, + snapshot: { title: "Review branch", status: "idle" }, }, ], }); @@ -165,10 +159,7 @@ describe("requestArchiveSubagent", () => { initialSubagents: [ { id: "child-agent", - snapshot: { - title: "Review branch", - turn: { phase: "open", turnId: null, startedAt: null, cancellationRequestId: null }, - }, + snapshot: { title: "Review branch", status: "running" }, }, ], }); @@ -176,10 +167,7 @@ describe("requestArchiveSubagent", () => { await requestArchiveSubagent({ serverId: "server-1", subagentId: "child-agent" }, env.deps); expect(env.recordedConfirmInputs).toEqual([ - resolveArchiveSubagentDialog({ - title: "Review branch", - turn: { phase: "open", turnId: null, startedAt: null, cancellationRequestId: null }, - }), + resolveArchiveSubagentDialog({ title: "Review branch", status: "running" }), ]); }); @@ -189,7 +177,7 @@ describe("requestArchiveSubagent", () => { await requestArchiveSubagent({ serverId: "server-1", subagentId: "missing" }, env.deps); expect(env.recordedConfirmInputs).toEqual([ - resolveArchiveSubagentDialog({ title: undefined, turn: undefined }), + resolveArchiveSubagentDialog({ title: undefined, status: undefined }), ]); expect(env.recordedArchives).toEqual([]); }); @@ -200,10 +188,7 @@ describe("requestArchiveSubagent", () => { initialSubagents: [ { id: "child-agent", - snapshot: { - title: "Review branch", - turn: { phase: "open", turnId: null, startedAt: null, cancellationRequestId: null }, - }, + snapshot: { title: "Review branch", status: "running" }, }, ], }); diff --git a/packages/app/src/subagents/archive-subagent.ts b/packages/app/src/subagents/archive-subagent.ts index 1ff23b526d..653dcb1cb2 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; - turn: Agent["turn"] | null | undefined; + status: Agent["status"] | 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.turn?.phase === "open"; + const isRunning = input.status === "running"; 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, - turn: subagent?.turn, + status: subagent?.status, }), ); if (!confirmed) { diff --git a/packages/app/src/subagents/select.test.ts b/packages/app/src/subagents/select.test.ts index 11196f9835..6835260ca7 100644 --- a/packages/app/src/subagents/select.test.ts +++ b/packages/app/src/subagents/select.test.ts @@ -290,13 +290,7 @@ describe("selectSubagentsForParent", () => { parentAgentId: "parent", provider: "claude", title: "Review child", - status: "idle", - turn: { - phase: "open", - turnId: "turn-1", - startedAt: createdAt, - cancellationRequestId: null, - }, + status: "running", requiresAttention: true, createdAt, model: "should-not-leak", @@ -321,13 +315,8 @@ describe("selectSubagentsForParent", () => { title: "Review child", description: null, subtitle: null, - status: "idle", - turn: { - phase: "open", - turnId: "turn-1", - startedAt: createdAt, - cancellationRequestId: null, - }, + status: "running", + turn: { phase: "idle", cancellationRequestId: null }, requiresAttention: true, createdAt, }, diff --git a/packages/app/src/subagents/select.ts b/packages/app/src/subagents/select.ts index 2aeb070f5d..3bb3bddcb6 100644 --- a/packages/app/src/subagents/select.ts +++ b/packages/app/src/subagents/select.ts @@ -2,12 +2,7 @@ import { useEffect, useMemo } from "react"; import { usePendingArchiveAgentIds } from "@/hooks/use-archive-agent"; import equal from "fast-deep-equal"; import { useStoreWithEqualityFn } from "zustand/traditional"; -import { useHostRuntimeClient } from "@/runtime/host-runtime"; -import { - useAgentDirectoryFields, - useServerFeature, - type Agent, -} from "@/stores/session-store-hooks"; +import { useSessionStore, type Agent } from "@/stores/session-store"; import { refreshProviderSubagents, useProviderSubagentStore } from "./provider-store"; import type { ProviderSubagentDescriptorPayload } from "@getpaseo/protocol/messages"; @@ -44,9 +39,7 @@ export interface ProviderSubagentRow { export type SubagentRow = PaseoSubagentRow | ProviderSubagentRow; -interface SessionStoreSnapshot { - sessions: Record }>; -} +type SessionStoreSnapshot = ReturnType; type ProviderSubagentStoreSnapshot = ReturnType; interface SelectSubagentsParams { @@ -141,24 +134,24 @@ export function selectProviderSubagentsForParent( export function useSubagentsForParent(params: SelectSubagentsParams): SubagentRow[] { const pendingArchiveIds = usePendingArchiveAgentIds(params.serverId); - const paseoRows = useAgentDirectoryFields( - params.serverId, - (directory) => - selectSubagentsForParent( - { sessions: { [params.serverId]: { agents: directory.agents } } }, - params, - pendingArchiveIds, - ), + const paseoRows = useStoreWithEqualityFn( + useSessionStore, + (state) => selectSubagentsForParent(state, params, pendingArchiveIds), equal, ); - const supported = useServerFeature(params.serverId, "providerSubagents"); - const nestingSupported = useServerFeature(params.serverId, "providerSubagentNesting"); + 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 providerRows = useStoreWithEqualityFn( useProviderSubagentStore, (state) => selectProviderSubagentsForParent(state, params, supported, nestingSupported), equal, ); - const client = useHostRuntimeClient(params.serverId); + const client = useSessionStore((state) => state.sessions[params.serverId]?.client ?? null); useEffect(() => { if (!client || !supported) return; diff --git a/packages/app/src/subagents/use-archive-finished.ts b/packages/app/src/subagents/use-archive-finished.ts index 1ecc19e485..e7653f054e 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 { getAgentSnapshot } from "@/runtime/session-data"; +import { useSessionStore } from "@/stores/session-store"; import { createArchiveFinishedSubagents, type ArchiveFinishedOutcome, @@ -32,7 +32,7 @@ export function useArchiveFinishedSubagents({ () => createArchiveFinishedSubagents([], { parentAgentId, - getManagedSubagent: (id) => getAgentSnapshot(serverId, id) ?? undefined, + getManagedSubagent: (id) => useSessionStore.getState().sessions[serverId]?.agents.get(id), 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 1c5a1ecfb1..b00f9bb871 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 { getAgentSnapshot } from "@/runtime/session-data"; +import { useSessionStore } from "@/stores/session-store"; 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 => - getAgentSnapshot(serverId, id) ?? undefined, + useSessionStore.getState().sessions[serverId]?.agents?.get(id), 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 92b4744e0b..36b2f2a560 100644 --- a/packages/app/src/subagents/use-detach-subagent.ts +++ b/packages/app/src/subagents/use-detach-subagent.ts @@ -1,8 +1,7 @@ import { useCallback } from "react"; import { useToast } from "@/contexts/toast-context"; import { i18n } from "@/i18n/i18next"; -import { getHostClient } from "@/runtime/host-runtime"; -import { getAgentSnapshot } from "@/runtime/session-data"; +import { useSessionStore } from "@/stores/session-store"; import { confirmDialog } from "@/utils/confirm-dialog"; import { toErrorMessage } from "@/utils/error-messages"; import { navigateToAgent } from "@/utils/navigate-to-agent"; @@ -29,10 +28,10 @@ export function useDetachSubagent(input: UseDetachSubagentInput): (subagentId: s { serverId, subagentId }, { getSubagent: (id): ResolveDetachSubagentDialogInput | undefined => - getAgentSnapshot(serverId, id) ?? undefined, + useSessionStore.getState().sessions[serverId]?.agents?.get(id), confirm: confirmDialog, - detachAgent: async ({ agentId }) => { - const client = getHostClient(serverId); + detachAgent: async ({ serverId: targetServerId, agentId }) => { + const client = useSessionStore.getState().sessions[targetServerId]?.client; 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 18d802012a..ea02fd1b16 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 { useAgentDirectoryFields } from "@/stores/session-store-hooks"; +import { useSessionStore } from "@/stores/session-store"; import { getOrCreateClientId } from "@/utils/client-id"; import type { WorkspaceTab } from "@/workspace-tabs/model"; import { getAgentTabsNeedingOpenLabel } from "./open-tab-labels"; @@ -16,10 +16,9 @@ export function useOpenAgentTabLabels(input: { tabs: WorkspaceTab[]; enabled: boolean; }): void { - const activeAgents = useAgentDirectoryFields(input.serverId, ({ agents }) => agents); - const agentDetails = useAgentDirectoryFields( - input.serverId, - (directory) => directory.agentDetails, + const agents = useSessionStore((state) => state.sessions[input.serverId]?.agents ?? null); + const agentDetails = useSessionStore( + (state) => state.sessions[input.serverId]?.agentDetails ?? null, ); const pendingAgentIdsRef = useRef(new Set()); const retryTimerRef = useRef | null>(null); @@ -58,7 +57,7 @@ export function useOpenAgentTabLabels(input: { const label = getOpenAgentTabLabel(clientId); const agentIds = getAgentTabsNeedingOpenLabel({ tabs: input.tabs, - getAgent: (agentId) => activeAgents.get(agentId) ?? agentDetails.get(agentId), + getAgent: (agentId) => agents?.get(agentId) ?? agentDetails?.get(agentId), label, pendingAgentIds: pendingAgentIdsRef.current, }); @@ -83,5 +82,5 @@ export function useOpenAgentTabLabels(input: { console.warn("[OpenAgentTabLabels] Failed to resolve client ID", { error }); } })(); - }, [activeAgents, agentDetails, input.client, input.enabled, input.tabs, retryVersion]); + }, [agentDetails, agents, input.client, input.enabled, input.tabs, retryVersion]); } diff --git a/packages/app/src/test/seed-session.ts b/packages/app/src/test/seed-session.ts index 61a75b068f..c2221a144f 100644 --- a/packages/app/src/test/seed-session.ts +++ b/packages/app/src/test/seed-session.ts @@ -1,16 +1,10 @@ +import { getHostRuntimeStore } from "@/runtime/host-runtime"; +import { defaultHostAppearance } from "@/hosts/appearance"; import { useSessionStore, type ProjectDescriptor, type WorkspaceDescriptor, } from "@/stores/session-store"; -import { DirectorySync } from "@/runtime/directory-sync"; -import { - acceptProjectSnapshot, - acceptWorkspaceSnapshots, - removeWorkspaceSnapshot, - registerDefaultSessionDataOwner, - SessionDataOwner, -} from "@/runtime/session-data"; function projectFromWorkspace(workspace: WorkspaceDescriptor): ProjectDescriptor { return { @@ -25,19 +19,6 @@ function projectFromWorkspace(workspace: WorkspaceDescriptor): ProjectDescriptor }; } -function collectProjects( - workspaces: Iterable, - projects: Iterable = [], -): Map { - const byProjectId = new Map(Array.from(projects, (project) => [project.projectId, project])); - for (const workspace of workspaces) { - if (!byProjectId.has(workspace.projectId)) { - byProjectId.set(workspace.projectId, projectFromWorkspace(workspace)); - } - } - return byProjectId; -} - /** * Seeds a session the way a daemon populates it: every workspace's project is * published on the project channel, so the store never holds a workspace whose @@ -49,39 +30,29 @@ export function seedSessionWorkspaces( workspaces: Map, projects?: Iterable, ): void { - const byProjectId = collectProjects(workspaces.values(), projects); + const byProjectId = new Map(); + for (const project of projects ?? []) byProjectId.set(project.projectId, project); + for (const workspace of workspaces.values()) { + if (!byProjectId.has(workspace.projectId)) { + byProjectId.set(workspace.projectId, projectFromWorkspace(workspace)); + } + } const store = useSessionStore.getState(); store.setProjects(serverId, byProjectId.values()); store.setWorkspaces(serverId, workspaces); } -export function publishSessionWorkspaces( - serverId: string, - workspaces: Map, - projects?: Iterable, -): void { - const byProjectId = collectProjects(workspaces.values(), projects); - for (const workspaceId of useSessionStore.getState().sessions[serverId]?.workspaces.keys() ?? - []) { - if (!workspaces.has(workspaceId)) removeWorkspaceSnapshot(serverId, workspaceId); - } - for (const project of byProjectId.values()) acceptProjectSnapshot(serverId, project); - acceptWorkspaceSnapshots(serverId, Array.from(workspaces.values())); -} - -export function installSessionDataTestOwner(serverIds: readonly string[]): SessionDataOwner { - const owner = new SessionDataOwner(); - for (const serverId of serverIds) { - owner.registerDirectory( +export function seedSessionHosts(serverIds: readonly string[]): void { + getHostRuntimeStore().syncHosts( + serverIds.map((serverId) => ({ serverId, - new DirectorySync(serverId, { - onAgentStoppedRunning: () => undefined, - markAgentLoading: () => undefined, - markAgentReady: () => undefined, - markAgentError: () => undefined, - }), - ); - } - registerDefaultSessionDataOwner(owner); - return owner; + label: serverId, + appearance: defaultHostAppearance(), + lifecycle: {}, + connections: [], + preferredConnectionId: null, + createdAt: "2026-09-01T00:00:00.000Z", + updatedAt: "2026-09-01T00:00:00.000Z", + })), + ); } diff --git a/packages/app/src/timeline/agent-timeline-service.ts b/packages/app/src/timeline/agent-timeline-service.ts deleted file mode 100644 index f0b2f8df7f..0000000000 --- a/packages/app/src/timeline/agent-timeline-service.ts +++ /dev/null @@ -1,36 +0,0 @@ -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 fc773b33f8..53d4710347 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/client-replica"; +import type { CachedTimeline } from "@/runtime/replica-cache"; import { selectAgentTimelineState, useSessionStore } from "@/stores/session-store"; import type { StreamItem } from "@/types/stream"; import { diff --git a/packages/app/src/timeline/viewed-timeline-sync.ts b/packages/app/src/timeline/viewed-timeline-sync.ts index 5fbac95f3a..323676739b 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/client-replica"; +import type { CachedTimeline } from "@/runtime/replica-cache"; import { selectAgentTimelineState, useSessionStore, diff --git a/packages/app/src/utils/workspace-identity.ts b/packages/app/src/utils/workspace-identity.ts index 301230eb19..8c778372d9 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: ReadonlyMap | null | undefined; + workspaces: Map | 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 99209bd3a5..ab19957190 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 { useHasHostServerInfo, useServerFeature } from "@/stores/session-store-hooks"; +import { useSessionStore } from "@/stores/session-store"; 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 supportsRecovery = useServerFeature(input.serverId, "workspaceRecovery"); - const hasServerInfo = useHasHostServerInfo(input.serverId); + const serverInfo = useSessionStore((store) => store.sessions[input.serverId]?.serverInfo ?? null); + const supportsRecovery = serverInfo?.features?.workspaceRecovery === true; 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, + hasServerInfo: serverInfo !== null, supportsRecovery, inspection: { pending: inspection.isPending, @@ -90,7 +90,6 @@ export function useWorkspaceRecovery(input: { [ client, input.enabled, - hasServerInfo, inspection.data, inspection.error, inspection.isError, @@ -99,6 +98,7 @@ export function useWorkspaceRecovery(input: { restoreMutation.error, restoreMutation.isError, restoreMutation.isPending, + serverInfo, supportsRecovery, ], ); diff --git a/packages/app/src/workspace-tabs/agent-visibility.ts b/packages/app/src/workspace-tabs/agent-visibility.ts index 4df685aa1f..1f8f5c5ed4 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: ReadonlyMap | undefined; - agentDetails?: ReadonlyMap | undefined; + sessionAgents: Map | undefined; + agentDetails?: Map | undefined; workspaceId: string | null | undefined; }): WorkspaceAgentVisibility { const { sessionAgents, agentDetails } = input; diff --git a/packages/app/src/workspace/legacy-daemon-workspaces.ts b/packages/app/src/workspace/legacy-daemon-workspaces.ts index b3f35e31a0..8303cfadec 100644 --- a/packages/app/src/workspace/legacy-daemon-workspaces.ts +++ b/packages/app/src/workspace/legacy-daemon-workspaces.ts @@ -7,12 +7,12 @@ import { deriveAgentStateBucket, getWorkspaceStateBucketPriority, } from "@getpaseo/protocol/agent-state-bucket"; -import type { Agent, DaemonServerInfo, WorkspaceDescriptor } from "@/stores/session-store-hooks"; import { - getAgentSnapshot, - getWorkspaceDirectorySnapshot, - readServerInfo, -} from "@/runtime/session-data"; + useSessionStore, + type Agent, + type DaemonServerInfo, + type WorkspaceDescriptor, +} from "@/stores/session-store"; import { buildAgentDirectoryState } from "@/utils/agent-directory-sync"; import { normalizeWorkspacePath } from "@/utils/workspace-identity"; @@ -116,12 +116,14 @@ export function applyLegacyDaemonWorkspaceOwnership(input: { return input.agent; } - if (!shouldBackfillLegacyDaemonWorkspaceDirectory(readServerInfo(input.serverId))) { + const session = useSessionStore.getState().sessions[input.serverId]; + if (!shouldBackfillLegacyDaemonWorkspaceDirectory(session?.serverInfo)) { return input.agent; } - const existingAgent = getAgentSnapshot(input.serverId, input.agent.id); - const workspaces = getWorkspaceDirectorySnapshot(input.serverId).workspaces; + const existingAgent = + session?.agents.get(input.agent.id) ?? session?.agentDetails.get(input.agent.id); + const workspaces = session?.workspaces; const workspaceId = existingAgent?.workspaceId ?? resolveLegacyWorkspaceIdFromAgent(input.agent, workspaces) ?? diff --git a/packages/app/src/workspace/use-workspace-archive.ts b/packages/app/src/workspace/use-workspace-archive.ts index e037cef9e8..7dc86a61ae 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 { getHostClient } from "@/runtime/host-runtime"; +import { getHostRuntimeStore } 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-hooks"; +import type { WorkspaceDescriptor } from "@/stores/session-store"; 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 = getHostClient(serverId); + const client = getHostRuntimeStore().getClient(serverId); if (!client) { toast.error(t("sidebar.workspace.toasts.hostDisconnected")); return; diff --git a/packages/app/src/workspace/workspace-archive.test.ts b/packages/app/src/workspace/workspace-archive.test.ts index 5288efad32..e7fe660f3b 100644 --- a/packages/app/src/workspace/workspace-archive.test.ts +++ b/packages/app/src/workspace/workspace-archive.test.ts @@ -1,3 +1,5 @@ +import { seedSessionHosts } from "@/test/seed-session"; +import { getHostRuntimeStore } from "@/runtime/host-runtime"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { @@ -5,8 +7,6 @@ import { isWorkspaceArchivePending, } from "@/contexts/session-workspace-upserts"; import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store"; -import { installSessionDataTestOwner } from "@/test/seed-session"; -import type { SessionDataOwner } from "@/runtime/session-data"; import { archiveWorkspaceOptimistically, archiveWorkspacesOptimistically, @@ -15,7 +15,6 @@ import { const SERVER_ID = "workspace-archive-test"; const SECOND_SERVER_ID = "workspace-archive-test-2"; -let sessionDataOwner: SessionDataOwner; type ArchiveWorkspacePayload = Awaited>; @@ -88,12 +87,12 @@ function storedWorkspace(id: string): WorkspaceDescriptor | undefined { } beforeEach(() => { + seedSessionHosts([SERVER_ID, SECOND_SERVER_ID]); useSessionStore.getState().initializeSession(SERVER_ID, {} as DaemonClient); - useSessionStore.getState().initializeSession(SECOND_SERVER_ID, {} as DaemonClient); - sessionDataOwner = installSessionDataTestOwner([SERVER_ID, SECOND_SERVER_ID]); }); afterEach(() => { + seedSessionHosts([]); clearWorkspaceArchivePending({ serverId: SERVER_ID, workspaceId: "workspace-1" }); clearWorkspaceArchivePending({ serverId: SERVER_ID, workspaceId: "workspace-2" }); clearWorkspaceArchivePending({ serverId: SECOND_SERVER_ID, workspaceId: "workspace-1" }); @@ -104,7 +103,7 @@ afterEach(() => { describe("archiveWorkspaceOptimistically", () => { it("hides the workspace and marks the archive pending while the daemon call runs", async () => { const archived = workspace(); - sessionDataOwner.acceptWorkspaces(SERVER_ID, [archived]); + getHostRuntimeStore().acceptWorkspaceSnapshots(SERVER_ID, [archived]); const releaseArchive = deferred(); const client = createClient(vi.fn(async () => releaseArchive.promise)); @@ -129,7 +128,7 @@ describe("archiveWorkspaceOptimistically", () => { it("restores the workspace and clears pending state when the daemon rejects the archive", async () => { const archived = workspace(); - sessionDataOwner.acceptWorkspaces(SERVER_ID, [archived]); + getHostRuntimeStore().acceptWorkspaceSnapshots(SERVER_ID, [archived]); const client = createClient( vi.fn(async () => archivePayload({ workspaceId: archived.id, error: "nope" })), ); @@ -159,7 +158,7 @@ describe("archiveWorkspacesOptimistically", () => { workspaceDirectory: "/repo/project/workspace-2", name: "workspace-2", }); - sessionDataOwner.acceptWorkspaces(SERVER_ID, [first, second]); + getHostRuntimeStore().acceptWorkspaceSnapshots(SERVER_ID, [first, second]); const client = createClient( vi.fn(async (workspaceId) => archivePayload({ @@ -187,8 +186,9 @@ describe("archiveWorkspacesOptimistically", () => { workspaceDirectory: "/repo/project/workspace-2", name: "workspace-2", }); - sessionDataOwner.acceptWorkspaces(SERVER_ID, [first]); - sessionDataOwner.acceptWorkspaces(SECOND_SERVER_ID, [second]); + useSessionStore.getState().initializeSession(SECOND_SERVER_ID, {} as DaemonClient); + getHostRuntimeStore().acceptWorkspaceSnapshots(SERVER_ID, [first]); + getHostRuntimeStore().acceptWorkspaceSnapshots(SECOND_SERVER_ID, [second]); const archivedByServer = new Map(); const clientFor = (serverId: string) => diff --git a/packages/app/src/workspace/workspace-archive.ts b/packages/app/src/workspace/workspace-archive.ts index ff13eb8580..a4e5a00e80 100644 --- a/packages/app/src/workspace/workspace-archive.ts +++ b/packages/app/src/workspace/workspace-archive.ts @@ -1,13 +1,9 @@ +import { getHostRuntimeStore } from "@/runtime/host-runtime"; import { clearWorkspaceArchivePending, markWorkspaceArchivePending, } from "@/contexts/session-workspace-upserts"; -import type { WorkspaceDescriptor } from "@/stores/session-store-hooks"; -import { - acceptWorkspaceSnapshots, - getWorkspaceDirectorySnapshot, - removeWorkspaceSnapshot, -} from "@/runtime/session-data"; +import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store"; import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity"; import { i18n } from "@/i18n/i18next"; @@ -45,7 +41,7 @@ function isWorkspaceArchiveFailure(error: unknown): error is WorkspaceArchiveFai function hideWorkspaceOptimistically( workspace: WorkspaceArchiveTarget, ): OptimisticWorkspaceArchiveSnapshot { - const workspaces = getWorkspaceDirectorySnapshot(workspace.serverId).workspaces; + const workspaces = useSessionStore.getState().sessions[workspace.serverId]?.workspaces; const workspaceKey = resolveWorkspaceMapKeyByIdentity({ workspaces, workspaceId: workspace.workspaceId, @@ -55,7 +51,7 @@ function hideWorkspaceOptimistically( serverId: workspace.serverId, workspaceId: workspace.workspaceId, }); - removeWorkspaceSnapshot(workspace.serverId, workspace.workspaceId); + getHostRuntimeStore().removeWorkspaceSnapshot(workspace.serverId, workspace.workspaceId); return { workspace: snapshot }; } @@ -69,7 +65,7 @@ function restoreOptimisticallyHiddenWorkspace(input: { workspaceId: input.workspaceId, }); if (input.snapshot.workspace) { - acceptWorkspaceSnapshots(input.serverId, [input.snapshot.workspace]); + getHostRuntimeStore().acceptWorkspaceSnapshots(input.serverId, [input.snapshot.workspace]); } }