From 54d94c3715a4c975e119275212aa5c6b807ce97b Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 5 Sep 2026 14:09:35 +0200 Subject: [PATCH] fix(app): preserve pending chat cache writes and trim resume refactor Directory baseline replacement must preserve independently accepted timeline changes until the deferred flush. Keep the reconnect and persistence fixes while removing the session-data facade and broad read-hook migration. --- docs/architecture.md | 2 +- docs/timeline-sync.md | 2 +- .../browser/agent-message-submission.spec.ts | 2 +- .../internal/use-agent-profile-picker.ts | 4 +- .../internal/use-agent-profiles.ts | 9 +- .../agent-skills/legacy-migration-service.ts | 12 - .../app/src/agent-skills/legacy-migration.tsx | 19 +- .../chat-outline/use-chat-outline.test.tsx | 2 - .../chat-outline/use-chat-outline.ts | 30 +- packages/app/src/agent-stream/view.tsx | 33 +- packages/app/src/app/_layout.tsx | 55 +- .../src/app/h/[serverId]/agent/[agentId].tsx | 22 +- .../app/src/architecture-boundaries.test.ts | 137 ----- .../command-center/workspace-file-search.ts | 6 +- .../command-center/workspace-registration.tsx | 4 + .../app/src/components/add-project-flow.tsx | 18 +- packages/app/src/components/agent-list.tsx | 10 +- .../src/components/app-diagnostic-sheet.tsx | 9 +- .../compact-explorer-sidebar-host-state.ts | 2 +- .../app/src/components/file-explorer-pane.tsx | 18 +- .../rewind/use-rewind-agent-mutation.ts | 15 +- .../schedules/schedule-form-sheet.tsx | 26 +- .../sidebar-workspace-list.test.tsx | 4 +- .../src/components/sidebar-workspace-list.tsx | 4 +- .../components/sidebar/sidebar-help-menu.tsx | 7 +- packages/app/src/components/terminal-pane.tsx | 26 +- .../app/src/components/welcome-screen.tsx | 59 +- .../src/components/workspace-rename-modal.tsx | 4 +- .../src/components/workspace-setup-dialog.tsx | 32 +- ...space-shortcut-targets-subscriber.test.tsx | 10 +- .../app/src/composer/agent-controls/index.tsx | 20 +- .../composer/agent-controls/mode-control.tsx | 34 +- .../app/src/composer/draft/create-flow.ts | 6 +- .../app/src/composer/draft/workspace-tab.tsx | 15 +- packages/app/src/composer/index.tsx | 86 +-- packages/app/src/composer/input/input.tsx | 17 +- .../app/src/composer/submission/writer.ts | 49 +- .../app/src/composer/workspace-diff-stat.ts | 2 +- packages/app/src/contexts/session-context.tsx | 130 ++-- .../src/contexts/session-status-tracking.ts | 2 +- .../src/contexts/session-workspace-upserts.ts | 2 +- packages/app/src/contexts/voice-context.tsx | 5 +- .../workspace-directory-reconciliation.ts | 5 +- .../hooks/use-built-in-daemon-management.ts | 8 +- packages/app/src/file-pane/pane.tsx | 9 +- packages/app/src/git/actions-store.test.ts | 67 ++- packages/app/src/git/actions-store.ts | 11 +- packages/app/src/git/diff-pane.tsx | 21 +- .../app/src/git/pull-request-panel/pane.tsx | 18 +- packages/app/src/git/use-actions.tsx | 78 +-- packages/app/src/git/use-commits-query.ts | 10 +- packages/app/src/hooks/open-project.ts | 2 +- .../sidebar-workspaces-view-model.test.ts | 20 +- .../hooks/sidebar-workspaces-view-model.ts | 14 +- .../app/src/hooks/use-agent-autocomplete.ts | 6 +- packages/app/src/hooks/use-agent-history.ts | 32 +- .../app/src/hooks/use-agent-initialization.ts | 21 +- .../use-agent-screen-state-machine.test.ts | 7 +- .../app/src/hooks/use-aggregated-agents.ts | 71 +-- .../app/src/hooks/use-archive-agent.test.ts | 12 +- packages/app/src/hooks/use-archive-agent.ts | 20 +- .../hooks/use-clear-workspace-attention.ts | 16 +- packages/app/src/hooks/use-favicon-status.ts | 2 +- .../src/hooks/use-file-explorer-actions.ts | 13 +- packages/app/src/hooks/use-fork-agent.ts | 4 +- .../app/src/hooks/use-is-dictation-ready.ts | 25 +- .../src/hooks/use-load-older-agent-history.ts | 47 +- packages/app/src/hooks/use-open-project.ts | 48 +- packages/app/src/hooks/use-projects.ts | 87 ++- .../app/src/hooks/use-providers-snapshot.ts | 6 +- .../app/src/hooks/use-schedule-mutations.ts | 4 +- packages/app/src/hooks/use-schedules.ts | 5 +- packages/app/src/hooks/use-sidebar-pins.ts | 45 +- .../hooks/use-sidebar-workspace-entries.ts | 27 +- .../src/hooks/use-sidebar-workspace-pin.ts | 4 +- .../src/hooks/use-sidebar-workspaces-list.ts | 53 +- .../src/navigation/host-runtime-bootstrap.ts | 10 - packages/app/src/panels/agent-panel.tsx | 270 +++++---- packages/app/src/panels/agent-tracks.tsx | 10 +- .../src/panels/provider-subagent-panel.tsx | 26 +- packages/app/src/panels/terminal-panel.tsx | 10 +- packages/app/src/plugins/catalog-sync.tsx | 3 +- .../plugins/command-center/registration.tsx | 16 +- .../src/plugins/workspace-panels/panel.tsx | 18 +- packages/app/src/projects/icons.ts | 9 +- packages/app/src/projects/project-remove.ts | 6 +- .../src/provider-usage/use-provider-usage.ts | 6 +- .../app/src/runtime/client-replica/index.ts | 85 --- .../app/src/runtime/daemon-start-service.ts | 12 +- .../directory-sync/agent-replica.test.ts | 2 +- .../runtime/directory-sync/agent-replica.ts | 2 +- .../src/runtime/directory-sync/index.test.ts | 2 +- .../app/src/runtime/directory-sync/index.ts | 2 +- .../directory-sync/workspace-replica.ts | 2 +- packages/app/src/runtime/host-runtime.test.ts | 98 +-- packages/app/src/runtime/host-runtime.ts | 277 +++++---- .../cache => replica-cache}/index.test.ts | 33 +- .../internal/cache => replica-cache}/index.ts | 54 +- .../cache => replica-cache}/legacy-cleanup.ts | 0 .../legacy-cleanup.web.test.ts | 0 .../legacy-cleanup.web.ts | 0 .../row-store-factory.native.ts | 0 .../row-store-factory.ts | 0 .../row-store-factory.web.ts | 0 .../row-store-schema.ts | 0 .../row-store-sqlite.ts | 0 .../row-store.contract.ts | 0 .../row-store.native.test.ts | 0 .../row-store.native.ts | 0 .../cache => replica-cache}/row-store.ts | 0 .../row-store.web.test.ts | 0 .../cache => replica-cache}/row-store.web.ts | 0 .../app/src/runtime/session-data/index.ts | 308 ---------- .../internal/queued-message-drainer.ts | 56 -- .../queued-message-drainer.test.ts | 128 ---- .../app/src/screens/new-workspace-empty.ts | 2 +- .../app/src/screens/new-workspace-screen.tsx | 11 +- .../app/src/screens/new-workspace-terminal.ts | 2 +- packages/app/src/screens/schedules-screen.tsx | 22 +- packages/app/src/screens/settings-screen.tsx | 7 +- .../app/src/screens/settings/host-page.tsx | 40 +- .../terminals/use-workspace-terminals.ts | 2 +- .../workspace/use-workspace-tab-rename.tsx | 5 +- .../workspace/workspace-header-source.ts | 2 +- .../workspace/workspace-route-state.ts | 2 +- .../screens/workspace/workspace-screen.tsx | 95 +-- .../workspace-scripts-button.test.tsx | 4 - .../workspace/workspace-scripts-button.tsx | 7 +- .../src/stores/session-store-hooks/index.ts | 560 +----------------- .../src/subagents/archive-subagent.test.ts | 35 +- .../app/src/subagents/archive-subagent.ts | 6 +- packages/app/src/subagents/select.test.ts | 17 +- packages/app/src/subagents/select.ts | 33 +- .../app/src/subagents/use-archive-finished.ts | 4 +- .../app/src/subagents/use-archive-subagent.ts | 4 +- .../app/src/subagents/use-detach-subagent.ts | 9 +- .../subagents/use-open-agent-tab-labels.ts | 13 +- packages/app/src/test/seed-session.ts | 71 +-- .../src/timeline/agent-timeline-service.ts | 36 -- packages/app/src/timeline/replica.test.ts | 2 +- .../app/src/timeline/viewed-timeline-sync.ts | 2 +- packages/app/src/utils/workspace-identity.ts | 2 +- .../use-workspace-recovery.ts | 10 +- .../src/workspace-tabs/agent-visibility.ts | 4 +- .../src/workspace/legacy-daemon-workspaces.ts | 18 +- .../src/workspace/use-workspace-archive.ts | 6 +- .../src/workspace/workspace-archive.test.ts | 20 +- .../app/src/workspace/workspace-archive.ts | 14 +- 148 files changed, 1658 insertions(+), 2623 deletions(-) delete mode 100644 packages/app/src/agent-skills/legacy-migration-service.ts delete mode 100644 packages/app/src/architecture-boundaries.test.ts delete mode 100644 packages/app/src/runtime/client-replica/index.ts rename packages/app/src/runtime/{client-replica/internal/cache => replica-cache}/index.test.ts (95%) rename packages/app/src/runtime/{client-replica/internal/cache => replica-cache}/index.ts (97%) rename packages/app/src/runtime/{client-replica/internal/cache => replica-cache}/legacy-cleanup.ts (100%) rename packages/app/src/runtime/{client-replica/internal/cache => replica-cache}/legacy-cleanup.web.test.ts (100%) rename packages/app/src/runtime/{client-replica/internal/cache => replica-cache}/legacy-cleanup.web.ts (100%) rename packages/app/src/runtime/{client-replica/internal/cache => replica-cache}/row-store-factory.native.ts (100%) rename packages/app/src/runtime/{client-replica/internal/cache => replica-cache}/row-store-factory.ts (100%) rename packages/app/src/runtime/{client-replica/internal/cache => replica-cache}/row-store-factory.web.ts (100%) rename packages/app/src/runtime/{client-replica/internal/cache => replica-cache}/row-store-schema.ts (100%) rename packages/app/src/runtime/{client-replica/internal/cache => replica-cache}/row-store-sqlite.ts (100%) rename packages/app/src/runtime/{client-replica/internal/cache => replica-cache}/row-store.contract.ts (100%) rename packages/app/src/runtime/{client-replica/internal/cache => replica-cache}/row-store.native.test.ts (100%) rename packages/app/src/runtime/{client-replica/internal/cache => replica-cache}/row-store.native.ts (100%) rename packages/app/src/runtime/{client-replica/internal/cache => replica-cache}/row-store.ts (100%) rename packages/app/src/runtime/{client-replica/internal/cache => replica-cache}/row-store.web.test.ts (100%) rename packages/app/src/runtime/{client-replica/internal/cache => replica-cache}/row-store.web.ts (100%) delete mode 100644 packages/app/src/runtime/session-data/index.ts delete mode 100644 packages/app/src/runtime/session-data/internal/queued-message-drainer.ts delete mode 100644 packages/app/src/runtime/session-data/queued-message-drainer.test.ts delete mode 100644 packages/app/src/timeline/agent-timeline-service.ts 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]); } }