perf(app): restore live sessions immediately after resume

Foregrounding now reconnects every host without waiting for scheduled backoff, while cached turns, timelines, agents, and workspaces remain internally consistent until authoritative catch-up arrives. Replica persistence is keyed and incremental so ordinary updates avoid whole-directory serialization.
This commit is contained in:
Mohamed Boudra
2026-09-05 13:45:53 +02:00
parent 02e611f3dd
commit 82b47897e7
167 changed files with 4295 additions and 2466 deletions
+1 -1
View File
@@ -104,7 +104,7 @@ Cross-platform React Native app that connects to one or more daemons.
- Expo Router navigation (`/h/[serverId]/workspace/[workspaceId]`, `/h/[serverId]/agent/[agentId]`, etc.). The `workspaceId` URL segment is an opaque workspace id, not a directly meaningful filesystem path.
- `HostRuntimeController` manages saved host connections, reconnection, and per-host runtime state. Direct TCP and relay connections use the ordinary client transport; desktop socket, pipe, and SSH connections cross one Electron-owned transport boundary. SSH only tunnels to an already-running daemon.
- `runtime/replica-cache` is typed storage behind the directory and timeline owners. It never observes or mutates `SessionStore`.
- `runtime/client-replica` owns typed storage behind narrow host-lifecycle, directory, and timeline capabilities. Its cache internals never escape the module or observe `SessionStore`.
- `runtime/directory-sync` owns directory cache selection and network reconciliation. On demand it paints accepted rows for one host, then passes the persisted per-entity cursor through `project.list`, `fetch_workspaces`, and `fetch_agents`; the daemon returns each entity's latest projection when its sequence is newer, plus tombstones.
- `workspace-labels` owns one sequenced catalog replica per connected host, the deterministic cross-host projection that surfaces spanning hosts use (the filter page, the manager), and the per-host resolution a workspace row's chips use. Two hosts may give one name different colors, so a row resolves against its own host's catalog and a merged answer would be wrong there. Catalogs never synchronize between hosts; assignment creates a missing definition only on the target host. On the daemon, catalog and assignment rewrites share a journaled commit boundary. Startup recovery completes that commit before workspace or catalog publication.
- `SessionContext` wraps the daemon client for the active session
+6 -2
View File
@@ -574,8 +574,8 @@ Right-sidebar client state splits on whether it is determined by the directory o
The durable client replica uses IndexedDB on browser/Electron and expo-sqlite on native. Rows use the
compound key `(serverId, kind, id)`; kinds are `agent`, `workspace`, `project`, `timeline`, and
`checkpoint`. Directory entities have individual rows. Timeline and checkpoint use the singleton id
and have at most one row per host.
`checkpoint`. Directory entities have individual rows, timelines use the agent id, and the checkpoint
uses the singleton id.
The store is a typed persistence boundary. It returns values to directory and timeline owners and
accepts their explicit commits; it never reads or writes UI state. Reads are scoped to the requested
@@ -585,6 +585,10 @@ An invalid directory row and its affected checkpoint cursor are repaired in one
later launch cannot accept a checkpoint for a partial baseline. Directory changes and their
checkpoint are also applied in one transaction.
Owners submit keyed values and tombstones, not directory snapshots. The cache coalesces each key while
the value remains structured, then serializes only the surviving changed rows during the deferred
write. App backgrounding flushes immediately; the ordinary write window is one second.
The cache is capped at 32 MiB and evicts whole hosts in least-recently-written order. Budget
bookkeeping may scan opaque row sizes during a deferred write, never during host registry startup or
before a requested cache row can paint. The row store is not encrypted. A cached timeline can contain
+4 -4
View File
@@ -152,7 +152,7 @@ remaining page through the existing stream reducer. It must not append full proj
live prefix.
Every path that sends a message to an agent — composer send, dictation accept-and-send, queued
send-now, and the automatic queue drain in `HostRuntime` — goes through
send-now, and the session-data owner's automatic queue drain — goes through
`dispatchComposerAgentMessage` with a submission writer. There is no second transport for the same
product action: calling `client.sendAgentMessage` directly skips the submitted row and the pending
footer, and permanently drops attachments because the daemon does not echo them back.
@@ -196,9 +196,9 @@ fork positions retain the canonical `turnId` boundaries.
The compatibility boundary for older daemons is snapshot normalization: running/idle status becomes an
anonymous active turn or idle state once, and downstream code consumes the same activity shape. The app
does not combine anonymous lifecycle events, timestamps, timeline rows, and resume coverage to infer a
second running state. Disconnect and replica removal remain destructive close boundaries. Elapsed time
comes only from turn liveness, never from submission records or whichever timeline rows happen to be
mounted.
second running state. Disconnect preserves the last replicated turn until cache or network hydration
advances it; replica removal remains the destructive close boundary. Elapsed time comes only from turn
liveness, never from submission records or whichever timeline rows happen to be mounted.
The daemon records one canonical submitted user row at acceptance. Its wire `messageId` is the
submission's `clientMessageId`, so the row is born with its final identity and remains immutable on
@@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
import { mergeCreateAgentSelectionPreferences } from "@/create-agent-preferences/preferences";
import { useFormPreferences } from "@/hooks/use-form-preferences";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import { useSessionStore } from "@/stores/session-store";
import { useHostRuntimeClient } from "@/runtime/host-runtime";
import { useToast } from "@/contexts/toast-context";
import { toErrorMessage } from "@/utils/error-messages";
import { showProviderNoticeToast } from "@/utils/provider-notice-toast";
@@ -73,7 +73,7 @@ export function useAgentProfilePicker(
// so every composer on a host shares one query instead of adding its own.
const { entries } = useProvidersSnapshot(serverId, { cwd: null });
const { updatePreferences } = useFormPreferences();
const client = useSessionStore((state) => state.sessions[serverId ?? ""]?.client ?? null);
const client = useHostRuntimeClient(serverId ?? "");
const toast = useToast();
const applicableProfiles = useMemo(() => {
@@ -1,8 +1,7 @@
import { useCallback } from "react";
import type { AgentProfile } from "@getpaseo/protocol/messages";
import { useDaemonConfig } from "@/hooks/use-daemon-config";
import { useSessionStore } from "@/stores/session-store";
import { supportsAgentProfiles } from "./capabilities";
import { useServerFeature } from "@/stores/session-store-hooks";
export interface UseAgentProfilesResult {
/** `null` until the daemon config has arrived. */
@@ -15,9 +14,9 @@ export interface UseAgentProfilesResult {
export function useAgentProfiles(serverId: string | null): UseAgentProfilesResult {
const { config, patchConfig } = useDaemonConfig(serverId);
const isSupported = useSessionStore((state) => {
return supportsAgentProfiles(state.sessions[serverId ?? ""]?.serverInfo?.features);
});
const supportsProfiles = useServerFeature(serverId, "agentProfiles");
const supportsApply = useServerFeature(serverId, "agentConfigApply");
const isSupported = supportsProfiles && supportsApply;
const saveProfiles = useCallback(
async (next: AgentProfile[]) => {
@@ -0,0 +1,12 @@
import type { AgentSkillSelection } from "@getpaseo/protocol/messages";
import { getHostClient } from "@/runtime/host-runtime";
export async function importLegacyAgentSkillsSelection(
serverId: string,
selection: AgentSkillSelection,
): Promise<boolean> {
const client = getHostClient(serverId);
if (!client) return false;
await client.importLegacyAgentSkillsSelection(selection);
return true;
}
@@ -5,8 +5,8 @@ import {
deleteLegacySkillSelection,
readLegacySkillSelection,
} from "@/desktop/daemon/desktop-daemon";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { useSessionStore } from "@/stores/session-store";
import { useConnectedDesktopManagedHostIds } from "@/stores/session-store-hooks";
import { importLegacyAgentSkillsSelection } from "./legacy-migration-service";
interface LegacySelectionClient {
importLegacyAgentSkillsSelection(selection: AgentSkillSelection): Promise<unknown>;
@@ -26,21 +26,16 @@ export async function migrateLegacyAgentSkillsSelection(
// COMPAT(desktopSkillSelectionMigration): added in v0.4.0; remove after 2027-02-16.
export function LegacyAgentSkillsMigration() {
const connectedManagedHosts = useSessionStore((state) =>
Object.entries(state.sessions)
.filter(([, session]) => session.serverInfo?.desktopManaged === true)
.map(([serverId]) => serverId)
.sort()
.join(","),
);
const connectedManagedHosts = useConnectedDesktopManagedHostIds().join(",");
useEffect(() => {
if (!shouldUseDesktopDaemon() || !connectedManagedHosts) return;
async function migrate(): Promise<void> {
const status = await getDesktopDaemonStatus();
if (status.status !== "running" || !status.desktopManaged) return;
const client = getHostRuntimeStore().getSnapshot(status.serverId)?.client;
if (!client) return;
await migrateLegacyAgentSkillsSelection(client);
const selection = await readLegacySkillSelection();
if (!selection) return;
const imported = await importLegacyAgentSkillsSelection(status.serverId, selection);
if (imported) await deleteLegacySkillSelection();
}
void migrate().catch((error) => {
console.error("[Agent skills] Legacy selection migration failed; will retry", error);
@@ -2,7 +2,11 @@ import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } fro
import type { AgentTimelinePromptIndexPayload } from "@getpaseo/client/internal/daemon-client";
import { isWeb } from "@/constants/platform";
import { useStableEvent } from "@/hooks/use-stable-event";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import {
listAgentTimelinePrompts,
loadAgentTimelineWindow,
subscribeToAgentTimelinePrompts,
} from "@/timeline/agent-timeline-service";
import { planTimelinePromptJump } from "@/timeline/timeline-sync-plan";
import type { StreamItem } from "@/types/stream";
import type { StreamViewportHandle } from "../strategy";
@@ -71,15 +75,13 @@ export function useChatOutline({
return;
}
setIndex(null);
const client = getHostRuntimeStore().getClient(serverId);
if (!client) return;
let active = true;
const refresh = () => {
const requestId = ++nextIndexRequestIdRef.current;
void client
.listAgentTimelinePrompts(agentId)
void listAgentTimelinePrompts(serverId, agentId)
.then((payload) => {
if (
payload &&
active &&
requestId === nextIndexRequestIdRef.current &&
shouldAcceptPromptIndexEpoch(timelineEpoch, payload.epoch)
@@ -91,16 +93,7 @@ export function useChatOutline({
.catch(() => undefined);
};
refresh();
const unsubscribe = client.on("agent_stream", (message) => {
if (
message.type === "agent_stream" &&
message.payload.agentId === agentId &&
message.payload.event.type === "timeline" &&
message.payload.event.item.type === "user_message"
) {
refresh();
}
});
const unsubscribe = subscribeToAgentTimelinePrompts(serverId, agentId, refresh);
return () => {
active = false;
unsubscribe();
@@ -172,8 +165,11 @@ export function useChatOutline({
if (!index) return;
const requestId = nextJumpRequestIdRef.current;
setPendingJump({ requestId, seq, fetchSettled: false, hasScrolled: false });
void getHostRuntimeStore()
.fetchAgentTimeline(serverId, agentId, planTimelinePromptJump({ epoch: index.epoch, seq }))
void loadAgentTimelineWindow(
serverId,
agentId,
planTimelinePromptJump({ epoch: index.epoch, seq }),
)
.catch((error: unknown) => {
console.warn("Failed to load a Chat outline window", error);
onJumpError();
+13 -20
View File
@@ -50,7 +50,8 @@ import type {
AgentPermissionResponse,
} from "@getpaseo/protocol/agent-types";
import type { AgentScreenAgent } from "@/hooks/use-agent-screen-state-machine";
import { useSessionStore } from "@/stores/session-store";
import { useHostRuntimeClient } from "@/runtime/host-runtime";
import { useAgentStreamView, useServerFeature } from "@/stores/session-store-hooks";
import { useRevealedText } from "@/hooks/use-revealed-text";
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
import { useLoadOlderAgentHistory } from "@/hooks/use-load-older-agent-history";
@@ -71,7 +72,7 @@ import { resolveStreamRenderStrategy } from "./strategy-resolver";
import { type StreamSegmentRenderers, type StreamViewportHandle } from "./strategy";
import { ChatOutlineRail } from "@/agent-stream/chat-outline/rail";
import { useChatOutline } from "@/agent-stream/chat-outline/use-chat-outline";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { loadAgentTimelineWindow } from "@/timeline/agent-timeline-service";
import { planTimelineTailFetch } from "@/timeline/timeline-sync-plan";
import {
CompletedTurnFooterRow,
@@ -378,26 +379,18 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
const resolvedServerId = serverId ?? context.serverId ?? "";
const transformTimelineItem = useInstalledTimelineTransform(resolvedServerId);
const client = useSessionStore((state) => state.sessions[resolvedServerId]?.client ?? null);
const sessionStreamHead = useSessionStore((state) =>
state.sessions[resolvedServerId]?.agentStreamHead?.get(agentId),
);
const client = useHostRuntimeClient(resolvedServerId);
const sessionStream = useAgentStreamView(resolvedServerId, agentId);
const sessionStreamHead = sessionStream.head;
const streamHead = providedStreamHead ?? sessionStreamHead;
const forkAgent = useForkAgent({ serverId: resolvedServerId, toast, readOnly });
const supportsAgentForkContextCursor = useSessionStore(
(state) =>
state.sessions[resolvedServerId]?.serverInfo?.features?.agentForkContextCursor === true,
);
const supportsChatOutline = useSessionStore(
(state) =>
state.sessions[resolvedServerId]?.serverInfo?.features?.agentTimelinePromptIndex === true,
);
const timelineEpoch = useSessionStore(
(state) => state.sessions[resolvedServerId]?.agentTimelineCursor.get(agentId)?.epoch ?? null,
);
const isTimelineDetached = useSessionStore(
(state) => state.sessions[resolvedServerId]?.agentTimelineHasNewer.get(agentId) === true,
const supportsAgentForkContextCursor = useServerFeature(
resolvedServerId,
"agentForkContextCursor",
);
const supportsChatOutline = useServerFeature(resolvedServerId, "agentTimelinePromptIndex");
const timelineEpoch = sessionStream.timelineEpoch;
const isTimelineDetached = sessionStream.hasNewerTimeline;
const workspaceRoot = context.cwd?.trim() || "";
const { requestDirectoryListing } = useFileExplorerActions({
@@ -658,7 +651,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
}
void returnToTimelineTail({
fetchTail: () =>
getHostRuntimeStore().fetchAgentTimeline(resolvedServerId, agentId, {
loadAgentTimelineWindow(resolvedServerId, agentId, {
...planTimelineTailFetch(),
}),
scrollToBottom: () => viewportRef.current?.scrollToBottom("jump-to-bottom"),
+21 -32
View File
@@ -68,7 +68,7 @@ import {
resolveStartupBlocker,
resolveStartupNavigationReady,
shouldRunStartupGiveUpTimer,
startHostRuntimeBootstrap,
startDefaultHostRuntimeBootstrap,
type StartupBlocker,
} from "@/navigation/host-runtime-bootstrap";
import { registerWorkspaceRouteNavigationRef } from "@/navigation/workspace-route-navigation";
@@ -105,19 +105,20 @@ import { polyfillCrypto } from "@/polyfills/crypto";
import { polyfillNavigator } from "@/polyfills/navigator";
import { queryClient } from "@/data/query-client";
import {
getHostRuntimeStore,
hasConfiguredLocalDaemonOverride,
setAppVisible,
useEarliestOnlineHostServerId as useRuntimeEarliestOnlineHostServerId,
useHostRegistryLoaded,
useHostMutations,
useHostRuntimeClient,
useHostRuntimeIsConnected,
useHosts,
} from "@/runtime/host-runtime";
import { getDaemonStartService } from "@/runtime/daemon-start-service";
import { getManagedDaemonStartService } from "@/runtime/daemon-start-service";
import { usePanelStore } from "@/stores/panel-store";
import { flushDraftPersistStorage } from "@/stores/draft-store";
import { getNextThemePreference } from "@/styles/theme";
import { useSessionStore } from "@/stores/session-store";
import { useConnection } from "@/stores/session-store-hooks";
import { installWebScrollbarStyles } from "@/styles/install-web-scrollbar-styles";
import type { HostProfile } from "@/types/host-connection";
import {
@@ -289,7 +290,7 @@ function LegacyFavoriteProfileMigrationBootstrap({
serverId: string;
client: NonNullable<ReturnType<typeof useHostRuntimeClient>>;
}) {
const serverInfo = useSessionStore((state) => state.sessions[serverId]?.serverInfo ?? null);
const serverInfo = useConnection(serverId, (connection) => connection.serverInfo);
const isConnected = useHostRuntimeIsConnected(serverId);
useEffect(() => {
@@ -321,27 +322,11 @@ function HostSessionManager() {
}
export function useEarliestOnlineHostServerId(): string | null {
const store = getHostRuntimeStore();
const subscribe = useCallback(
(listener: () => void) => {
const unsubscribeAll = store.subscribeAll(listener);
const unsubscribeHostList = store.subscribeHostList(listener);
return () => {
unsubscribeAll();
unsubscribeHostList();
};
},
[store],
);
return useSyncExternalStore(
subscribe,
() => store.getEarliestOnlineHostServerId(),
() => store.getEarliestOnlineHostServerId(),
);
return useRuntimeEarliestOnlineHostServerId();
}
function useDaemonStartLastError(): string | null {
const service = getDaemonStartService({ store: getHostRuntimeStore() });
const service = getManagedDaemonStartService();
return useSyncExternalStore(
(listener) => service.subscribe(listener),
() => service.getLastError(),
@@ -350,7 +335,7 @@ function useDaemonStartLastError(): string | null {
}
function useDaemonStartIsRunning(): boolean {
const service = getDaemonStartService({ store: getHostRuntimeStore() });
const service = getManagedDaemonStartService();
return useSyncExternalStore(
(listener) => service.subscribe(listener),
() => service.isRunning(),
@@ -372,16 +357,20 @@ async function shouldStartBuiltInDaemon(): Promise<boolean> {
}
function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
const pathname = usePathname();
const routeServerId = useMemo(() => parseServerIdFromPathname(pathname), [pathname]);
useEffect(() => {
const store = getHostRuntimeStore();
const daemonStartService = getDaemonStartService({ store });
startHostRuntimeBootstrap({
store,
daemonStartService,
shouldStartDaemon: shouldStartBuiltInDaemon,
});
startDefaultHostRuntimeBootstrap(shouldStartBuiltInDaemon);
}, []);
useEffect(() => {
const subscription = AppState.addEventListener("change", (nextState) => {
setAppVisible(nextState === "active", routeServerId);
});
return () => subscription.remove();
}, [routeServerId]);
const anyOnlineHostServerId = useEarliestOnlineHostServerId();
const daemonStartError = useDaemonStartLastError();
const daemonStartIsRunning = useDaemonStartIsRunning();
@@ -416,7 +405,7 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
}, [shouldRunGiveUpTimer]);
const retry = useCallback(() => {
const daemonStartService = getDaemonStartService({ store: getHostRuntimeStore() });
const daemonStartService = getManagedDaemonStartService();
void daemonStartService.startIfEnabled({ shouldStart: shouldStartBuiltInDaemon });
}, []);
@@ -4,8 +4,13 @@ import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-bo
import { useFetchQuery } from "@/data/query";
import { resolveAgentRoute, type AgentRouteLookup } from "@/navigation/agent-route-resolution";
import { AgentRouteResolutionView } from "@/navigation/agent-route-resolution-view";
import { useSessionStore } from "@/stores/session-store";
import { getHostRuntimeStore, useHostRuntimeSnapshot, useHosts } from "@/runtime/host-runtime";
import { useAgentWorkspaceId } from "@/stores/session-store-hooks";
import {
prepareAgentRoute,
probeHostConnections,
useHostRuntimeSnapshot,
useHosts,
} from "@/runtime/host-runtime";
import { buildHostRootRoute, buildSettingsHostRoute } from "@/utils/host-routes";
import { toErrorMessage } from "@/utils/error-messages";
import { navigateToAgent } from "@/utils/navigate-to-agent";
@@ -32,17 +37,10 @@ function HostAgentReadyRouteContent() {
const client = runtimeSnapshot?.client ?? null;
const connectionStatus = runtimeSnapshot?.connectionStatus ?? "connecting";
const hostName = hosts.find((host) => host.serverId === serverId)?.label ?? serverId;
const agentWorkspaceId = useSessionStore((state) => {
if (!serverId || !agentId) {
return null;
}
return state.sessions[serverId]?.agents?.get(agentId)?.workspaceId ?? null;
});
const agentWorkspaceId = useAgentWorkspaceId(serverId, agentId);
useEffect(() => {
if (!serverId || !agentId || agentWorkspaceId) return;
void getHostRuntimeStore()
.prepareAgentRoute(serverId, agentId)
.catch(() => undefined);
void prepareAgentRoute(serverId, agentId).catch(() => undefined);
}, [agentId, agentWorkspaceId, serverId]);
const shouldLookupAgent = Boolean(
serverId && agentId && client && connectionStatus === "online" && !agentWorkspaceId,
@@ -118,7 +116,7 @@ function HostAgentReadyRouteContent() {
return;
}
if (serverId) {
void getHostRuntimeStore().runProbeCycleNow(serverId);
void probeHostConnections(serverId);
}
}, [lookupQuery, resolution.kind, serverId]);
const handleManageHost = useCallback(() => {
@@ -0,0 +1,137 @@
import { readdirSync, readFileSync } from "node:fs";
import { dirname, extname, join, relative } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
const SOURCE_ROOT = dirname(fileURLToPath(import.meta.url));
const UI_ROOTS = [
"app",
"agent-skills",
"agent-stream",
"command-center",
"components",
"composer",
"contexts",
"desktop",
"file-pane",
"git",
"hooks",
"panels",
"plugins",
"projects",
"provider-usage",
"screens",
"subagents",
"workspace",
"workspace-recovery",
"workspace-tabs",
] as const;
const SESSION_STORE_OWNER_FILES = new Set(["plugins/client-state/source.ts"]);
function productionSources(root: string): string[] {
const sources: string[] = [];
for (const entry of readdirSync(root, { withFileTypes: true })) {
const path = join(root, entry.name);
if (entry.isDirectory()) {
sources.push(...productionSources(path));
continue;
}
if (![".ts", ".tsx"].includes(extname(entry.name))) continue;
if (/\.(test|spec)\.[^.]+$/.test(entry.name)) continue;
sources.push(path);
}
return sources;
}
function importedModules(source: string): string[] {
return Array.from(
source.matchAll(/(?:from\s+|import\s*)["']([^"']+)["']/g),
(match) => match[1] ?? "",
);
}
function importsSessionStoreValue(source: string): boolean {
return Array.from(source.matchAll(/import\s+(?!type\b)[\s\S]*?from\s+["']([^"']+)["']/g)).some(
(match) => match[1] === "@/stores/session-store",
);
}
function importsAgentStoreProjection(path: string): boolean {
return importedModules(readFileSync(path, "utf8")).some((module) =>
module.includes("runtime/directory-sync/internal/agent-store"),
);
}
describe("app architecture boundaries", () => {
it("keeps UI leaves behind semantic session queries and commands", () => {
const violations: string[] = [];
for (const root of UI_ROOTS) {
for (const path of productionSources(join(SOURCE_ROOT, root))) {
const source = readFileSync(path, "utf8");
const sourcePath = relative(SOURCE_ROOT, path);
const modules = importedModules(source);
const reasons = [
...(importsSessionStoreValue(source) && !SESSION_STORE_OWNER_FILES.has(sourcePath)
? ["imports session-store"]
: []),
...(modules.some(
(module) =>
module.includes("runtime/replica-cache") ||
module.includes("runtime/client-replica/internal/cache"),
)
? ["imports replica cache internals"]
: []),
...(source.includes("useSessionStore") && !SESSION_STORE_OWNER_FILES.has(sourcePath)
? ["uses useSessionStore"]
: []),
...(source.includes("getHostRuntimeStore") ? ["uses getHostRuntimeStore"] : []),
];
for (const reason of reasons) {
violations.push(`${sourcePath}: ${reason}`);
}
}
}
expect(violations).toEqual([]);
});
it("keeps cache internals private to the client-replica entry point", () => {
const publicEntry = join(SOURCE_ROOT, "runtime/client-replica/index.ts");
const violations: string[] = [];
for (const path of productionSources(SOURCE_ROOT)) {
if (path === publicEntry || path.includes("/runtime/client-replica/internal/cache/"))
continue;
const modules = importedModules(readFileSync(path, "utf8"));
if (
modules.some(
(module) =>
module.includes("client-replica/internal/cache") ||
module.includes("runtime/replica-cache"),
)
) {
violations.push(relative(SOURCE_ROOT, path));
}
}
expect(violations).toEqual([]);
});
it("keeps the session hook entry point query-only", () => {
const source = readFileSync(join(SOURCE_ROOT, "stores/session-store-hooks/index.ts"), "utf8");
expect(source).not.toMatch(/export\s+(?:const|function)\s+read[A-Z]/);
expect(source).not.toMatch(/export\s+type\s+\{[^}]*SessionState/s);
expect(source).not.toMatch(/Commands\s*[={]/);
expect(source).not.toContain("useSessionStore.getState");
expect(importedModules(source)).not.toContain("@getpaseo/client/internal/daemon-client");
});
it("keeps agent store projection private to the agent directory owner", () => {
const owner = join(SOURCE_ROOT, "runtime/directory-sync/agent-replica.ts");
const violations = productionSources(SOURCE_ROOT)
.filter((path) => path !== owner && !path.includes("/runtime/directory-sync/internal/"))
.filter(importsAgentStoreProjection)
.map((path) => relative(SOURCE_ROOT, path));
expect(violations).toEqual([]);
expect(readFileSync(join(SOURCE_ROOT, "utils/agent-directory-sync.ts"), "utf8")).not.toContain(
"useSessionStore",
);
});
});
@@ -53,7 +53,7 @@ function createAgent(overrides: Partial<Agent> = {}): Agent {
parentAgentId: null,
labels: {},
...overrides,
activeTurn: overrides.activeTurn ?? null,
turn: overrides.turn ?? { phase: "idle", cancellationRequestId: null },
};
}
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { openWorkspaceFileFromExplorer } from "@/screens/workspace/workspace-file-open-command";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { usePanelStore } from "@/stores/panel-store";
import { useSessionStore } from "@/stores/session-store";
import { useHostRuntimeClient } from "@/runtime/host-runtime";
import { useWorkspaceDirectory } from "@/stores/session-store-hooks";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import { clearCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
@@ -57,9 +57,7 @@ export function useWorkspaceFileSearch(input: { enabled: boolean; query: string
const serverId = selection?.serverId ?? null;
const workspaceId = selection?.workspaceId ?? null;
const cwd = useWorkspaceDirectory(serverId, workspaceId);
const client = useSessionStore((state) =>
serverId ? (state.sessions[serverId]?.client ?? null) : null,
);
const client = useHostRuntimeClient(serverId ?? "");
const [state, setState] = useState<WorkspaceFileSearchState>(EMPTY_STATE);
const sourceKey = useMemo(
() => (serverId && workspaceId && cwd && client ? `${serverId}\0${workspaceId}\0${cwd}` : null),
@@ -179,10 +179,6 @@ export function useWorkspaceCommandCenterActions(): void {
const activeTabIndex = focusedTabs.findIndex((tab) => tab.tabId === focusedPane?.focusedTabId);
const activeTabKind =
activeTabIndex >= 0 ? (focusedTabs[activeTabIndex]?.target.kind ?? null) : null;
// One narrow projection for the workspace management contribution fields. The registry's snapshot
// dedup is unreachable (registry.ts spreads a fresh object per contribution, then compares by
// reference), so the array-identity guard in registry.replace() is the only thing stopping a
// re-render — a churny subscription here rebuilds the list while the user is looking at it.
const fields = useWorkspaceFields(serverId, workspaceId, (workspace) => ({
id: workspace.id,
workspaceDirectory: workspace.workspaceDirectory ?? null,
@@ -89,8 +89,11 @@ import {
useHostRuntimeConnectionStatuses,
} from "@/runtime/host-runtime";
import { useHostFeatureMap } from "@/runtime/host-features";
import { useSessionStore } from "@/stores/session-store";
import { useRecommendedProjectPaths } from "@/stores/session-store-hooks";
import {
acceptProjectSnapshot as upsertProject,
publishWorkspaceHydration as setHasHydratedWorkspaces,
} from "@/runtime/session-data";
import type { AddProjectFlowRequest } from "@/stores/add-project-flow-store";
import type { Theme } from "@/styles/theme";
import { shortenPath } from "@/utils/shorten-path";
@@ -372,8 +375,6 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
const recommendedPaths = useRecommendedProjectPaths(hostId);
const openProject = useOpenProject(hostId);
const cloneGithubProject = useCloneGithubProject(hostId);
const upsertProject = useSessionStore((store) => store.upsertProject);
const setHasHydratedWorkspaces = useSessionStore((store) => store.setHasHydratedWorkspaces);
const inputRef = useRef<EditingTextInputHandle>(null);
const submissionInFlightRef = useRef(false);
const browseInFlightRef = useRef(false);
@@ -751,7 +752,7 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
} finally {
submissionInFlightRef.current = false;
}
}, [client, openNewWorkspaceForProject, page, setHasHydratedWorkspaces, upsertProject]);
}, [client, openNewWorkspaceForProject, page]);
const submitActive = useCallback(() => {
if (page.kind === "new-directory-name") {
+4 -6
View File
@@ -16,7 +16,7 @@ import { useTranslation } from "react-i18next";
import { useIsCompactFormFactor } from "@/constants/layout";
import { formatTimeAgo } from "@/utils/time";
import { type AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { useSessionStore } from "@/stores/session-store";
import { getHostClient, useHostRuntimeClient } from "@/runtime/host-runtime";
import { Archive, ChevronRight } from "lucide-react-native";
import { getProviderIcon } from "@/components/provider-icons";
import { navigateToAgent } from "@/utils/navigate-to-agent";
@@ -390,9 +390,7 @@ export function AgentList({
const isMobile = useIsCompactFormFactor();
const { archiveAgent } = useArchiveAgent();
const actionClient = useSessionStore((state) =>
actionAgent?.serverId ? (state.sessions[actionAgent.serverId]?.client ?? null) : null,
);
const actionClient = useHostRuntimeClient(actionAgent?.serverId ?? "");
const isActionSheetVisible = actionAgent !== null;
const isActionDaemonUnavailable = Boolean(actionAgent?.serverId && !actionClient);
@@ -419,13 +417,13 @@ export function AgentList({
const handleAgentLongPress = useCallback(
(agent: AggregatedAgent) => {
const isRunning = agent.status === "running";
const isRunning = agent.turn.phase === "open";
if (isRunning) {
setActionAgent(agent);
return;
}
const client = useSessionStore.getState().sessions[agent.serverId]?.client ?? null;
const client = getHostClient(agent.serverId);
if (!client) {
setActionAgent(agent);
return;
@@ -17,7 +17,11 @@ import {
redactAppDiagnosticReport,
} from "@/diagnostics/app-diagnostic-report";
import { collectDesktopDiagnosticSections } from "@/diagnostics/desktop-diagnostic-report";
import { getHostRuntimeStore, useHosts, type HostRuntimeSnapshot } from "@/runtime/host-runtime";
import {
readHostRuntimeSnapshot,
useHosts,
type HostRuntimeSnapshot,
} from "@/runtime/host-runtime";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import type { HostProfile } from "@/types/host-connection";
@@ -108,11 +112,10 @@ export function AppDiagnosticSheet({
updateRunProgress("desktop", desktopLabel, desktopResult.status);
}
const store = getHostRuntimeStore();
for (const host of hosts) {
const hostProgressId = `host:${host.serverId}`;
updateRunProgress(hostProgressId, host.label, "running");
const snapshot = store.getSnapshot(host.serverId);
const snapshot = readHostRuntimeSnapshot(host.serverId);
const hostResult = await collectHostDiagnosticSections(host, snapshot);
sections.push(...hostResult.sections);
updateRunProgress(hostProgressId, host.label, hostResult.status);
@@ -1,6 +1,6 @@
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import type { WorkspaceDescriptor } from "@/stores/session-store";
import type { WorkspaceDescriptor } from "@/stores/session-store-hooks";
export interface CompactExplorerSidebarHostModel {
serverId: string;
@@ -54,8 +54,8 @@ import type {
AgentFileExplorerState,
ExplorerDirectory,
ExplorerEntry,
} from "@/stores/session-store";
import { useSessionStore } from "@/stores/session-store";
} from "@/stores/session-store-hooks";
import { useFileExplorer, useServerFeature } from "@/stores/session-store-hooks";
import { FileActionsContextMenuContent } from "@/components/file-actions-menu";
import { ContextMenu, ContextMenuTrigger, useContextMenu } from "@/components/ui/context-menu";
import { useFileDownload } from "@/hooks/use-file-download";
@@ -426,11 +426,7 @@ export function FileExplorerPane({
[normalizedWorkspaceRoot, workspaceId],
);
const hasWorkspaceScope = Boolean(workspaceStateKey && normalizedWorkspaceRoot);
const explorerState = useSessionStore((state) =>
workspaceStateKey && state.sessions[serverId]
? state.sessions[serverId]?.fileExplorer.get(workspaceStateKey)
: undefined,
);
const explorerState = useFileExplorer(serverId, workspaceStateKey);
const {
requestDirectoryListing,
@@ -455,13 +451,9 @@ export function FileExplorerPane({
workspaceDirectory: normalizedWorkspaceRoot,
});
// COMPAT(fsEntryOps): added in v0.3.0, remove gate after 2027-02-08.
const fsEntryOpsEnabled = useSessionStore(
(state) => state.sessions[serverId]?.serverInfo?.features?.fsEntryOps === true,
);
const fsEntryOpsEnabled = useServerFeature(serverId, "fsEntryOps");
// COMPAT(fsEntryDuplicate): added in v0.3.0, remove gate after 2027-02-09.
const fsEntryDuplicateEnabled = useSessionStore(
(state) => state.sessions[serverId]?.serverInfo?.features?.fsEntryDuplicate === true,
);
const fsEntryDuplicateEnabled = useServerFeature(serverId, "fsEntryDuplicate");
const [pendingEdit, setPendingEdit] = useState<ExplorerPendingEdit | null>(null);
const downloadFile = useFileDownload({
serverId,
@@ -5,9 +5,9 @@ import { useToast } from "@/contexts/toast-context";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { RewindMode } from "./use-rewind-capabilities";
import { useRewindComposerRestore } from "./composer-restore";
import { useSessionStore } from "@/stores/session-store";
import { getAgentTimelineLoadSnapshot } from "@/runtime/session-data";
import { shouldRestoreComposerForRewindMode } from "./rewind-mode";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { fetchAgentTimeline } from "@/runtime/host-runtime";
interface UseRewindAgentMutationInput {
serverId?: string;
@@ -35,13 +35,12 @@ export function useRewindAgentMutation(input: UseRewindAgentMutationInput): {
}
await input.client.rewindAgent(input.agentId, input.messageId, mode);
if (mode !== "files") {
const cursor = input.serverId
? useSessionStore
.getState()
.sessions[input.serverId]?.agentTimelineCursor.get(input.agentId)
: undefined;
const timeline = input.serverId
? getAgentTimelineLoadSnapshot(input.serverId, input.agentId).timeline
: null;
const cursor = timeline?.status === "synced" ? timeline.range : undefined;
if (!input.serverId) throw new Error(t("common.errors.daemonClientUnavailable"));
await getHostRuntimeStore().fetchAgentTimeline(input.serverId, input.agentId, {
await fetchAgentTimeline(input.serverId, input.agentId, {
direction: "tail",
projection: "projected",
...(cursor ? { cursor: { epoch: cursor.epoch, seq: cursor.endSeq } } : {}),
@@ -14,7 +14,6 @@ import { Brain, Folder, GitBranch } from "lucide-react-native";
import { StyleSheet } from "react-native-unistyles";
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
import type { ScheduleCadence, ScheduleSummary } from "@getpaseo/protocol/schedule/types";
import { useStoreWithEqualityFn } from "zustand/traditional";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { ComboboxItem } from "@/components/ui/combobox";
import { Button } from "@/components/ui/button";
@@ -43,7 +42,7 @@ import { useScheduleMutations } from "@/hooks/use-schedule-mutations";
import { useAggregatedAgents } from "@/hooks/use-aggregated-agents";
import { useProjects } from "@/hooks/use-projects";
import { useHosts } from "@/runtime/host-runtime";
import { useSessionStore } from "@/stores/session-store";
import { useConnections, type ConnectionView } from "@/stores/session-store-hooks";
import { buildScheduleProjectTargets } from "@/schedules/schedule-project-targets";
import { useScheduleFormModel } from "@/schedules/use-schedule-form-model";
import { useScheduleFormProviderSnapshot } from "@/schedules/use-schedule-form-provider-snapshot";
@@ -114,14 +113,14 @@ function openKey(props: ScheduleFormSheetProps): string {
function selectScheduleHosts(
hosts: readonly { serverId: string; label: string }[],
): (state: ReturnType<typeof useSessionStore.getState>) => ScheduleFormHost[] {
return (state) =>
hosts.map((host) => ({
serverId: host.serverId,
label: host.label,
supportsWorkspaceMultiplicity:
state.sessions[host.serverId]?.serverInfo?.features?.workspaceMultiplicity === true,
}));
connections: ReadonlyMap<string, ConnectionView>,
): ScheduleFormHost[] {
return hosts.map((host) => ({
serverId: host.serverId,
label: host.label,
supportsWorkspaceMultiplicity:
connections.get(host.serverId)?.serverInfo?.features?.workspaceMultiplicity === true,
}));
}
function buildSnapshot(input: {
@@ -242,9 +241,10 @@ function OpenScheduleFormSheet({
const controlSize: FieldControlSize = useIsCompactFormFactor() ? "md" : "sm";
const { projects } = useProjects();
const hostProfiles = useHosts();
const hosts = useStoreWithEqualityFn(
useSessionStore,
useMemo(() => selectScheduleHosts(hostProfiles), [hostProfiles]),
const hostIds = useMemo(() => hostProfiles.map((host) => host.serverId), [hostProfiles]);
const hosts = useConnections(
hostIds,
(connections) => selectScheduleHosts(hostProfiles, connections),
equal,
);
const { preferences, updatePreferences } = useFormPreferences();
@@ -40,7 +40,7 @@ import { ExternalLink, Settings, MoreVertical, Plus, Trash2 } from "lucide-react
import { NestableScrollContainer } from "react-native-draggable-flatlist";
import { DraggableList, type DraggableRenderItemInfo } from "./draggable-list";
import type { DraggableListDragHandleProps } from "./draggable-list.types";
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
import { getHostClient, useHosts } from "@/runtime/host-runtime";
import type { PinnedSidebarGroups } from "@/hooks/use-sidebar-pins";
import {
useSidebarWorkspacePinController,
@@ -1703,7 +1703,7 @@ function ProjectBlock({
void removeProjectFromHosts({
targets: readiness.targets,
getClient: (serverId) => getHostRuntimeStore().getClient(serverId),
getClient: (serverId) => getHostClient(serverId),
})
.then((outcome) => {
if (outcome.kind === "host_disconnected") {
@@ -19,7 +19,7 @@ import { useAppDiagnosticStore } from "@/diagnostics/store";
import { useKeyboardShortcutsAvailable } from "@/keyboard/availability";
import { useHostRuntimeIsConnected, useHosts } from "@/runtime/host-runtime";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { useSessionStore } from "@/stores/session-store";
import { useConnection } from "@/stores/session-store-hooks";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import type { HostProfile } from "@/types/host-connection";
import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates";
@@ -58,8 +58,9 @@ const changelogLeadingIcon = (
function HostVersionHint({ host }: { host: HostProfile }) {
const { t } = useTranslation();
const isConnected = useHostRuntimeIsConnected(host.serverId);
const daemonVersion = useSessionStore(
(state) => state.sessions[host.serverId]?.serverInfo?.version ?? null,
const daemonVersion = useConnection(
host.serverId,
({ serverInfo }) => serverInfo?.version ?? null,
);
const version = isConnected
? formatVersionWithPrefix(daemonVersion)
+6 -20
View File
@@ -50,7 +50,8 @@ import {
import { resolveTerminalRestoreOptions } from "@/terminal/runtime/terminal-restore-options";
import { usePanelStore } from "@/stores/panel-store";
import { useBlockMobilePanelOpenGestures } from "@/mobile-panels/provider";
import { useSessionStore } from "@/stores/session-store";
import { useServerFeature } from "@/stores/session-store-hooks";
import { publishFocusedTerminal as setFocusedTerminalId } from "@/runtime/session-data";
import { toXtermTheme } from "@/utils/to-xterm-theme";
import TerminalEmulator, { type TerminalEmulatorHandle } from "./terminal-emulator";
import { TerminalFloatingCopyAction, TerminalPasteAction } from "./terminal-copy-paste-actions";
@@ -233,17 +234,9 @@ export function TerminalPane({
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const isTerminalPresented = retainedPanelActive && isWorkspaceFocused;
const supportsTerminalRestoreModes = useSessionStore(
(state) => state.sessions[serverId]?.serverInfo?.features?.["terminal-restore-modes"] === true,
);
const supportsTerminalInputModeReplay = useSessionStore(
(state) =>
state.sessions[serverId]?.serverInfo?.features?.["terminal-input-mode-replay"] === true,
);
const supportsTerminalSizeOwnership = useSessionStore(
(state) => state.sessions[serverId]?.serverInfo?.features?.["terminal-size-ownership"] === true,
);
const setFocusedTerminalId = useSessionStore((state) => state.setFocusedTerminalId);
const supportsTerminalRestoreModes = useServerFeature(serverId, "terminal-restore-modes");
const supportsTerminalInputModeReplay = useServerFeature(serverId, "terminal-input-mode-replay");
const supportsTerminalSizeOwnership = useServerFeature(serverId, "terminal-size-ownership");
const scopeKey = useMemo(() => terminalScopeKey({ serverId, cwd }), [serverId, cwd]);
const terminalStreamKey = useMemo(() => `${scopeKey}:${terminalId}`, [scopeKey, terminalId]);
@@ -393,14 +386,7 @@ export function TerminalPane({
lastSentTerminalSizeRef.current = null;
requestTerminalReflow();
emulatorRef.current?.claimSize();
}, [
isPaneFocused,
isWorkspaceFocused,
requestTerminalReflow,
serverId,
setFocusedTerminalId,
terminalId,
]);
}, [isPaneFocused, isWorkspaceFocused, requestTerminalReflow, serverId, terminalId]);
const clearKeyboardRefitTimeouts = useCallback(() => {
if (keyboardRefitTimeoutsRef.current.length === 0) {
+23 -36
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Pressable, Text, View, ScrollView } from "react-native";
import { useRouter } from "expo-router";
@@ -13,7 +13,12 @@ import {
} from "lucide-react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import type { HostProfile } from "@/types/host-connection";
import { getHostRuntimeStore, isHostRuntimeConnected, useHosts } from "@/runtime/host-runtime";
import {
isHostRuntimeConnected,
readHostRuntimeSnapshot,
useHostRuntimeVersion,
useHosts,
} from "@/runtime/host-runtime";
import { AddHostModal } from "./add-host-modal";
import { AddRemoteSshHostModal } from "./add-remote-ssh-host-modal";
import { PairLinkModal } from "./pair-link-modal";
@@ -124,42 +129,24 @@ const styles = StyleSheet.create((theme) => ({
}));
function useAnyHostOnline(serverIds: string[]): string | null {
const runtime = getHostRuntimeStore();
return useSyncExternalStore(
(onStoreChange) => runtime.subscribeAll(onStoreChange),
() => {
let firstOnlineServerId: string | null = null;
let firstOnlineAt: string | null = null;
for (const serverId of serverIds) {
const snapshot = runtime.getSnapshot(serverId);
const lastOnlineAt = snapshot?.lastOnlineAt ?? null;
if (!isHostRuntimeConnected(snapshot) || !lastOnlineAt) {
continue;
}
if (!firstOnlineAt || lastOnlineAt < firstOnlineAt) {
firstOnlineAt = lastOnlineAt;
firstOnlineServerId = serverId;
}
const runtimeVersion = useHostRuntimeVersion();
return useMemo(() => {
void runtimeVersion;
let firstOnlineServerId: string | null = null;
let firstOnlineAt: string | null = null;
for (const serverId of serverIds) {
const snapshot = readHostRuntimeSnapshot(serverId);
const lastOnlineAt = snapshot?.lastOnlineAt ?? null;
if (!isHostRuntimeConnected(snapshot) || !lastOnlineAt) {
continue;
}
return firstOnlineServerId;
},
() => {
let firstOnlineServerId: string | null = null;
let firstOnlineAt: string | null = null;
for (const serverId of serverIds) {
const snapshot = runtime.getSnapshot(serverId);
const lastOnlineAt = snapshot?.lastOnlineAt ?? null;
if (!isHostRuntimeConnected(snapshot) || !lastOnlineAt) {
continue;
}
if (!firstOnlineAt || lastOnlineAt < firstOnlineAt) {
firstOnlineAt = lastOnlineAt;
firstOnlineServerId = serverId;
}
if (!firstOnlineAt || lastOnlineAt < firstOnlineAt) {
firstOnlineAt = lastOnlineAt;
firstOnlineServerId = serverId;
}
return firstOnlineServerId;
},
);
}
return firstOnlineServerId;
}, [runtimeVersion, serverIds]);
}
export interface WelcomeScreenProps {
@@ -2,7 +2,7 @@ import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { useMutation } from "@tanstack/react-query";
import { AdaptiveRenameModal } from "@/components/rename-modal";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { getHostClient } from "@/runtime/host-runtime";
// The subset of a workspace the rename dialog needs. Narrower than SidebarWorkspaceEntry so the
// command center can build one from the active route selection without a sidebar row.
@@ -39,7 +39,7 @@ export function WorkspaceRenameModal({
const renameMutation = useMutation({
mutationFn: async (title: string) => {
const client = getHostRuntimeStore().getClient(workspace.serverId);
const client = getHostClient(workspace.serverId);
if (!client) {
throw new Error(t("sidebar.workspace.toasts.hostDisconnected"));
}
@@ -12,7 +12,12 @@ import { useToast } from "@/contexts/toast-context";
import { useAgentInputDraft } from "@/composer/draft/input-draft";
import { useProjectIcon } from "@/projects/icons";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store";
import {
acceptAgentSnapshot,
acceptWorkspaceSnapshots as mergeWorkspaces,
publishWorkspaceHydration as setHasHydratedWorkspaces,
} from "@/runtime/session-data";
import { normalizeWorkspaceDescriptor, useServerFeature } from "@/stores/session-store-hooks";
import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
import { applyLegacyDaemonWorkspaceOwnership } from "@/workspace/legacy-daemon-workspaces";
@@ -164,9 +169,6 @@ export function WorkspaceSetupDialog() {
const toast = useToast();
const pendingWorkspaceSetup = useWorkspaceSetupStore((state) => state.pendingWorkspaceSetup);
const clearWorkspaceSetup = useWorkspaceSetupStore((state) => state.clearWorkspaceSetup);
const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces);
const setHasHydratedWorkspaces = useSessionStore((state) => state.setHasHydratedWorkspaces);
const setAgents = useSessionStore((state) => state.setAgents);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [createdWorkspace, setCreatedWorkspace] = useState<ReturnType<
typeof normalizeWorkspaceDescriptor
@@ -174,9 +176,7 @@ export function WorkspaceSetupDialog() {
const [pendingAction, setPendingAction] = useState<"chat" | null>(null);
const serverId = pendingWorkspaceSetup?.serverId ?? "";
const supportsForgeSearch = useSessionStore(
(state) => state.sessions[serverId]?.serverInfo?.features?.forgeSearch === true,
);
const supportsForgeSearch = useServerFeature(serverId, "forgeSearch");
const sourceDirectory = pendingWorkspaceSetup?.sourceDirectory ?? "";
const displayName = pendingWorkspaceSetup?.displayName?.trim() ?? "";
const workspace = createdWorkspace;
@@ -278,14 +278,7 @@ export function WorkspaceSetupDialog() {
setCreatedWorkspace(normalizedWorkspace);
return normalizedWorkspace;
},
[
createdWorkspace,
mergeWorkspaces,
pendingWorkspaceSetup,
setHasHydratedWorkspaces,
t,
withConnectedClient,
],
[createdWorkspace, pendingWorkspaceSetup, t, withConnectedClient],
);
const getIsStillActive = useCallback(() => {
@@ -341,17 +334,13 @@ export function WorkspaceSetupDialog() {
return;
}
setAgents(serverId, (previous) => {
const next = new Map(previous);
next.set(
agent.id,
applyLegacyDaemonWorkspaceOwnership({
serverId,
agent: normalizeAgentSnapshot(agent, serverId),
}),
);
return next;
});
acceptAgentSnapshot(
serverId,
applyLegacyDaemonWorkspaceOwnership({
serverId,
agent: normalizeAgentSnapshot(agent, serverId),
}),
);
navigateAfterCreation(ensuredWorkspace.id, { kind: "agent", agentId: agent.id });
} catch (error) {
const message = toErrorMessage(error);
@@ -368,7 +357,6 @@ export function WorkspaceSetupDialog() {
getIsStillActive,
navigateAfterCreation,
serverId,
setAgents,
ensureWorkspace,
t,
toast,
@@ -22,7 +22,6 @@ import {
type ViewStyle,
} from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useShallow } from "zustand/shallow";
import { Settings2 } from "lucide-react-native";
import { getAgentFeatureIcon, ThinkingIcon } from "@/agent-controls/icons";
import { formatThinkingOptionLabel } from "@/agent-controls/labels";
@@ -34,7 +33,8 @@ import {
type ProviderSelectorProvider,
} from "@/provider-selection/provider-selection";
import { filterSelectableModels } from "@/provider-selection/model-catalog";
import { useSessionStore } from "@/stores/session-store";
import { useActiveAgentFields, type Agent } from "@/stores/session-store-hooks";
import { useHostRuntimeClient } from "@/runtime/host-runtime";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import { resolveProviderDefinition } from "@/utils/provider-definitions";
import { mergeProviderPreferences, useFormPreferences } from "@/hooks/use-form-preferences";
@@ -398,15 +398,7 @@ type AgentControlsSlice = {
lastUsage: unknown;
} | null;
function selectAgentControlsSlice(
state: ReturnType<typeof useSessionStore.getState>,
serverId: string,
agentId: string,
): AgentControlsSlice {
const currentAgent = state.sessions[serverId]?.agents?.get(agentId) ?? null;
if (!currentAgent) {
return null;
}
function selectAgentControlsSlice(currentAgent: Agent): AgentControlsSlice {
return {
provider: currentAgent.provider,
cwd: currentAgent.cwd,
@@ -1541,10 +1533,8 @@ export const AgentControls = memo(function AgentControls({
isCompactLayout,
}: AgentControlsProps) {
const { updatePreferences } = useFormPreferences();
const agent = useSessionStore(
useShallow((state) => selectAgentControlsSlice(state, serverId, agentId)),
);
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
const agent = useActiveAgentFields(serverId, agentId, selectAgentControlsSlice);
const client = useHostRuntimeClient(serverId);
const toast = useToast();
const modeControl = useLiveAgentModeControl(serverId, agentId);
const commandCenterModes = toCommandCenterModes(modeControl);
@@ -2,8 +2,6 @@ import { useCallback, useMemo, useRef, useState, type ReactElement } from "react
import { useTranslation } from "react-i18next";
import { Text, View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useShallow } from "zustand/shallow";
import { useStoreWithEqualityFn } from "zustand/traditional";
import { type SheetHeader } from "@/components/adaptive-modal-sheet";
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
@@ -17,7 +15,8 @@ import { resolveNextAgentModeId } from "@/composer/agent-controls/mode";
import { useComposerKeyboardScope } from "@/composer/keyboard-scope";
import { useComposerControlLayout } from "@/composer/agent-controls/layout-context";
import { AgentControlTrigger } from "@/composer/agent-controls/control";
import { useSessionStore } from "@/stores/session-store";
import { useActiveAgentFields } from "@/stores/session-store-hooks";
import { useHostRuntimeClient } from "@/runtime/host-runtime";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import { mergeProviderPreferences, useFormPreferences } from "@/hooks/use-form-preferences";
import { resolveProviderDefinition } from "@/utils/provider-definitions";
@@ -238,31 +237,18 @@ export function AgentModeControl({
const EMPTY_MODES: AgentMode[] = [];
function compareAvailableModes(a: AgentMode[], b: AgentMode[]): boolean {
return a === b || JSON.stringify(a) === JSON.stringify(b);
}
export function useLiveAgentModeControl(
serverId: string,
agentId: string,
): AgentModeControlValue | null {
const slice = useSessionStore(
useShallow((state) => {
const agent = state.sessions[serverId]?.agents?.get(agentId);
if (!agent) return null;
return {
provider: agent.provider,
cwd: agent.cwd,
currentModeId: agent.currentModeId,
};
}),
);
const availableModes = useStoreWithEqualityFn(
useSessionStore,
(state) => state.sessions[serverId]?.agents?.get(agentId)?.availableModes ?? EMPTY_MODES,
compareAvailableModes,
);
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
const slice = useActiveAgentFields(serverId, agentId, (agent) => ({
provider: agent.provider,
cwd: agent.cwd,
currentModeId: agent.currentModeId,
availableModes: agent.availableModes,
}));
const availableModes = slice?.availableModes ?? EMPTY_MODES;
const client = useHostRuntimeClient(serverId);
const { updatePreferences } = useFormPreferences();
const toast = useToast();
const { entries: snapshotEntries } = useProvidersSnapshot(serverId, { cwd: slice?.cwd });
@@ -7,7 +7,7 @@ import {
} from "@/composer/attachments/submit";
import { useCreateFlowStore } from "@/stores/create-flow-store";
import { handoffCreatedAgentMessageSubmission } from "@/composer/submission/writer";
import { useSessionStore } from "@/stores/session-store";
import { hostSupports } from "@/runtime/session-data";
import {
createUserMessage,
generateMessageId,
@@ -267,9 +267,7 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
dispatch({ type: "DRAFT_SET_ERROR", message: error.message });
throw error;
}
const supportsForgeSearch =
useSessionStore.getState().sessions[pendingServerId]?.serverInfo?.features?.forgeSearch ===
true;
const supportsForgeSearch = hostSupports(pendingServerId, "forgeSearch");
const wirePayload = splitComposerAttachmentsForSubmit(attachments, {
format: resolveComposerAttachmentSubmitFormat({
supportsForgeAttachments: supportsForgeSearch,
@@ -20,7 +20,7 @@ import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-
import { buildWorkspaceDraftAgentConfig } from "@/screens/workspace/workspace-draft-agent-config";
import { buildDraftStoreKey } from "@/stores/draft-keys";
import { useCreateFlowStore } from "@/stores/create-flow-store";
import type { Agent } from "@/stores/session-store";
import type { Agent } from "@/stores/session-store-hooks";
import { useWorkspaceFields } from "@/stores/session-store-hooks";
import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store";
import { useAgentControlCommandCenterActions } from "@/command-center/agent-control-registration";
@@ -248,7 +248,12 @@ function buildDraftAgentSnapshot(input: {
id: tabId,
provider,
status: "running",
activeTurn: null,
turn: {
phase: "open",
turnId: null,
startedAt: now,
cancellationRequestId: null,
},
createdAt: now,
updatedAt: now,
lastUserMessageAt: now,
@@ -342,9 +347,9 @@ export function WorkspaceDraftAgentTab({
const insets = useSafeAreaInsets();
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const workspaceFields = useWorkspaceFields(serverId, workspaceId, (w) => ({
workspaceDirectory: w.workspaceDirectory,
id: w.id,
const workspaceFields = useWorkspaceFields(serverId, workspaceId, (workspace) => ({
id: workspace.id,
workspaceDirectory: workspace.workspaceDirectory,
}));
const workspaceDirectory = workspaceFields?.workspaceDirectory || null;
const draftSetup = initialSetup ?? null;
+37 -54
View File
@@ -20,7 +20,6 @@ import {
import { useTranslation } from "react-i18next";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useShallow } from "zustand/shallow";
import {
ArrowUp,
Square,
@@ -43,7 +42,18 @@ import {
import { ContextWindowMeter } from "@/components/context-window-meter";
import { KeyboardTranslateView } from "@/components/keyboard-translate-view";
import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker";
import { selectAgentTurnPresentation, useSessionStore } from "@/stores/session-store";
import {
useActiveAgentFields,
type Agent,
useAgentTurn,
useComposerSession,
useServerFeature,
} from "@/stores/session-store-hooks";
import {
getAgentTurnSnapshot,
getQueuedMessagesSnapshot,
publishQueuedMessages as setQueuedMessages,
} from "@/runtime/session-data";
import { useFilePicker } from "@/hooks/use-file-picker";
import { useFileDrop } from "@/components/file-drop/use-file-drop";
import type { DroppedItem } from "@/components/file-drop/types";
@@ -93,6 +103,7 @@ import {
useHostRuntimeClient,
useHostRuntimeIsConnected,
} from "@/runtime/host-runtime";
import { beginAgentCancellation, settleAgentCancellation } from "@/runtime/session-data";
import {
deleteAttachments,
persistAttachmentFromBlob,
@@ -254,17 +265,14 @@ function buildRealtimeVoiceButtonStyle(
);
}
function buildAgentStateSelector(serverId: string, agentId: string) {
return (state: ReturnType<typeof useSessionStore.getState>) => {
const agent = state.sessions[serverId]?.agents?.get(agentId) ?? null;
return {
status: agent?.status ?? null,
contextWindowMaxTokens: agent?.lastUsage?.contextWindowMaxTokens ?? null,
contextWindowUsedTokens: agent?.lastUsage?.contextWindowUsedTokens ?? null,
totalCostUsd: agent?.lastUsage?.totalCostUsd ?? null,
model: agent?.model ?? null,
provider: agent?.provider ?? null,
};
function buildAgentState(agent: Agent) {
return {
status: agent.status,
contextWindowMaxTokens: agent.lastUsage?.contextWindowMaxTokens ?? null,
contextWindowUsedTokens: agent.lastUsage?.contextWindowUsedTokens ?? null,
totalCostUsd: agent.lastUsage?.totalCostUsd ?? null,
model: agent.model,
provider: agent.provider,
};
}
@@ -981,7 +989,6 @@ interface ComposerProps {
const MAX_FILE_SIZE_BYTES = 50 * 1024 * 1024;
const EMPTY_ARRAY: readonly QueuedMessage[] = [];
const StableMessageInput = memo(MessageInput);
function resolveContextWindowValues(
@@ -1203,14 +1210,15 @@ function ComposerContentImpl({
const { settings: appSettings } = useAppSettings();
const agentState = useSessionStore(useShallow(buildAgentStateSelector(serverId, agentId)));
const queuedMessagesRaw = useSessionStore((state) =>
state.sessions[serverId]?.queuedMessages?.get(agentId),
);
const queuedMessages = queuedMessagesRaw ?? EMPTY_ARRAY;
const setQueuedMessages = useSessionStore((state) => state.setQueuedMessages);
const agentState = useActiveAgentFields(serverId, agentId, buildAgentState) ?? {
status: null,
contextWindowMaxTokens: null,
contextWindowUsedTokens: null,
totalCostUsd: null,
model: null,
provider: null,
};
const { queuedMessages, hasPendingPermission } = useComposerSession(serverId, agentId);
const isCompactFormFactor = useIsCompactFormFactor();
const isCompactLayout = resolveCompactLayout(isCompactLayoutOverride, isCompactFormFactor);
@@ -1236,9 +1244,7 @@ function ComposerContentImpl({
});
const setSelectedAttachments = onChangeAttachments;
const checkoutStatusQuery = useCheckoutStatusQuery({ serverId, cwd });
const supportsForgeSearch = useSessionStore(
(state) => state.sessions[serverId]?.serverInfo?.features?.forgeSearch === true,
);
const supportsForgeSearch = useServerFeature(serverId, "forgeSearch");
const forgeAutoAttach = useComposerForgeAutoAttach({
text: userInput,
remoteUrl: resolveCheckoutRemoteUrl(checkoutStatusQuery.status),
@@ -1487,8 +1493,7 @@ function ComposerContentImpl({
activeTurnBehavior,
activeTurnId:
activeTurnBehavior === "steer"
? (useSessionStore.getState().sessions[serverId]?.agents.get(targetAgentId)?.activeTurn
?.turnId ?? undefined)
? (getAgentTurnSnapshot(serverId, targetAgentId).turnId ?? undefined)
: undefined,
});
onAttentionPromptSend?.();
@@ -1499,25 +1504,11 @@ function ComposerContentImpl({
onSubmitMessageRef.current = onSubmitMessage;
}, [onSubmitMessage]);
const hasActiveTurn = useSessionStore(
(state) => selectAgentTurnPresentation(state.sessions[serverId], agentId).isActive,
);
const isCancellingAgent = useSessionStore(
(state) => selectAgentTurnPresentation(state.sessions[serverId], agentId).isCancelling,
);
const beginAgentCancellation = useSessionStore((state) => state.beginAgentCancellation);
const settleAgentCancellation = useSessionStore((state) => state.settleAgentCancellation);
const hasActiveTurn = useAgentTurn(serverId, agentId, (turn) => turn.isActive);
const isCancellingAgent = useAgentTurn(serverId, agentId, (turn) => turn.isCancelling);
const isAgentRunning = hasActiveTurn;
// Queueing behind a permission prompt would strand the message: the turn is
// parked until the request is answered.
const hasPendingPermission = useSessionStore((state) => {
const pendingPermissions = state.sessions[serverId]?.pendingPermissions;
if (!pendingPermissions) return false;
for (const permission of pendingPermissions.values()) {
if (permission.agentId === agentId) return true;
}
return false;
});
const activeSendBehavior = resolveActiveSendBehavior(
appSettings.sendBehavior,
hasPendingPermission,
@@ -1526,10 +1517,10 @@ function ComposerContentImpl({
const queueWriter = useMemo<QueueWriter>(
() => ({
read: (id) => useSessionStore.getState().sessions[serverId]?.queuedMessages?.get(id) ?? [],
read: (id) => getQueuedMessagesSnapshot(serverId, id),
write: (updater) => setQueuedMessages(serverId, updater),
}),
[serverId, setQueuedMessages],
[serverId],
);
const queueMessage = useCallback(
@@ -1824,15 +1815,7 @@ function ComposerContentImpl({
settleAgentCancellation(serverId, targetAgentId, requestId);
});
messageInputRef.current?.focus();
}, [
beginAgentCancellation,
client,
isAgentRunning,
isCancellingAgent,
isConnected,
serverId,
settleAgentCancellation,
]);
}, [client, isAgentRunning, isCancellingAgent, isConnected, serverId]);
const focusMessageInputForKeyboardAction = useCallback(() => {
focusMessageInputWithPlatformStrategy(messageInputRef);
+6 -11
View File
@@ -26,7 +26,7 @@ import { useDictation } from "@/hooks/use-dictation";
import { DictationOverlay } from "@/components/dictation-controls";
import { RealtimeVoiceOverlay } from "@/components/realtime-voice-overlay";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { useSessionStore } from "@/stores/session-store";
import { useConnection } from "@/stores/session-store-hooks";
import { useVoiceOptional } from "@/contexts/voice-context";
import { useToast } from "@/contexts/toast-context";
import { resolveVoiceUnavailableMessage } from "@/utils/server-info-capabilities";
@@ -1274,16 +1274,11 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
getNativeElement: () => (isWeb ? getTextInputNativeElement(textInputRef.current) : null),
}));
const sendAfterTranscriptRef = useRef(false);
const serverInfo = useSessionStore(
useCallback(
(state) => {
if (!voiceServerId) {
return null;
}
return state.sessions[voiceServerId]?.serverInfo ?? null;
},
[voiceServerId],
),
const serverInfo = useConnection(
voiceServerId,
useCallback((connection) => {
return connection.serverInfo;
}, []),
);
useEffect(() => {
+22 -27
View File
@@ -1,5 +1,13 @@
import type { MessageSubmissionWriter } from "@/composer/actions";
import { useSessionStore } from "@/stores/session-store";
import {
acceptMessageSubmission,
beginMessageSubmission,
handoffCreatedAgentSubmission,
getAgentStreamSnapshot,
hostSupports,
publishAgentStreamState,
rejectMessageSubmission,
} from "@/runtime/session-data";
import {
appendSubmittedUserMessage,
removeSubmittedUserMessage,
@@ -7,13 +15,8 @@ import {
} from "@/types/stream";
function appendUntrackedSubmission(serverId: string, agentId: string, message: UserMessageItem) {
const session = useSessionStore.getState().sessions[serverId];
if (!session) return;
const tail = session.agentStreamTail.get(agentId) ?? [];
const head = session.agentStreamHead.get(agentId) ?? [];
useSessionStore
.getState()
.setAgentStreamState(serverId, agentId, appendSubmittedUserMessage({ tail, head, message }));
const { tail, head } = getAgentStreamSnapshot(serverId, agentId);
publishAgentStreamState(serverId, agentId, appendSubmittedUserMessage({ tail, head, message }));
}
function removeUntrackedSubmission(
@@ -21,17 +24,12 @@ function removeUntrackedSubmission(
agentId: string,
clientMessageId: string,
): void {
const session = useSessionStore.getState().sessions[serverId];
if (!session) return;
const tail = session.agentStreamTail.get(agentId) ?? [];
const head = session.agentStreamHead.get(agentId) ?? [];
useSessionStore
.getState()
.setAgentStreamState(
serverId,
agentId,
removeSubmittedUserMessage({ tail, head, clientMessageId }),
);
const { tail, head } = getAgentStreamSnapshot(serverId, agentId);
publishAgentStreamState(
serverId,
agentId,
removeSubmittedUserMessage({ tail, head, clientMessageId }),
);
}
function createUntrackedMessageSubmissionWriter(serverId: string): MessageSubmissionWriter {
@@ -46,20 +44,17 @@ function createUntrackedMessageSubmissionWriter(serverId: string): MessageSubmis
}
export function createMessageSubmissionWriter(serverId: string): MessageSubmissionWriter {
const supportsTrackedMessageSubmissions =
useSessionStore.getState().sessions[serverId]?.serverInfo?.features
?.canonicalSubmittedPrompts === true;
const supportsTrackedMessageSubmissions = hostSupports(serverId, "canonicalSubmittedPrompts");
if (!supportsTrackedMessageSubmissions) {
// COMPAT(canonicalSubmittedPrompts): added in v0.2.6; remove the gate after 2027-01-31 once daemon floor >= v0.2.6.
return createUntrackedMessageSubmissionWriter(serverId);
}
return {
begin: (agentId, message) =>
useSessionStore.getState().beginAgentMessageSubmission(serverId, agentId, message),
begin: (agentId, message) => beginMessageSubmission(serverId, agentId, message),
accept: (agentId, clientMessageId) =>
useSessionStore.getState().acceptAgentMessageSubmission(serverId, agentId, clientMessageId),
acceptMessageSubmission(serverId, agentId, clientMessageId),
reject: (agentId, clientMessageId) =>
useSessionStore.getState().rejectAgentMessageSubmission(serverId, agentId, clientMessageId),
rejectMessageSubmission(serverId, agentId, clientMessageId),
};
}
@@ -68,5 +63,5 @@ export function handoffCreatedAgentMessageSubmission(
agentId: string,
message: UserMessageItem,
): boolean {
return useSessionStore.getState().handoffCreatedAgentUserMessage(serverId, agentId, message);
return handoffCreatedAgentSubmission(serverId, agentId, message);
}
@@ -1,4 +1,4 @@
import type { WorkspaceDescriptor } from "@/stores/session-store";
import type { WorkspaceDescriptor } from "@/stores/session-store-hooks";
import { useWorkspaceFields } from "@/stores/session-store-hooks";
type DiffStat = NonNullable<WorkspaceDescriptor["diffStat"]>;
+44 -98
View File
@@ -33,14 +33,29 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { AgentSessionConfig } from "@getpaseo/protocol/agent-types";
import type { GitSetupOptions } from "@getpaseo/protocol/messages";
import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types";
import { getHostRuntimeStore, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import {
createViewedTimelineOwnerForHost,
fetchAgentTimeline,
useHostRuntimeIsConnected,
} from "@/runtime/host-runtime";
import {
applyAgentTurn,
getAgentConversationSnapshot,
getAgentSnapshot,
getAgentStreamSnapshot,
getAgentTimelineSnapshot,
getSessionFocusSnapshot,
publishAudioPlayback as setIsPlayingAudio,
publishAgentInitialization as setInitializingAgents,
advanceHistorySyncGeneration as bumpHistorySyncGeneration,
flushAgentActivity as flushAgentLastActivity,
publishPendingPermissions as setPendingPermissions,
publishServerInfo as updateSessionServerInfo,
publishViewedTimeline as setViewedTimelineSync,
} from "@/runtime/session-data";
import { useVoiceAudioEngineOptional, useVoiceRuntimeOptional } from "@/contexts/voice-context";
import type { AudioPlaybackSource } from "@/voice/audio-engine-types";
import {
selectAgentTimelineState,
useSessionStore,
type SessionState,
} from "@/stores/session-store";
import { useSessionFocus } from "@/stores/session-store-hooks";
import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
import { sendOsNotification } from "@/utils/os-notifications";
import { getIsAppActivelyVisible, getIsAppVisible } from "@/utils/app-visibility";
@@ -70,7 +85,7 @@ export type {
ExplorerFileKind,
ExplorerEncoding,
AgentFileExplorerState,
} from "@/stores/session-store";
} from "@/stores/session-store-hooks";
type AudioOutputPayload = Extract<SessionOutboundMessage, { type: "audio_output" }>["payload"];
@@ -126,32 +141,6 @@ const findLatestAssistantMessageText = (items: StreamItem[]): string | null => {
return null;
};
const getLatestPermissionRequest = (
session: SessionState | undefined,
agentId: string,
): NotificationPermissionRequest | null => {
if (!session) {
return null;
}
let latest: NotificationPermissionRequest | null = null;
for (const pending of session.pendingPermissions.values()) {
if (pending.agentId === agentId) {
latest = pending.request;
}
}
if (latest) {
return latest;
}
const agentPending = session.agents.get(agentId)?.pendingPermissions;
if (agentPending && agentPending.length > 0) {
return agentPending[agentPending.length - 1] as NotificationPermissionRequest;
}
return null;
};
interface AgentAttentionNotificationInput {
notification?: AgentAttentionNotificationPayload;
reason: AgentAttentionReason;
@@ -227,29 +216,10 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
const isConnected = useHostRuntimeIsConnected(serverId);
const toast = useToast();
// Zustand store actions
const setIsPlayingAudio = useSessionStore((state) => state.setIsPlayingAudio);
const setAgentStreamTail = useSessionStore((state) => state.setAgentStreamTail);
const setAgentStreamHead = useSessionStore((state) => state.setAgentStreamHead);
const applyAgentTurnLiveness = useSessionStore((state) => state.applyAgentTurnLiveness);
const clearAgentTurnLiveness = useSessionStore((state) => state.clearAgentTurnLiveness);
const clearAgentStreamHead = useSessionStore((state) => state.clearAgentStreamHead);
const setInitializingAgents = useSessionStore((state) => state.setInitializingAgents);
const bumpHistorySyncGeneration = useSessionStore((state) => state.bumpHistorySyncGeneration);
const setAgents = useSessionStore((state) => state.setAgents);
const flushAgentLastActivity = useSessionStore((state) => state.flushAgentLastActivity);
const setPendingPermissions = useSessionStore((state) => state.setPendingPermissions);
const updateSessionServerInfo = useSessionStore((state) => state.updateSessionServerInfo);
const setViewedTimelineSync = useSessionStore((state) => state.setViewedTimelineSync);
const upsertWorkspaceSetupProgress = useWorkspaceSetupStore((state) => state.upsertProgress);
// Track focused agent for heartbeat
const focusedAgentId = useSessionStore(
(state) => state.sessions[serverId]?.focusedAgentId ?? null,
);
const focusedTerminalId = useSessionStore(
(state) => state.sessions[serverId]?.focusedTerminalId ?? null,
);
const { focusedAgentId, focusedTerminalId } = useSessionFocus(serverId);
const _sessionStateTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const attentionNotifiedRef = useRef<Map<string, number>>(new Map());
const appStateRef = useRef(AppState.currentState);
@@ -281,7 +251,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
bumpHistorySyncGeneration,
});
},
[bumpHistorySyncGeneration, serverId],
[serverId],
);
// Client activity tracking (heartbeat, push token registration)
@@ -301,8 +271,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
notification?: AgentAttentionNotificationPayload;
}) => {
const appState = appStateRef.current;
const session = useSessionStore.getState().sessions[serverId];
const attentionFocusedAgentId = session?.focusedAgentId ?? null;
const attentionFocusedAgentId = getSessionFocusSnapshot(serverId).focusedAgentId;
if (params.reason === "error") {
return;
}
@@ -319,12 +288,16 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
}
attentionNotifiedRef.current.set(params.agentId, timestampMs);
const head = session?.agentStreamHead.get(params.agentId) ?? [];
const tail = session?.agentStreamTail.get(params.agentId) ?? [];
const { head, tail } = getAgentStreamSnapshot(serverId, params.agentId);
const conversation = getAgentConversationSnapshot(serverId, params.agentId);
const agent = getAgentSnapshot(serverId, params.agentId);
const assistantMessage =
findLatestAssistantMessageText(head) ?? findLatestAssistantMessageText(tail);
const permissionRequest = getLatestPermissionRequest(session, params.agentId);
const workspaceId = session?.agents?.get(params.agentId)?.workspaceId;
const permissionRequest =
conversation.pendingPermissions.at(-1)?.request ??
(agent?.pendingPermissions.at(-1) as NotificationPermissionRequest | undefined) ??
null;
const workspaceId = agent?.workspaceId;
const notification = resolveAgentAttentionNotification({
notification: params.notification,
@@ -364,7 +337,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
...(serverInfo.capabilities ? { capabilities: serverInfo.capabilities } : {}),
...(serverInfo.features ? { features: serverInfo.features } : {}),
});
}, [client, serverId, updateSessionServerInfo]);
}, [client, serverId]);
useEffect(() => {
const unregister = voiceRuntime?.registerSession({
@@ -398,7 +371,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
},
});
return () => unregister?.();
}, [client, serverId, setIsPlayingAudio, t, voiceRuntime]);
}, [client, serverId, t, voiceRuntime]);
useEffect(() => {
voiceRuntime?.updateSessionConnection(serverId, isConnected);
@@ -410,16 +383,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
flushAgentLastActivity();
setInitializingAgents(serverId, new Map());
}
}, [flushAgentLastActivity, serverId, isConnected, setInitializingAgents]);
useEffect(
() =>
client.subscribeConnectionStatus((connection) => {
if (connection.status === "connected") return;
clearAgentTurnLiveness(serverId);
}),
[clearAgentTurnLiveness, client, serverId],
);
}, [serverId, isConnected]);
const applyWorkspaceSetupProgress = useCallback(
(payload: WorkspaceSetupProgressPayload) => {
@@ -443,22 +407,18 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
const initialDeliveryMode = getTimelineDeliveryMode(
client.getLastServerInfoMessage()?.features?.selectiveAgentTimeline,
);
const sync = getHostRuntimeStore().createViewedTimelineOwner(serverId, {
const sync = createViewedTimelineOwnerForHost(serverId, {
initialDeliveryMode,
setSubscription: (agentIds) => client.setAgentTimelineSubscription(agentIds),
readCursor: (agentId) => {
const timeline = selectAgentTimelineState(
useSessionStore.getState().sessions[serverId],
agentId,
);
const timeline = getAgentTimelineSnapshot(serverId, agentId);
return timeline.status === "synced" && timeline.range
? { epoch: timeline.range.epoch, endSeq: timeline.range.endSeq }
: undefined;
},
fetchPage: async (agentId, request) => {
const session = useSessionStore.getState().sessions[serverId];
const initKey = getInitKey(serverId, agentId);
const shouldInitialize = selectAgentTimelineState(session, agentId).status !== "synced";
const shouldInitialize = getAgentTimelineSnapshot(serverId, agentId).status !== "synced";
if (shouldInitialize) {
if (!getInitDeferred(initKey)) {
const deferred = createInitDeferred(initKey, request.direction ?? "tail");
@@ -472,7 +432,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
setAgentInitializing(agentId, true);
}
try {
const page = await getHostRuntimeStore().fetchAgentTimeline(serverId, agentId, request);
const page = await fetchAgentTimeline(serverId, agentId, request);
if (shouldInitialize && getInitDeferred(initKey)) {
refreshAgentInitializationTimeout({ key: initKey, agentId, setAgentInitializing });
}
@@ -488,11 +448,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
fetchLatestTail: async (agentId) => {
forcedTimelineTailReplacements.current.add(agentId);
try {
return await getHostRuntimeStore().fetchAgentTimeline(
serverId,
agentId,
planTimelineTailFetch(),
);
return await fetchAgentTimeline(serverId, agentId, planTimelineTailFetch());
} finally {
forcedTimelineTailReplacements.current.delete(agentId);
}
@@ -516,7 +472,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
setViewedTimelineSync(serverId, null);
sync.dispose();
};
}, [client, serverId, setInitializingAgents, setViewedTimelineSync]);
}, [client, serverId]);
useEffect(() => {
viewedTimelineSyncRef.current?.setConnected(isConnected);
@@ -544,7 +500,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
{ event: streamEvent, seq, epoch, timestamp: parsedTimestamp },
]);
if (turnLiveness.length > 0) {
applyAgentTurnLiveness(serverId, agentId, turnLiveness);
applyAgentTurn(serverId, agentId, turnLiveness);
}
owner.enqueueStreamEvent(agentId, {
event: streamEvent,
@@ -574,8 +530,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
if (message.type !== "agent.timeline.replacement") return;
void requestTimelineReplacement(
{
fetchAgentTimeline: (agentId, request) =>
getHostRuntimeStore().fetchAgentTimeline(serverId, agentId, request),
fetchAgentTimeline: (agentId, request) => fetchAgentTimeline(serverId, agentId, request),
},
message.payload.agentId,
).catch((error: unknown) => {
@@ -794,18 +749,9 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
client,
queryClient,
serverId,
setIsPlayingAudio,
setAgentStreamTail,
setAgentStreamHead,
applyAgentTurnLiveness,
clearAgentStreamHead,
setInitializingAgents,
setAgents,
setPendingPermissions,
notifyAgentAttention,
applyWorkspaceSetupProgress,
applyTimelineResponse,
updateSessionServerInfo,
toast,
voiceRuntime,
voiceAudioEngine,
@@ -8,7 +8,7 @@ function createAgent(status: Agent["status"]): Agent {
id: "agent-1",
provider: "codex",
status,
activeTurn: null,
turn: { phase: "idle", cancellationRequestId: null },
createdAt: new Date(0),
updatedAt: new Date(0),
lastUserMessageAt: null,
@@ -1,5 +1,5 @@
import type { AgentLifecycleStatus } from "@getpaseo/protocol/agent-lifecycle";
import type { Agent } from "@/stores/session-store";
import type { Agent } from "@/stores/session-store-hooks";
export function reconcilePreviousAgentStatuses(
previousStatuses: Map<string, AgentLifecycleStatus>,
@@ -1,4 +1,4 @@
import type { WorkspaceDescriptor } from "@/stores/session-store";
import type { WorkspaceDescriptor } from "@/stores/session-store-hooks";
import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity";
interface PendingWorkspaceArchive {
+2 -3
View File
@@ -8,7 +8,7 @@ import {
type ReactNode,
} from "react";
import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake";
import { useSessionStore } from "@/stores/session-store";
import { readServerInfo } from "@/runtime/session-data";
import { createAudioEngine } from "@/voice/audio-engine";
import type { AudioEngine } from "@/voice/audio-engine-types";
import {
@@ -137,8 +137,7 @@ export function VoiceProvider({ children }: VoiceProviderProps) {
runtime = createVoiceRuntime({
engine,
getServerInfo: (serverId) =>
useSessionStore.getState().getSession(serverId)?.serverInfo ?? null,
getServerInfo: readServerInfo,
activateKeepAwake: async (tag) => {
await activateKeepAwakeAsync(tag);
},
@@ -1,5 +1,8 @@
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
import { normalizeWorkspaceDescriptor, type WorkspaceDescriptor } from "@/stores/session-store";
import {
normalizeWorkspaceDescriptor,
type WorkspaceDescriptor,
} from "@/stores/session-store-hooks";
import { shouldSuppressWorkspaceForLocalArchive } from "./session-workspace-upserts";
type WorkspaceDelta = Extract<SessionOutboundMessage, { type: "workspace_update" }>["payload"];
@@ -17,8 +17,7 @@ import {
} from "@/desktop/daemon/daemon-management-error";
import { useDesktopIpcErrorReporter } from "@/desktop/hooks/desktop-ipc-error";
import type { DesktopSettings } from "@/desktop/settings/desktop-settings";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { upsertDesktopDaemonConnection } from "@/runtime/daemon-start-service";
import { registerDesktopDaemonConnection } from "@/runtime/daemon-start-service";
import { confirmDialog } from "@/utils/confirm-dialog";
type DesktopDaemonSettings = DesktopSettings["daemon"];
@@ -68,10 +67,7 @@ export function useBuiltInDaemonManagement(
stopDaemon: () => stopDesktopDaemon("settings"),
});
if (result.kind === "enabled") {
const upsertResult = await upsertDesktopDaemonConnection(
getHostRuntimeStore(),
result.newStatus,
);
const upsertResult = await registerDesktopDaemonConnection(result.newStatus);
if (!upsertResult.ok) {
throw new DaemonConnectionRegistrationError(upsertResult.error);
}
+4 -5
View File
@@ -13,7 +13,8 @@ import { ScrollView as RNScrollView, Text, View } from "react-native";
import { StyleSheet, UnistylesRuntime, withUnistyles } from "react-native-unistyles";
import { useTranslation } from "react-i18next";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useSessionStore, type ExplorerFile } from "@/stores/session-store";
import { useHostRuntimeClient } from "@/runtime/host-runtime";
import { useServerFeature, type ExplorerFile } from "@/stores/session-store-hooks";
import { filePreviewRenderKind } from "@/components/file-pane-render-mode";
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
import { getFileNameFromPath } from "@/attachments/utils";
@@ -234,11 +235,9 @@ export function FilePane({
const isMobile = useIsCompactFormFactor();
const [previewMode, setPreviewMode] = useState<"preview" | "source">("preview");
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
const client = useHostRuntimeClient(serverId);
// COMPAT(workspaceFileEditing): added in v0.2.0, remove after 2027-01-18 once daemon floor >= v0.2.0.
const supportsEditing = useSessionStore(
(state) => state.sessions[serverId]?.serverInfo?.features?.workspaceFileEditing === true,
);
const supportsEditing = useServerFeature(serverId, "workspaceFileEditing");
const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]);
const normalizedFilePath = useMemo(() => trimNonEmpty(location.path), [location.path]);
const readTarget = useMemo(
+5 -6
View File
@@ -1,7 +1,8 @@
import type { CheckoutPrMergeMethod } from "@getpaseo/protocol/messages";
import { create } from "zustand";
import { queryClient as appQueryClient } from "@/data/query-client";
import { useSessionStore } from "@/stores/session-store";
import { getHostClient } from "@/runtime/host-runtime";
import { hostSupports } from "@/runtime/session-data";
import { invalidateCheckoutGitQueriesForClient } from "@/git/query-keys";
import { i18n } from "@/i18n/i18next";
@@ -35,8 +36,7 @@ function checkoutKey(serverId: string, cwd: string): CheckoutKey {
}
function resolveClient(serverId: string) {
const session = useSessionStore.getState().sessions[serverId];
const client = session?.client ?? null;
const client = getHostClient(serverId);
if (!client) {
throw new Error(i18n.t("common.errors.daemonClientUnavailable"));
}
@@ -46,14 +46,13 @@ function resolveClient(serverId: string) {
type AutoMergeActionsRpc = "forge" | "github";
function resolveAutoMergeActionsRpc(serverId: string): AutoMergeActionsRpc {
const session = useSessionStore.getState().sessions[serverId];
if (session?.serverInfo?.features?.checkoutForgeSetAutoMerge === true) {
if (hostSupports(serverId, "checkoutForgeSetAutoMerge")) {
return "forge";
}
// COMPAT(githubAutoMergeRpc): use the legacy GitHub RPC with daemons that
// predate checkout.forge.set_auto_merge.*. Remove after 2027-01-17 once the
// supported daemon floor is >= v0.2.0.
if (session?.serverInfo?.features?.checkoutGithubSetAutoMerge === true) {
if (hostSupports(serverId, "checkoutGithubSetAutoMerge")) {
return "github";
}
throw new Error("Update the host to use auto-merge actions.");
+7 -14
View File
@@ -72,7 +72,8 @@ import { parseGitRemoteLocation } from "@getpaseo/protocol/git-remote";
import type { ForgeAuthState } from "@getpaseo/protocol/messages";
import { useCheckoutGitActionsStore } from "@/git/actions-store";
import { useToast } from "@/contexts/toast-context";
import { useSessionStore } from "@/stores/session-store";
import { useHostRuntimeClient } from "@/runtime/host-runtime";
import { useServerFeature } from "@/stores/session-store-hooks";
import { confirmDialog } from "@/utils/confirm-dialog";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { Button } from "@/components/ui/button";
@@ -143,9 +144,7 @@ function useDiscardChangesAction({
const toast = useToast();
const discardChanges = useCheckoutGitActionsStore((state) => state.discardChanges);
// COMPAT(checkoutDiscardChanges): added in v0.3.0, remove gate after 2027-02-08.
const discardSupported = useSessionStore(
(s) => s.sessions[serverId]?.serverInfo?.features?.checkoutDiscardChanges === true,
);
const discardSupported = useServerFeature(serverId, "checkoutDiscardChanges");
const discardPath = useCallback(
async (path: string, oldPath?: string) => {
const confirmed = await confirmDialog({
@@ -1632,14 +1631,10 @@ export function ChangesSurface({
isMobile,
pullRequestOpenLocation: appSettings.pullRequestOpenLocation,
});
const refreshSupported = useSessionStore(
(s) => s.sessions[serverId]?.serverInfo?.features?.checkoutRefresh === true,
);
const client = useSessionStore((state) => state.sessions[serverId]?.client);
const refreshSupported = useServerFeature(serverId, "checkoutRefresh");
const client = useHostRuntimeClient(serverId);
// COMPAT(fsEntryDuplicate): added in v0.3.0, remove gate after 2027-02-09.
const fsEntryDuplicateEnabled = useSessionStore(
(state) => state.sessions[serverId]?.serverInfo?.features?.fsEntryDuplicate === true,
);
const fsEntryDuplicateEnabled = useServerFeature(serverId, "fsEntryDuplicate");
const runRefresh = useCheckoutGitActionsStore((s) => s.refresh);
const isRefreshing =
useCheckoutGitActionsStore((s) => s.getStatus({ serverId, cwd, actionId: "refresh" })) ===
@@ -1696,9 +1691,7 @@ export function ChangesSurface({
cwd,
enabled: isGit,
});
const forgeProvidersSupported = useSessionStore(
(s) => s.sessions[serverId]?.serverInfo?.features?.forgeProviders === true,
);
const forgeProvidersSupported = useServerFeature(serverId, "forgeProviders");
const forgeSetupAction = computeForgeSetupAction({
forge,
forgeProvidersSupported,
@@ -31,7 +31,7 @@ import { MarkdownRenderer } from "@/components/markdown/renderer";
import { getDefaultMarkdownClipboardEnvironment } from "@/utils/rich-clipboard-default-environment";
import { writeMarkdownToRichClipboard } from "@/utils/rich-clipboard";
import { useHostRuntimeClient } from "@/runtime/host-runtime";
import { useSessionStore } from "@/stores/session-store";
import { useServerFeature } from "@/stores/session-store-hooks";
import { useWorkspaceAttachmentsStore } from "@/attachments/workspace-attachments-store";
import { useToast } from "@/contexts/toast-context";
import { useCheckoutGitActionsStore } from "@/git/actions-store";
@@ -209,15 +209,9 @@ export function PullRequestPane({
// COMPAT(githubCheckDetailsRpc): recognize the legacy capability on daemons
// predating checkout.forge.get_check_details.*. Remove after 2027-01-17 once
// the supported daemon floor is >= v0.2.0.
const canFetchGitHubCheckDetails = useSessionStore(
(state) => state.sessions[serverId]?.serverInfo?.features?.githubCheckDetails === true,
);
const canFetchForgeCheckDetails = useSessionStore(
(state) => state.sessions[serverId]?.serverInfo?.features?.forgeCheckDetails === true,
);
const forgeProvidersEnabled = useSessionStore(
(state) => state.sessions[serverId]?.serverInfo?.features?.forgeProviders === true,
);
const canFetchGitHubCheckDetails = useServerFeature(serverId, "githubCheckDetails");
const canFetchForgeCheckDetails = useServerFeature(serverId, "forgeCheckDetails");
const forgeProvidersEnabled = useServerFeature(serverId, "forgeProviders");
const addWorkspaceAttachment = useWorkspaceAttachmentsStore(
(state) => state.addWorkspaceAttachment,
);
@@ -230,9 +224,7 @@ export function PullRequestPane({
void openExternalUrl(data.url);
}, [data.url]);
const refreshSupported = useSessionStore(
(state) => state.sessions[serverId]?.serverInfo?.features?.checkoutRefresh === true,
);
const refreshSupported = useServerFeature(serverId, "checkoutRefresh");
const runRefresh = useCheckoutGitActionsStore((state) => state.refresh);
const isRefreshing =
useCheckoutGitActionsStore((state) =>
+36 -42
View File
@@ -21,7 +21,12 @@ import { deriveMergeCapability } from "@/git/merge-capability";
import type { CheckoutPrMergeMethod } from "@getpaseo/protocol/messages";
import { openExternalUrl } from "@/utils/open-external-url";
import { useToast } from "@/contexts/toast-context";
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
import {
useServerFeature,
useWorkspaceByDirectoryFields,
useWorkspaceFields,
type WorkspaceDescriptor,
} from "@/stores/session-store-hooks";
import {
useActiveWorkspaceSelection,
type ActiveWorkspaceSelection,
@@ -29,7 +34,6 @@ import {
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
import { type WorktreeArchiveWarningLabels } from "@/git/worktree-archive-warning";
import { useWorkspaceArchive } from "@/workspace/use-workspace-archive";
import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity";
import { readValidatedString } from "@/storage/validated-storage";
export type { GitActionId, GitAction, GitActions } from "@/git/policy";
@@ -216,33 +220,13 @@ interface UseWorkspaceScreenArchiveControllerInput {
t: (key: string, options?: Record<string, unknown>) => string;
}
function resolveArchiveWorkspaceDescriptor(input: {
workspaces: Map<string, WorkspaceDescriptor> | undefined;
activeWorkspaceSelection: ActiveWorkspaceSelection | null;
workspaceDirectory: string | null | undefined;
}): WorkspaceDescriptor | null {
const activeWorkspaceKey = input.activeWorkspaceSelection
? resolveWorkspaceMapKeyByIdentity({
workspaces: input.workspaces,
workspaceId: input.activeWorkspaceSelection.workspaceId,
})
: null;
if (activeWorkspaceKey) {
return input.workspaces?.get(activeWorkspaceKey) ?? null;
}
if (!input.workspaceDirectory) {
return null;
}
for (const candidate of input.workspaces?.values() ?? []) {
if (candidate.workspaceDirectory === input.workspaceDirectory) {
return candidate;
}
}
return null;
}
type ArchiveWorkspaceProjection = Pick<
WorkspaceDescriptor,
"id" | "workspaceKind" | "name" | "diffStat" | "archivingAt" | "gitRuntime"
>;
function resolveWorkspaceArchiveRisk(
workspace: WorkspaceDescriptor | null,
workspace: ArchiveWorkspaceProjection | null,
gitStatus: CheckoutStatusPayload | null,
): { isDirty: boolean | null | undefined; aheadOfOrigin: number | null | undefined } {
return {
@@ -252,7 +236,7 @@ function resolveWorkspaceArchiveRisk(
}
function canArchiveWorkspace(
workspace: WorkspaceDescriptor | null,
workspace: ArchiveWorkspaceProjection | null,
risk: ReturnType<typeof resolveWorkspaceArchiveRisk>,
): boolean {
return (
@@ -270,17 +254,29 @@ function useWorkspaceScreenArchiveController({
gitStatus,
t,
}: UseWorkspaceScreenArchiveControllerInput) {
const sessionWorkspaces = useSessionStore((state) => state.sessions[serverId]?.workspaces);
const [isHidingWorkspace, setIsHidingWorkspace] = useState(false);
const workspaceDescriptor = useMemo(
() =>
resolveArchiveWorkspaceDescriptor({
workspaces: sessionWorkspaces,
activeWorkspaceSelection,
workspaceDirectory,
}),
[activeWorkspaceSelection, sessionWorkspaces, workspaceDirectory],
const projectArchiveFields = useCallback(
(workspace: WorkspaceDescriptor) => ({
id: workspace.id,
workspaceKind: workspace.workspaceKind,
name: workspace.name,
diffStat: workspace.diffStat,
archivingAt: workspace.archivingAt,
gitRuntime: workspace.gitRuntime,
}),
[],
);
const selectedWorkspace = useWorkspaceFields(
serverId,
activeWorkspaceSelection?.workspaceId ?? null,
projectArchiveFields,
);
const directoryWorkspace = useWorkspaceByDirectoryFields(
serverId,
workspaceDirectory,
projectArchiveFields,
);
const workspaceDescriptor = selectedWorkspace ?? directoryWorkspace;
const archiveRisk = resolveWorkspaceArchiveRisk(workspaceDescriptor, gitStatus);
const controller = useWorkspaceArchive({
@@ -453,11 +449,9 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
const runDisablePrAutoMerge = useCheckoutGitActionsStore((s) => s.disablePrAutoMerge);
const runMergeBranch = useCheckoutGitActionsStore((s) => s.mergeBranch);
const runMergeFromBase = useCheckoutGitActionsStore((s) => s.mergeFromBase);
const githubAutoMergeActionsEnabled = useSessionStore(
(s) =>
s.sessions[serverId]?.serverInfo?.features?.checkoutForgeSetAutoMerge === true ||
s.sessions[serverId]?.serverInfo?.features?.checkoutGithubSetAutoMerge === true,
);
const supportsForgeAutoMerge = useServerFeature(serverId, "checkoutForgeSetAutoMerge");
const supportsGithubAutoMerge = useServerFeature(serverId, "checkoutGithubSetAutoMerge");
const githubAutoMergeActionsEnabled = supportsForgeAutoMerge || supportsGithubAutoMerge;
const toastActionError = useCallback(
(error: unknown, fallback: string) => {
+4 -6
View File
@@ -4,7 +4,7 @@ import { useRetainedPanelActive } from "@/components/retained-panel";
import { useFetchQuery } from "@/data/query";
import { checkoutCommitsQueryKey } from "@/git/query-keys";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useSessionStore } from "@/stores/session-store";
import { useServerFeature } from "@/stores/session-store-hooks";
// Commits ahead of base change rarely while the section is open; this keeps a
// collapse/re-expand cycle warm without leaving the fetch result stale for long.
@@ -80,11 +80,9 @@ export function useCheckoutCommitsQuery({
// COMPAT(commitsList): added in v0.1.110, remove after 2027-01-16.
// COMPAT(commitBaseClassification): added in v0.2.0, remove after 2027-01-23.
// Single capability-detection site; downstream reads a clean load-state union.
const capabilityPresent = useSessionStore(
(state) =>
state.sessions[serverId]?.serverInfo?.features?.commitsList === true &&
state.sessions[serverId]?.serverInfo?.features?.commitBaseClassification === true,
);
const supportsCommitsList = useServerFeature(serverId, "commitsList");
const supportsBaseClassification = useServerFeature(serverId, "commitBaseClassification");
const capabilityPresent = supportsCommitsList && supportsBaseClassification;
const canFetch = Boolean(cwd) && Boolean(client) && isConnected;
const queryEnabled = queryEnabledByCaller && capabilityPresent && canFetch;
+1 -1
View File
@@ -4,7 +4,7 @@ import type {
ProjectAddResponse,
WorkspaceProjectDescriptorPayload,
} from "@getpaseo/protocol/messages";
import { normalizeProjectDescriptor, type ProjectDescriptor } from "@/stores/session-store";
import { normalizeProjectDescriptor, type ProjectDescriptor } from "@/stores/session-store-hooks";
type OpenProjectPayload = ProjectAddResponse["payload"];
type OpenProjectErrorCode = NonNullable<OpenProjectPayload["errorCode"]>;
@@ -701,7 +701,7 @@ function agent(input: {
id: input.id,
provider: "claude" as Agent["provider"],
status: input.status,
activeTurn: null,
turn: { phase: "idle", cancellationRequestId: null },
createdAt: new Date(0),
updatedAt: input.updatedAt ?? new Date(1_000),
lastUserMessageAt: null,
@@ -2,7 +2,7 @@ import type { PrHint } from "@/git/pr-hint";
import { selectPrHintFromStatus } from "@/git/pr-hint";
import { type HostProjectListItem } from "@/projects/host-project-model";
import type { PendingCreateAttempt } from "@/stores/create-flow-store";
import type { WorkspaceDescriptor } from "@/stores/session-store";
import type { WorkspaceDescriptor } from "@/stores/session-store-hooks";
import type {
WorkspaceStructureHostPlacement,
WorkspaceStructureProject,
@@ -71,13 +71,13 @@ export interface SidebarWorkspacePlacementModel {
export interface SidebarWorkspaceSession {
serverId: string;
workspaces: Map<string, WorkspaceDescriptor>;
workspaceAgentActivity: Map<string, WorkspaceAgentActivity>;
workspaces: ReadonlyMap<string, WorkspaceDescriptor>;
workspaceAgentActivity: ReadonlyMap<string, WorkspaceAgentActivity>;
}
interface SidebarWorkspaceSessionSource {
workspaces: Map<string, WorkspaceDescriptor>;
workspaceAgentActivity: Map<string, WorkspaceAgentActivity>;
workspaces: ReadonlyMap<string, WorkspaceDescriptor>;
workspaceAgentActivity: ReadonlyMap<string, WorkspaceAgentActivity>;
}
export function selectSidebarWorkspaceSessions(
@@ -232,8 +232,8 @@ function getPendingInitialAgentCreateStartedAt(input: {
}
export interface ProjectStatusSession {
workspaces: Map<string, WorkspaceDescriptor>;
workspaceAgentActivity: Map<string, WorkspaceAgentActivity>;
workspaces: ReadonlyMap<string, WorkspaceDescriptor>;
workspaceAgentActivity: ReadonlyMap<string, WorkspaceAgentActivity>;
}
/**
@@ -10,7 +10,7 @@ import {
} from "./use-agent-commands-query";
import { orderAutocompleteOptions } from "@/components/ui/autocomplete-utils";
import { useAutocomplete } from "./use-autocomplete";
import { useSessionStore } from "@/stores/session-store";
import { useActiveAgentFields } from "@/stores/session-store-hooks";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { CLIENT_SLASH_COMMANDS, type ClientSlashCommand } from "@/client-slash-commands";
import type { PluginClientSlashCommand } from "@/plugins/client-slash-commands";
@@ -389,9 +389,7 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
const queryDraftConfig = normalizedDraftConfig;
const canLoadCommands = resolveCanLoadCommands({ serverId, agentId, isDraftContext });
const agentCwd = useSessionStore(
(state) => state.sessions[serverId]?.agents?.get(agentId)?.cwd ?? "",
);
const agentCwd = useActiveAgentFields(serverId, agentId, (agent) => agent.cwd, Object.is) ?? "";
const autocompleteCwd = useMemo(() => {
if (isDraftContext) {
return queryDraftConfig?.cwd ?? "";
+18 -15
View File
@@ -5,11 +5,17 @@ import type {
} from "@getpaseo/client/internal/daemon-client";
import type { AgentSearchMatch } from "@getpaseo/protocol/messages";
import { useInfiniteQuery } from "@tanstack/react-query";
import { useCallback, useMemo, useSyncExternalStore } from "react";
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { getHostRuntimeStore, isHostRuntimeConnected, useHosts } from "@/runtime/host-runtime";
import { useSessionStore } from "@/stores/session-store";
import {
getHostClient,
isHostRuntimeConnected,
readHostRuntimeSnapshot,
useHostRuntimeVersion,
useHosts,
} from "@/runtime/host-runtime";
import { useConnections } from "@/stores/session-store-hooks";
import { buildAgentDirectoryState } from "@/utils/agent-directory-sync";
import { agentHistoryQueryKey, allAgentHistoryQueryKey } from "./agent-history-query-key";
@@ -134,6 +140,7 @@ export async function fetchAgentHistoryPage(input: {
serverLabel: input.serverId,
title: agent.title ?? null,
status: agent.status,
turn: agent.turn,
lastActivityAt: agent.lastActivityAt,
cwd: agent.cwd,
workspaceId: agent.workspaceId,
@@ -298,12 +305,7 @@ export function useAgentHistory(options: {
}): AgentHistoryResult {
const { t } = useTranslation();
const daemons = useHosts();
const runtime = getHostRuntimeStore();
const runtimeVersion = useSyncExternalStore(
(onStoreChange) => runtime.subscribeAll(onStoreChange),
() => runtime.getVersion(),
() => runtime.getVersion(),
);
const runtimeVersion = useHostRuntimeVersion();
const serverId = useMemo(() => {
const value = options.serverId;
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
@@ -321,8 +323,8 @@ export function useAgentHistory(options: {
const unreachable: AgentHistoryHostError[] = [];
for (const targetServerId of serverIds) {
const snapshot = runtime.getSnapshot(targetServerId);
const client = runtime.getClient(targetServerId);
const snapshot = readHostRuntimeSnapshot(targetServerId);
const client = getHostClient(targetServerId);
const serverName = serverLabelById.get(targetServerId) ?? targetServerId;
if (!client || !isHostRuntimeConnected(snapshot)) {
unreachable.push({ serverId: targetServerId, serverName });
@@ -332,17 +334,18 @@ export function useAgentHistory(options: {
}
return { targetHosts: hosts, unreachableHosts: unreachable };
}, [daemons, runtime, runtimeVersion, serverId]);
}, [daemons, runtimeVersion, serverId]);
const targetServerIds = useMemo(() => targetHosts.map((host) => host.serverId), [targetHosts]);
// One gate, checked before the field is offered: a fleet where any host
// predates search has no search, rather than a list that silently omits that
// host's sessions.
const isSearchSupported = useSessionStore(
const isSearchSupported = useConnections(
targetServerIds,
useCallback(
(state) =>
(connections) =>
targetServerIds.length > 0 &&
targetServerIds.every(
(id) => state.sessions[id]?.serverInfo?.features?.agentHistorySearch === true,
(id) => connections.get(id)?.serverInfo?.features?.agentHistorySearch === true,
),
[targetServerIds],
),
@@ -1,7 +1,7 @@
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { selectAgentTimelineState, useSessionStore } from "@/stores/session-store";
import { getAgentTimelineSnapshot, publishAgentInitialization } from "@/runtime/session-data";
import {
createInitDeferred,
getInitDeferred,
@@ -10,7 +10,7 @@ import {
rejectInitDeferred,
refreshInitTimeout,
} from "@/utils/agent-initialization";
import { getHostRuntimeStore, type HostRuntimeStore } from "@/runtime/host-runtime";
import { agentTimelineRuntime } from "@/runtime/host-runtime";
import { planTimelineResumeFetch, planTimelineTailFetch } from "@/timeline/timeline-sync-plan";
import { i18n } from "@/i18n/i18next";
@@ -38,7 +38,7 @@ export interface EnsureAgentIsInitializedInput {
serverId: string;
agentId: string;
client: Pick<DaemonClient, "fetchAgentTimeline"> | null;
runtime: Pick<HostRuntimeStore, "fetchAgentTimeline">;
runtime: typeof agentTimelineRuntime;
setAgentInitializing: SetAgentInitializing;
hostDisconnectedMessage?: string;
}
@@ -51,7 +51,7 @@ export function ensureAgentIsInitialized(input: EnsureAgentIsInitializedInput):
return existing.promise;
}
const timeline = selectAgentTimelineState(useSessionStore.getState().sessions[serverId], agentId);
const timeline = getAgentTimelineSnapshot(serverId, agentId);
const timelineRequest = planTimelineResumeFetch(
timeline.status === "synced" ? timeline.range : null,
);
@@ -82,7 +82,7 @@ export interface RefreshAgentInput {
serverId: string;
agentId: string;
client: Pick<DaemonClient, "refreshAgent"> | null;
runtime: Pick<HostRuntimeStore, "fetchAgentTimeline">;
runtime: typeof agentTimelineRuntime;
setAgentInitializing: SetAgentInitializing;
hostDisconnectedMessage?: string;
}
@@ -105,7 +105,7 @@ export async function refreshAgent(input: RefreshAgentInput): Promise<void> {
export function createSetAgentInitializing(
serverId: string,
setInitializingAgents: ReturnType<typeof useSessionStore.getState>["setInitializingAgents"],
setInitializingAgents: typeof publishAgentInitialization,
): SetAgentInitializing {
return (agentId, initializing) => {
setInitializingAgents(serverId, (prev) => {
@@ -127,10 +127,9 @@ export function useAgentInitialization({
client: DaemonClient | null;
}) {
const { t } = useTranslation();
const setInitializingAgents = useSessionStore((state) => state.setInitializingAgents);
const setAgentInitializing = useMemo(
() => createSetAgentInitializing(serverId, setInitializingAgents),
[serverId, setInitializingAgents],
() => createSetAgentInitializing(serverId, publishAgentInitialization),
[serverId],
);
const ensureAgentIsInitializedCallback = useCallback(
@@ -139,7 +138,7 @@ export function useAgentInitialization({
serverId,
agentId,
client,
runtime: getHostRuntimeStore(),
runtime: agentTimelineRuntime,
setAgentInitializing,
hostDisconnectedMessage: t("workspace.terminal.hostDisconnected"),
}),
@@ -152,7 +151,7 @@ export function useAgentInitialization({
serverId,
agentId,
client,
runtime: getHostRuntimeStore(),
runtime: agentTimelineRuntime,
setAgentInitializing,
hostDisconnectedMessage: t("workspace.terminal.hostDisconnected"),
}),
@@ -17,7 +17,12 @@ function createAgent(id: string): Agent {
id,
provider: "claude",
status: "running",
activeTurn: { turnId: "turn-1", startedAt: now },
turn: {
phase: "open",
turnId: "turn-1",
startedAt: now,
cancellationRequestId: null,
},
createdAt: now,
updatedAt: now,
lastUserMessageAt: now,
+47 -29
View File
@@ -1,16 +1,38 @@
import { useMemo, useCallback, useEffect, useRef, useSyncExternalStore } from "react";
import { useMemo, useCallback, useEffect, useRef } from "react";
import equal from "fast-deep-equal";
import { useShallow } from "zustand/shallow";
import { useSessionStore } from "@/stores/session-store";
import { useAgentDirectories } from "@/stores/session-store-hooks";
import type { Agent } from "@/stores/session-store-hooks";
import type { AgentDirectoryEntry } from "@/types/agent-directory";
import type { Agent } from "@/stores/session-store";
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
import {
acquireDirectoryDemand,
readHostRuntimeSnapshot,
refreshHostDirectories,
useHostRuntimeVersion,
useHosts,
} from "@/runtime/host-runtime";
export interface AggregatedAgent extends AgentDirectoryEntry {
serverId: string;
serverLabel: string;
}
interface AgentSource {
serverId: string;
agents: ReadonlyMap<string, Agent>;
}
const EMPTY_AGENT_MAP: ReadonlyMap<string, Agent> = new Map();
function agentSourcesEqual(left: AgentSource[], right: AgentSource[]): boolean {
return (
left.length === right.length &&
left.every(
(source, index) =>
source.serverId === right[index]?.serverId && source.agents === right[index]?.agents,
)
);
}
export interface AggregatedAgentsResult {
agents: AggregatedAgent[];
isLoading: boolean;
@@ -24,35 +46,30 @@ export function useAggregatedAgents(options?: {
demand?: boolean;
}): AggregatedAgentsResult {
const daemons = useHosts();
const runtime = getHostRuntimeStore();
const includeArchived = options?.includeArchived ?? false;
const demand = options?.demand ?? true;
const serverIds = useMemo(() => daemons.map((daemon) => daemon.serverId), [daemons]);
useEffect(() => {
if (!demand) return;
const releases = serverIds.map((serverId) => runtime.acquireDirectoryDemand(serverId));
const releases = serverIds.map(acquireDirectoryDemand);
return () => releases.forEach((release) => release());
}, [demand, runtime, serverIds]);
const runtimeVersion = useSyncExternalStore(
(onStoreChange) => runtime.subscribeAll(onStoreChange),
() => runtime.getVersion(),
() => runtime.getVersion(),
);
}, [demand, serverIds]);
const runtimeVersion = useHostRuntimeVersion();
const sessionAgents = useSessionStore(
useShallow((state) => {
const result: Record<string, Map<string, Agent> | undefined> = {};
for (const [serverId, session] of Object.entries(state.sessions)) {
result[serverId] = session.agents;
}
return result;
}),
const sessionAgents = useAgentDirectories(
serverIds,
(directories) =>
serverIds.map((serverId) => ({
serverId,
agents: directories.get(serverId)?.agents ?? EMPTY_AGENT_MAP,
})),
agentSourcesEqual,
);
const refreshAll = useCallback(() => {
if (!demand) return;
for (const serverId of serverIds) void runtime.refreshDirectories(serverId);
}, [demand, runtime, serverIds]);
for (const serverId of serverIds) void refreshHostDirectories(serverId);
}, [demand, serverIds]);
// Keyed by "serverId:agentId" — reuse the previous AggregatedAgent object when
// none of its fields changed, so downstream memo/shallow comparisons can bail early.
@@ -70,8 +87,8 @@ export function useAggregatedAgents(options?: {
);
// Derive agent directory from all sessions
for (const [serverId, agents] of Object.entries(sessionAgents)) {
if (!agents || agents.size === 0) {
for (const { serverId, agents } of sessionAgents) {
if (agents.size === 0) {
continue;
}
const serverLabel = serverLabelById.get(serverId) ?? serverId;
@@ -85,6 +102,7 @@ export function useAggregatedAgents(options?: {
serverLabel,
title: agent.title ?? null,
status: agent.status,
turn: agent.turn,
lastActivityAt: agent.lastActivityAt,
cwd: agent.cwd,
workspaceId: agent.workspaceId,
@@ -108,8 +126,8 @@ export function useAggregatedAgents(options?: {
// Sort by: running agents first, then by most recent activity
allAgents.sort((left, right) => {
const leftRunning = left.status === "running";
const rightRunning = right.status === "running";
const leftRunning = left.turn.phase === "open";
const rightRunning = right.turn.phase === "open";
if (leftRunning && !rightRunning) {
return -1;
}
@@ -144,7 +162,7 @@ export function useAggregatedAgents(options?: {
// Align list loading with the runtime directory-sync machine.
const isLoading = daemons.some((daemon) => {
const status =
runtime.getSnapshot(daemon.serverId)?.agentDirectoryStatus ?? "initial_loading";
readHostRuntimeSnapshot(daemon.serverId)?.agentDirectoryStatus ?? "initial_loading";
return status === "initial_loading" || status === "revalidating";
});
const isInitialLoad = isLoading && !hasAnyData;
@@ -156,7 +174,7 @@ export function useAggregatedAgents(options?: {
isInitialLoad,
isRevalidating,
};
}, [daemons, includeArchived, runtime, runtimeVersion, sessionAgents]);
}, [daemons, includeArchived, runtimeVersion, sessionAgents]);
return {
...result,
@@ -41,7 +41,7 @@ function makeAgent(overrides: Partial<Agent> = {}): Agent {
labels: {},
archivedAt: null,
...overrides,
activeTurn: overrides.activeTurn ?? null,
turn: overrides.turn ?? { phase: "idle", cancellationRequestId: null },
};
}
+10 -40
View File
@@ -7,7 +7,12 @@ import {
type QueryKey,
} from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { useSessionStore } from "@/stores/session-store";
import { getActiveAgentSnapshot } from "@/runtime/session-data";
import {
archiveAgentSnapshot,
restoreAgentSnapshot as restoreStoredAgent,
} from "@/runtime/session-data";
import { getHostClient } from "@/runtime/host-runtime";
import { agentHistoryQueryKey, allAgentHistoryQueryRootKey } from "./agent-history-query-key";
export const ARCHIVE_AGENT_PENDING_QUERY_KEY = ["archive-agent-pending"] as const;
@@ -241,33 +246,13 @@ interface ArchiveAgentMutationContext {
}
function getStoredAgentSnapshot(input: ArchiveAgentInput) {
return useSessionStore.getState().sessions[input.serverId]?.agents.get(input.agentId);
return getActiveAgentSnapshot(input.serverId, input.agentId) ?? undefined;
}
function restoreAgentSnapshot(
input: ArchiveAgentInput & { agent: ReturnType<typeof getStoredAgentSnapshot> },
): void {
const setAgents = useSessionStore.getState().setAgents;
setAgents(input.serverId, (prev) => {
const hasAgent = prev.has(input.agentId);
if (!input.agent) {
if (!hasAgent) {
return prev;
}
const next = new Map(prev);
next.delete(input.agentId);
return next;
}
const current = prev.get(input.agentId);
if (current === input.agent) {
return prev;
}
const next = new Map(prev);
next.set(input.agentId, input.agent);
return next;
});
restoreStoredAgent(input.serverId, input.agentId, input.agent);
}
function getArchivedAgentListCacheSnapshot(
@@ -324,22 +309,7 @@ function markAgentArchivedInStore(input: ArchiveAgentInput & { archivedAt: strin
return;
}
const setAgents = useSessionStore.getState().setAgents;
setAgents(input.serverId, (prev) => {
const existing = prev.get(input.agentId);
if (!existing) {
return prev;
}
if (existing.archivedAt && existing.archivedAt.getTime() === archivedAt.getTime()) {
return prev;
}
const next = new Map(prev);
next.set(input.agentId, {
...existing,
archivedAt,
});
return next;
});
archiveAgentSnapshot(input.serverId, input.agentId, archivedAt.toISOString());
}
interface ApplyArchivedAgentCloseResultsInput {
@@ -413,7 +383,7 @@ export function useArchiveAgent() {
const archiveMutation = useMutation({
mutationFn: async (input: ArchiveAgentInput): Promise<{ archivedAt: string }> => {
const client = useSessionStore.getState().sessions[input.serverId]?.client ?? null;
const client = getHostClient(input.serverId);
if (!client) {
throw new Error(t("common.errors.daemonClientUnavailable"));
}
@@ -1,7 +1,7 @@
import { useCallback, useMemo } from "react";
import { i18n } from "@/i18n/i18next";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { useSessionStore } from "@/stores/session-store";
import { getHostClient } from "@/runtime/host-runtime";
import { useWorkspaceFields } from "@/stores/session-store-hooks";
export interface ClearWorkspaceAttentionController {
hasClearableAttention: boolean;
@@ -15,16 +15,18 @@ export function useClearWorkspaceAttention({
serverId: string;
workspaceId: string;
}): ClearWorkspaceAttentionController {
const hasClearableAttention = useSessionStore((state) => {
const workspace = state.sessions[serverId]?.workspaces.get(workspaceId);
return workspace?.status === "attention" || workspace?.status === "failed";
});
const hasClearableAttention =
useWorkspaceFields(
serverId,
workspaceId,
(workspace) => workspace.status === "attention" || workspace.status === "failed",
) ?? false;
const clearAttention = useCallback(async () => {
if (!hasClearableAttention) {
return;
}
const client = getHostRuntimeStore().getClient(serverId);
const client = getHostClient(serverId);
if (!client) {
throw new Error(i18n.t("workspace.terminal.hostDisconnected"));
}
+1 -1
View File
@@ -27,7 +27,7 @@ const FAVICON_IMAGES: Record<ColorScheme, Record<FaviconStatus, { uri: string }
function deriveFaviconStatus(
agents: ReturnType<typeof useAggregatedAgents>["agents"],
): FaviconStatus {
const hasRunning = agents.some((agent) => agent.status === "running");
const hasRunning = agents.some((agent) => agent.turn.phase === "open");
if (hasRunning) {
return "running";
}
@@ -1,10 +1,8 @@
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import {
useSessionStore,
type AgentFileExplorerState,
type ExplorerDirectory,
} from "@/stores/session-store";
import { type AgentFileExplorerState, type ExplorerDirectory } from "@/stores/session-store-hooks";
import { publishFileExplorer as setFileExplorer } from "@/runtime/session-data";
import { useHostRuntimeClient } from "@/runtime/host-runtime";
import { explorerFileFromReadResult } from "@/file-explorer/read-result";
import { parentExplorerPath } from "@/utils/explorer-paths";
@@ -59,8 +57,7 @@ export function buildWorkspaceExplorerStateKey(scope: FileExplorerWorkspaceScope
export function useFileExplorerActions(params: { serverId: string } & FileExplorerWorkspaceScope) {
const { t } = useTranslation();
const { serverId, workspaceId, workspaceRoot } = params;
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
const setFileExplorer = useSessionStore((state) => state.setFileExplorer);
const client = useHostRuntimeClient(serverId);
const normalizedWorkspaceRoot = useMemo(
() => normalizeWorkspaceValue(workspaceRoot),
[workspaceRoot],
@@ -86,7 +83,7 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
return next;
});
},
[serverId, setFileExplorer, workspaceStateKey],
[serverId, workspaceStateKey],
);
const requestDirectoryListing = useCallback(
+2 -2
View File
@@ -12,7 +12,7 @@ import { useStableEvent } from "@/hooks/use-stable-event";
import { useHostFeature } from "@/runtime/host-features";
import { generateDraftId } from "@/stores/draft-keys";
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
import { useSessionStore } from "@/stores/session-store";
import { useHostRuntimeClient } from "@/runtime/host-runtime";
import {
buildDraftWorkspaceAttachmentScopeKey,
useWorkspaceAttachmentsStore,
@@ -129,7 +129,7 @@ export function useForkAgent(
const { serverId, toast, readOnly = false } = input;
const { t } = useTranslation();
const router = useRouter();
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
const client = useHostRuntimeClient(serverId);
const supportsAgentForkContext = useHostFeature(serverId, "agentForkContext") && !readOnly;
return useStableEvent(async ({ agentId, agent, workspaceId, target, boundary }) => {
@@ -1,7 +1,7 @@
import { useCallback } from "react";
import type { HostRuntimeAgentDirectoryStatus } from "@/runtime/host-runtime";
import { useSessionStore } from "@/stores/session-store";
import { useConnection } from "@/stores/session-store-hooks";
import { getVoiceReadinessState } from "@/utils/server-info-capabilities";
function isLegacyDictationReady(agentDirectoryStatus: HostRuntimeAgentDirectoryStatus): boolean {
@@ -21,19 +21,16 @@ export function useIsDictationReady({
isConnected: boolean;
agentDirectoryStatus: HostRuntimeAgentDirectoryStatus;
}): boolean {
const dictationCapabilityEnabled = useSessionStore(
useCallback(
(state) => {
const serverInfo = state.sessions[serverId]?.serverInfo ?? null;
return (
getVoiceReadinessState({
serverInfo,
mode: "dictation",
})?.enabled ?? null
);
},
[serverId],
),
const dictationCapabilityEnabled = useConnection(
serverId,
useCallback(({ serverInfo }) => {
return (
getVoiceReadinessState({
serverInfo,
mode: "dictation",
})?.enabled ?? null
);
}, []),
);
if (!isConnected) {
@@ -2,13 +2,13 @@ import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import type { ToastApi } from "@/components/toast-host";
import { i18n } from "@/i18n/i18next";
import { useAgentTimeline, type AgentTimelineCursorState } from "@/stores/session-store-hooks";
import {
selectAgentTimelineState,
useSessionStore,
type AgentTimelineCursorState,
} from "@/stores/session-store";
getAgentTimelineLoadSnapshot,
publishOlderTimelineFetch as setOlderFetchInFlight,
} from "@/runtime/session-data";
import { planTimelineOlderFetch } from "@/timeline/timeline-sync-plan";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { fetchAgentTimeline, getHostClient } from "@/runtime/host-runtime";
export interface LoadOlderAgentHistoryClient {
fetchAgentTimeline: (
@@ -78,22 +78,15 @@ export function useLoadOlderAgentHistory({
toast?: ToastApi | null;
}) {
const { t } = useTranslation();
const hasOlder = useSessionStore((state) => {
const timeline = selectAgentTimelineState(state.sessions[serverId], agentId);
return timeline.status === "synced" && timeline.older === "available";
});
const isLoadingOlder =
useSessionStore((state) =>
state.sessions[serverId]?.agentTimelineOlderFetchInFlight.get(agentId),
) === true;
const progressKey = useSessionStore((state) => {
const timeline = selectAgentTimelineState(state.sessions[serverId], agentId);
const timelineState = useAgentTimeline(serverId, agentId, (timeline, meta) => {
const cursor = timeline.status === "synced" ? timeline.range : null;
return cursor ? `${cursor.epoch}:${cursor.startSeq}` : null;
return {
hasOlder: timeline.status === "synced" && timeline.older === "available",
isLoadingOlder: meta.isLoadingOlder,
progressKey: cursor ? `${cursor.epoch}:${cursor.startSeq}` : null,
};
});
const setOlderFetchInFlight = useSessionStore(
(state) => state.setAgentTimelineOlderFetchInFlight,
);
const { hasOlder, isLoadingOlder, progressKey } = timelineState;
const setInFlight = useCallback(
(value: boolean) => {
@@ -106,22 +99,24 @@ export function useLoadOlderAgentHistory({
return next;
});
},
[agentId, serverId, setOlderFetchInFlight],
[agentId, serverId],
);
const loadOlder = useCallback(async (): Promise<boolean> => {
const session = useSessionStore.getState().sessions[serverId];
const timeline = selectAgentTimelineState(session, agentId);
const { timeline, isLoadingOlder: currentIsLoadingOlder } = getAgentTimelineLoadSnapshot(
serverId,
agentId,
);
return await loadOlderAgentHistory(agentId, {
client: session?.client
client: getHostClient(serverId)
? {
fetchAgentTimeline: (timelineAgentId, request) =>
getHostRuntimeStore().fetchAgentTimeline(serverId, timelineAgentId, request),
fetchAgentTimeline(serverId, timelineAgentId, request),
}
: null,
cursor: timeline.status === "synced" ? (timeline.range ?? undefined) : undefined,
hasOlder: timeline.status === "synced" && timeline.older === "available",
isLoadingOlder: session?.agentTimelineOlderFetchInFlight.get(agentId) === true,
isLoadingOlder: currentIsLoadingOlder,
setInFlight,
toast,
failedMessage: t("loadOlderHistory.failed"),
+12 -19
View File
@@ -1,6 +1,10 @@
import { useCallback } from "react";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useSessionStore } from "@/stores/session-store";
import { useServerFeature } from "@/stores/session-store-hooks";
import {
acceptProjectSnapshot as upsertProject,
publishWorkspaceHydration as setHasHydratedWorkspaces,
} from "@/runtime/session-data";
import {
cloneGithubProjectDirectly,
openProjectDirectly,
@@ -14,14 +18,12 @@ export function useOpenProject(
const normalizedServerId = serverId?.trim() ?? "";
const client = useHostRuntimeClient(normalizedServerId);
const isConnected = useHostRuntimeIsConnected(normalizedServerId);
const canAddProject = useSessionStore((state) =>
normalizedServerId
? state.sessions[normalizedServerId]?.serverInfo?.features?.projectAdd === true &&
state.sessions[normalizedServerId]?.serverInfo?.features?.stableProjectIdentity === true
: false,
const supportsProjectAdd = useServerFeature(normalizedServerId, "projectAdd");
const supportsStableProjectIdentity = useServerFeature(
normalizedServerId,
"stableProjectIdentity",
);
const upsertProject = useSessionStore((state) => state.upsertProject);
const setHasHydratedWorkspaces = useSessionStore((state) => state.setHasHydratedWorkspaces);
const canAddProject = supportsProjectAdd && supportsStableProjectIdentity;
return useCallback(
async (path: string) => {
@@ -36,14 +38,7 @@ export function useOpenProject(
});
return result;
},
[
upsertProject,
canAddProject,
client,
isConnected,
normalizedServerId,
setHasHydratedWorkspaces,
],
[canAddProject, client, isConnected, normalizedServerId],
);
}
@@ -57,8 +52,6 @@ export function useCloneGithubProject(
const normalizedServerId = serverId?.trim() ?? "";
const client = useHostRuntimeClient(normalizedServerId);
const isConnected = useHostRuntimeIsConnected(normalizedServerId);
const upsertProject = useSessionStore((state) => state.upsertProject);
const setHasHydratedWorkspaces = useSessionStore((state) => state.setHasHydratedWorkspaces);
return useCallback(
async (repo: string, targetDirectory: string, cloneProtocol?: ProjectGithubCloneProtocol) => {
@@ -73,6 +66,6 @@ export function useCloneGithubProject(
setHasHydratedWorkspaces,
});
},
[client, isConnected, normalizedServerId, setHasHydratedWorkspaces, upsertProject],
[client, isConnected, normalizedServerId],
);
}
+50 -37
View File
@@ -1,17 +1,20 @@
import { useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from "react";
import { useCallback, useEffect, useMemo, useRef } from "react";
import equal from "fast-deep-equal";
import { useStoreWithEqualityFn } from "zustand/traditional";
import {
getHostRuntimeStore,
acquireDirectoryDemand,
isHostRuntimeDirectoryLoading,
readHostRuntimeSnapshot,
refreshHostDirectories,
useHostRuntimeVersion,
useHosts,
type HostRuntimeSnapshot,
} from "@/runtime/host-runtime";
import {
useSessionStore,
useWorkspaceDirectories,
type WorkspaceDirectoryView,
type ProjectDescriptor,
type WorkspaceDescriptor,
} from "@/stores/session-store";
} from "@/stores/session-store-hooks";
import { buildProjects, type ProjectHost, type ProjectSummary } from "@/utils/projects";
export interface ProjectHostError {
@@ -73,23 +76,41 @@ function toProjectHostRuntimeState(
};
}
function selectProjectHostReplicas(
function createProjectHostReplicasSelector(
hosts: readonly { serverId: string; label: string }[],
enabled: boolean,
): (state: ReturnType<typeof useSessionStore.getState>) => ProjectHostReplica[] {
if (!enabled) {
return () => EMPTY_PROJECT_HOST_REPLICAS;
}
return (state) =>
hosts.map((host) => {
const session = state.sessions[host.serverId];
return {
serverId: host.serverId,
serverName: host.label,
workspaces: Array.from(session?.workspaces.values() ?? []),
projects: Array.from(session?.projects.values() ?? []),
};
): (directories: ReadonlyMap<string, WorkspaceDirectoryView>) => ProjectHostReplica[] {
let previousInputs: Array<{
workspaces: WorkspaceDirectoryView["workspaces"] | undefined;
projects: WorkspaceDirectoryView["projects"] | undefined;
}> | null = null;
let previousResult = EMPTY_PROJECT_HOST_REPLICAS;
return (directories) => {
if (!enabled) return EMPTY_PROJECT_HOST_REPLICAS;
const inputs = hosts.map((host) => {
const directory = directories.get(host.serverId);
return { workspaces: directory?.workspaces, projects: directory?.projects };
});
const priorInputs = previousInputs;
if (
priorInputs &&
inputs.every(
(input, index) =>
input.workspaces === priorInputs[index]?.workspaces &&
input.projects === priorInputs[index]?.projects,
)
) {
return previousResult;
}
previousInputs = inputs;
previousResult = hosts.map((host, index) => ({
serverId: host.serverId,
serverName: host.label,
workspaces: Array.from(inputs[index]?.workspaces?.values() ?? []),
projects: Array.from(inputs[index]?.projects?.values() ?? []),
}));
return previousResult;
};
}
export function deriveProjectsFromReplica(input: {
@@ -134,15 +155,8 @@ function useProjectHostRuntimeStates(
serverIds: readonly string[],
enabled: boolean,
): ProjectHostRuntimeState[] {
const runtime = getHostRuntimeStore();
const previousStatesRef = useRef<ProjectHostRuntimeState[]>([]);
const subscribe = useCallback(
(onStoreChange: () => void) =>
enabled ? runtime.subscribeAll(onStoreChange) : () => undefined,
[enabled, runtime],
);
const getSnapshot = useCallback(() => (enabled ? runtime.getVersion() : 0), [enabled, runtime]);
const runtimeSnapshotTick = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
const runtimeSnapshotTick = useHostRuntimeVersion();
return useMemo(() => {
if (!enabled) {
previousStatesRef.current = EMPTY_PROJECT_HOST_RUNTIME_STATES;
@@ -150,33 +164,32 @@ function useProjectHostRuntimeStates(
}
void runtimeSnapshotTick;
const nextStates = serverIds.map((serverId) =>
toProjectHostRuntimeState(serverId, runtime.getSnapshot(serverId)),
toProjectHostRuntimeState(serverId, readHostRuntimeSnapshot(serverId)),
);
if (equal(previousStatesRef.current, nextStates)) {
return previousStatesRef.current;
}
previousStatesRef.current = nextStates;
return nextStates;
}, [enabled, runtime, runtimeSnapshotTick, serverIds]);
}, [enabled, runtimeSnapshotTick, serverIds]);
}
export function useProjects(options: UseProjectsOptions = {}): UseProjectsResult {
const enabled = options.enabled ?? true;
const hosts = useHosts();
const runtime = getHostRuntimeStore();
const serverIds = useMemo(
() => (enabled ? hosts.map((host) => host.serverId) : []),
[enabled, hosts],
);
useEffect(() => {
const releases = serverIds.map((serverId) => runtime.acquireDirectoryDemand(serverId));
const releases = serverIds.map(acquireDirectoryDemand);
return () => releases.forEach((release) => release());
}, [runtime, serverIds]);
const replicaSelector = useMemo(
() => selectProjectHostReplicas(hosts, enabled),
}, [serverIds]);
const selectReplicas = useMemo(
() => createProjectHostReplicasSelector(hosts, enabled),
[enabled, hosts],
);
const replicas = useStoreWithEqualityFn(useSessionStore, replicaSelector, equal);
const replicas = useWorkspaceDirectories(serverIds, selectReplicas);
const runtimeStates = useProjectHostRuntimeStates(serverIds, enabled);
const derived = useMemo(
() => deriveProjectsFromReplica({ replicas, runtimeStates }),
@@ -184,8 +197,8 @@ export function useProjects(options: UseProjectsOptions = {}): UseProjectsResult
);
const refetch = useCallback(() => {
if (!enabled) return;
for (const serverId of serverIds) void runtime.refreshDirectories(serverId);
}, [enabled, runtime, serverIds]);
for (const serverId of serverIds) void refreshHostDirectories(serverId);
}, [enabled, serverIds]);
return {
...derived,
@@ -5,7 +5,7 @@ import type { AgentProvider, ProviderSnapshotEntry } from "@getpaseo/protocol/ag
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useSessionStore } from "@/stores/session-store";
import { useServerFeature } from "@/stores/session-store-hooks";
import { useReplicaQuery } from "@/data/query";
import { queryClient as singletonQueryClient } from "@/data/query-client";
import {
@@ -138,9 +138,7 @@ export function useProvidersSnapshot(
const enabled = (options.enabled ?? true) && retainedPanelActive;
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
const cwd = normalizeProvidersSnapshotCwd(options.cwd);
const supportsSnapshot = useSessionStore(
(state) => state.sessions[serverId ?? ""]?.serverInfo?.features?.providersSnapshot === true,
);
const supportsSnapshot = useServerFeature(serverId, "providersSnapshot");
const queryKey = useMemo(() => providersSnapshotQueryKey(serverId, cwd), [cwd, serverId]);
@@ -17,7 +17,7 @@ import type {
FetchAggregatedSchedulesState,
} from "@/schedules/aggregated-schedules";
import { schedulesQueryBaseKey } from "@/schedules/aggregated-schedules";
import { useSessionStore } from "@/stores/session-store";
import { getHostClient } from "@/runtime/host-runtime";
export type CreateScheduleInput = Omit<CreateScheduleOptions, "requestId">;
export type UpdateScheduleInput = Omit<UpdateScheduleOptions, "requestId">;
@@ -52,7 +52,7 @@ export function updateAggregatedSchedulesData(
}
function requireClient(serverId: string, unavailableMessage: string): DaemonClient {
const client = useSessionStore.getState().sessions[serverId]?.client ?? null;
const client = getHostClient(serverId);
if (!client) {
throw new Error(unavailableMessage);
}
+2 -3
View File
@@ -1,7 +1,7 @@
import { useMemo } from "react";
import { useFetchQuery } from "@/data/query";
import {
getHostRuntimeStore,
scheduleRuntime,
useHostRuntimeConnectionStatuses,
useHosts,
} from "@/runtime/host-runtime";
@@ -35,7 +35,6 @@ export interface UseSchedulesResult {
export function useSchedules(): UseSchedulesResult {
const hosts = useHosts();
const runtime = getHostRuntimeStore();
const hostInputs = useMemo<ScheduleHostInput[]>(
() => hosts.map((host) => ({ serverId: host.serverId, serverName: host.label })),
[hosts],
@@ -49,7 +48,7 @@ export function useSchedules(): UseSchedulesResult {
const query = useFetchQuery({
queryKey: [...schedulesQueryKey(serverIds), connectionStatusKey],
queryFn: () => fetchAggregatedSchedules({ hosts: hostInputs, runtime }),
queryFn: () => fetchAggregatedSchedules({ hosts: hostInputs, runtime: scheduleRuntime }),
dataShape: "list",
staleTimeMs: 5_000,
});
+19 -26
View File
@@ -1,12 +1,10 @@
import { useMemo, useRef } from "react";
import { shallow } from "zustand/shallow";
import { useStoreWithEqualityFn } from "zustand/traditional";
import type {
SidebarProjectEntry,
SidebarWorkspacePlacement,
} from "@/hooks/use-sidebar-workspaces-list";
import { applyStoredOrdering } from "@/hooks/sidebar-workspaces-view-model";
import { useSessionStore } from "@/stores/session-store";
import { useWorkspaceDirectories } from "@/stores/session-store-hooks";
export interface PinnedSidebarKeys {
pinnedWorkspaceKeys: string[];
@@ -83,30 +81,25 @@ export function usePinnedSidebarKeys(projects: SidebarProjectEntry[]): PinnedSid
),
[projects],
);
const workspaceMaps = useStoreWithEqualityFn(
useSessionStore,
(state) => serverIds.map((serverId) => state.sessions[serverId]?.workspaces ?? null),
shallow,
);
return useMemo(() => {
const workspaceMapByServerId = new Map<
string,
ReadonlyMap<string, { pinnedAt?: string | null }>
>();
for (let index = 0; index < serverIds.length; index += 1) {
const serverId = serverIds[index];
const workspaceMap = workspaceMaps[index];
if (serverId && workspaceMap) {
workspaceMapByServerId.set(serverId, workspaceMap);
return useWorkspaceDirectories(
serverIds,
(directories) => {
const workspaceMapByServerId = new Map<
string,
ReadonlyMap<string, { pinnedAt?: string | null }>
>();
for (const [serverId, directory] of directories) {
workspaceMapByServerId.set(serverId, directory.workspaces);
}
}
const nextKeys = buildPinnedSidebarKeys(projects, workspaceMapByServerId);
if (arePinnedSidebarKeysEqual(previousKeysRef.current, nextKeys)) {
return previousKeysRef.current;
}
previousKeysRef.current = nextKeys;
return nextKeys;
}, [projects, serverIds, workspaceMaps]);
const nextKeys = buildPinnedSidebarKeys(projects, workspaceMapByServerId);
if (arePinnedSidebarKeysEqual(previousKeysRef.current, nextKeys)) {
return previousKeysRef.current;
}
previousKeysRef.current = nextKeys;
return nextKeys;
},
arePinnedSidebarKeysEqual,
);
}
// Splits the sidebar into a dedicated Pinned section (chats) and the regular list below.
@@ -1,7 +1,6 @@
import { useMemo, useRef } from "react";
import { useStoreWithEqualityFn } from "zustand/traditional";
import { useCreateFlowStore } from "@/stores/create-flow-store";
import { useSessionStore } from "@/stores/session-store";
import { useWorkspaceDirectories } from "@/stores/session-store-hooks";
import {
areSidebarWorkspaceSessionsEqual,
buildSidebarWorkspaceEntries,
@@ -23,10 +22,26 @@ export function useSidebarWorkspaceEntries(
() => Array.from(new Set(placements.map((placement) => placement.serverId))),
[placements],
);
const sessions = useStoreWithEqualityFn(
useSessionStore,
(state) =>
enabled ? selectSidebarWorkspaceSessions(state.sessions, serverIds) : EMPTY_SESSIONS,
const sessions = useWorkspaceDirectories(
serverIds,
(directories) =>
enabled
? selectSidebarWorkspaceSessions(
Object.fromEntries(
serverIds.map((serverId) => {
const directory = directories.get(serverId);
return [
serverId,
{
workspaces: directory?.workspaces ?? new Map(),
workspaceAgentActivity: directory?.workspaceAgentActivity ?? new Map(),
},
];
}),
),
serverIds,
)
: EMPTY_SESSIONS,
areSidebarWorkspaceSessionsEqual,
);
const pendingCreateAttempts = useCreateFlowStore((state) =>
@@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
import { useMutation } from "@tanstack/react-query";
import { useToast } from "@/contexts/toast-context";
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { getHostClient } from "@/runtime/host-runtime";
// Everything the pin toggle actually needs. Kept narrower than SidebarWorkspaceEntry so the
// global keyboard handler can build one from the active route selection without a sidebar row.
@@ -30,7 +30,7 @@ export function useSidebarWorkspacePinController(): ToggleSidebarWorkspacePin {
workspace: PinnableWorkspace;
pinned: boolean;
}) => {
const client = getHostRuntimeStore().getClient(workspace.serverId);
const client = getHostClient(workspace.serverId);
if (!client) {
throw new Error(t("sidebar.workspace.toasts.hostDisconnected"));
}
@@ -1,11 +1,18 @@
import { useCallback, useEffect, useMemo } from "react";
import { useStoreWithEqualityFn } from "zustand/traditional";
import { useCreateFlowStore } from "@/stores/create-flow-store";
import { useSessionStore } from "@/stores/session-store";
import { useWorkspaceDirectoryServerIds } from "@/stores/session-store-hooks";
import {
useWorkspaceDirectories,
useWorkspaceDirectoryServerIds,
} from "@/stores/session-store-hooks";
import { workspaceEqualityFns } from "@/stores/session-store-hooks/selectors";
import { useHostProjects } from "@/projects/host-projects";
import { getHostRuntimeStore, useHostRegistryLoaded, useHosts } from "@/runtime/host-runtime";
import {
acquireDirectoryDemand,
refreshHostDirectories,
useHostRegistryLoaded,
useHosts,
} from "@/runtime/host-runtime";
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
import { useSidebarViewStore } from "@/stores/sidebar-view-store";
import {
@@ -64,19 +71,30 @@ export function useSidebarProjectStatusBucket(input: {
workspaceEqualityFns.deep,
);
const selector = useCallback(
(state: { sessions: Record<string, ProjectStatusSession | undefined> }) => {
const serverIds = useMemo(
() => Array.from(new Set(workspaces.map((workspace) => workspace.serverId))),
[workspaces],
);
return useWorkspaceDirectories(
serverIds,
(directories) => {
if (!enabled) return null;
return deriveProjectStatusBucket({
workspaces,
sessions: state.sessions,
pendingCreateAttempts,
});
const sessions = Object.fromEntries(
serverIds.map((serverId) => {
const directory = directories.get(serverId);
return [
serverId,
{
workspaces: directory?.workspaces ?? new Map(),
workspaceAgentActivity: directory?.workspaceAgentActivity ?? new Map(),
} satisfies ProjectStatusSession,
];
}),
);
return deriveProjectStatusBucket({ workspaces, sessions, pendingCreateAttempts });
},
[enabled, pendingCreateAttempts, workspaces],
Object.is,
);
return useStoreWithEqualityFn(useSessionStore, selector, Object.is);
}
const EMPTY_ORDER: string[] = [];
@@ -98,7 +116,6 @@ export function useSidebarWorkspacesList(options?: {
hostFilters?: readonly string[];
enabled?: boolean;
}): SidebarWorkspacesListResult {
const runtime = getHostRuntimeStore();
const allHosts = useHosts();
const hostRegistryLoaded = useHostRegistryLoaded();
const allServerIds = useMemo(() => allHosts.map((h) => h.serverId), [allHosts]);
@@ -123,9 +140,9 @@ export function useSidebarWorkspacesList(options?: {
}, [allServerIds, hostFilters, hostRegistryLoaded]);
useEffect(() => {
if (!isActive) return;
const releases = serverIds.map((serverId) => runtime.acquireDirectoryDemand(serverId));
const releases = serverIds.map(acquireDirectoryDemand);
return () => releases.forEach((release) => release());
}, [isActive, runtime, serverIds]);
}, [isActive, serverIds]);
useEffect(() => {
if (!hostRegistryLoaded) {
@@ -176,14 +193,14 @@ export function useSidebarWorkspacesList(options?: {
const refreshAll = useCallback(() => {
if (!isActive) return;
for (const serverId of serverIds) {
void runtime.refreshDirectories(serverId).catch((error) => {
void refreshHostDirectories(serverId).catch((error) => {
console.error("[WorkspaceFetch][sidebar-refresh] failed", {
serverId,
error,
});
});
}
}, [isActive, runtime, serverIds]);
}, [isActive, serverIds]);
const loadingState = deriveSidebarLoadingState({
isActive,
@@ -10,6 +10,8 @@ import {
buildHostWorkspaceRoute,
buildOpenProjectRoute,
} from "@/utils/host-routes";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { getManagedDaemonStartService } from "@/runtime/daemon-start-service";
export interface HostRuntimeBootstrapStore {
boot: () => Promise<void>;
@@ -37,6 +39,14 @@ export function startHostRuntimeBootstrap(input: StartHostRuntimeBootstrapInput)
});
}
export function startDefaultHostRuntimeBootstrap(shouldStartDaemon: DaemonStartCondition): void {
startHostRuntimeBootstrap({
store: getHostRuntimeStore(),
daemonStartService: getManagedDaemonStartService(),
shouldStartDaemon,
});
}
const WELCOME_ROUTE: Href = "/welcome";
export type StartupBlocker =
+104 -173
View File
@@ -18,8 +18,7 @@ import { StyleSheet as RNStyleSheet, Text, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import invariant from "tiny-invariant";
import { shallow, useShallow } from "zustand/shallow";
import { useStoreWithEqualityFn } from "zustand/traditional";
import { useShallow } from "zustand/shallow";
import { AgentStreamView, type AgentStreamViewHandle } from "@/agent-stream/view";
import { ArchivedAgentCallout } from "@/components/archived-agent-callout";
import { KeyboardDock } from "@/components/keyboard-dock";
@@ -78,6 +77,12 @@ import {
useHostRuntimeLastError,
useHosts,
} from "@/runtime/host-runtime";
import {
acceptAgentSnapshot,
getAgentSnapshot,
publishAgentDetails,
replaceAgentPendingPermissions,
} from "@/runtime/session-data";
import {
deriveRouteBottomAnchorIntent,
deriveRouteBottomAnchorRequest,
@@ -87,18 +92,19 @@ import { AgentTracks, hasAgentTracks } from "@/panels/agent-tracks";
import { useCreateFlowStore } from "@/stores/create-flow-store";
import { buildDraftStoreKey, generateDraftId } from "@/stores/draft-keys";
import {
selectAgentTimelineState,
selectAgentTurnPresentation,
useAgentFields,
useAgentConversation,
useAgentTurn,
useConnection,
type Agent,
useSessionStore,
} from "@/stores/session-store";
} from "@/stores/session-store-hooks";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
import { openWorkspaceChanges } from "@/workspace-tabs/open-supporting-view";
import { useSettings } from "@/hooks/use-settings";
import type { Theme } from "@/styles/theme";
import type { PendingPermission } from "@/types/shared";
import type { StreamItem, TodoEntry } from "@/types/stream";
import type { StreamItem } from "@/types/stream";
import type { ViewedTimelineStatus, ViewedTimelineUiBridge } from "@/timeline/viewed-timeline-sync";
import { useArchiveFinishedSubagents, useSubagentsForParent } from "@/subagents";
import { getInitDeferred, getInitKey } from "@/utils/agent-initialization";
@@ -134,16 +140,6 @@ interface ChatAgentSelectedState extends ChatAgentStateShape {
attentionReason: Agent["attentionReason"] | null;
}
function resolveChatAgentFromSession(
state: ReturnType<typeof useSessionStore.getState>,
serverId: string,
agentId: string | undefined,
): Agent | null {
if (!agentId) return null;
const session = state.sessions[serverId];
return session?.agents?.get(agentId) ?? session?.agentDetails?.get(agentId) ?? null;
}
function readViewedTimelineError(input: {
agentId: string | undefined;
status: ViewedTimelineStatus;
@@ -164,12 +160,7 @@ const EMPTY_CHAT_AGENT_STATE: ChatAgentSelectedState = {
attentionReason: null,
};
function selectChatAgentState(
state: ReturnType<typeof useSessionStore.getState>,
serverId: string,
agentId: string | undefined,
): ChatAgentSelectedState {
const agent = resolveChatAgentFromSession(state, serverId, agentId);
function selectChatAgentState(agent: Agent | null): ChatAgentSelectedState {
if (!agent) return EMPTY_CHAT_AGENT_STATE;
return {
serverId: agent.serverId,
@@ -191,6 +182,26 @@ function selectChatAgentState(
};
}
function selectAgentProjectPlacement(agent: Agent | null) {
return agent ? (agent.projectPlacement ?? null) : null;
}
function selectReadyAgent(agent: Agent) {
return {
agentState: selectChatAgentState(agent),
projectPlacement: selectAgentProjectPlacement(agent),
};
}
const EMPTY_READY_AGENT = {
agentState: EMPTY_CHAT_AGENT_STATE,
projectPlacement: null,
};
function resolveReadyAgent(selected: ReturnType<typeof selectReadyAgent> | null) {
return selected ?? EMPTY_READY_AGENT;
}
function buildChatAgentFromState(
state: ChatAgentStateShape,
projectPlacement: Agent["projectPlacement"] | null,
@@ -307,35 +318,24 @@ function storeFetchedAgentDetail(input: {
projectPlacement: input.result.project,
},
});
const store = useSessionStore.getState();
if (shouldStoreFetchedAgentInActiveDirectory(hydrated)) {
store.setAgents(input.serverId, (previous) => {
const next = new Map(previous);
next.set(hydrated.id, hydrated);
return next;
});
acceptAgentSnapshot(input.serverId, hydrated);
} else {
store.setAgentDetails(input.serverId, (previous) => {
publishAgentDetails(input.serverId, (previous) => {
const next = new Map(previous);
next.set(hydrated.id, hydrated);
return next;
});
}
store.setPendingPermissions(input.serverId, (previous) => {
const next = new Map(previous);
for (const [key, pending] of next.entries()) {
if (pending.agentId === hydrated.id) {
next.delete(key);
}
}
for (const request of hydrated.pendingPermissions) {
replaceAgentPendingPermissions(
input.serverId,
hydrated.id,
hydrated.pendingPermissions.map((request) => {
const key = derivePendingPermissionKey(hydrated.id, request);
next.set(key, { key, agentId: hydrated.id, request });
}
return next;
});
return { key, agentId: hydrated.id, request };
}),
);
return hydrated;
}
@@ -344,24 +344,17 @@ function useAgentPanelDescriptor(
target: { kind: "agent"; agentId: string },
context: { serverId: string },
): PanelDescriptor {
const descriptorState = useSessionStore(
useShallow((state) => {
const session = state.sessions[context.serverId];
const agent =
session?.agents?.get(target.agentId) ?? session?.agentDetails?.get(target.agentId) ?? null;
return {
provider: agent?.provider ?? "codex",
title: agent?.title ?? null,
status: agent?.status ?? null,
pendingPermissionCount: agent?.pendingPermissions.length ?? 0,
requiresAttention: agent?.requiresAttention ?? false,
attentionReason: agent?.attentionReason ?? null,
isTurnActive: selectAgentTurnPresentation(session, target.agentId).isActive,
};
}),
);
const provider = descriptorState.provider;
const label = resolveWorkspaceAgentTabLabel(descriptorState.title);
const descriptorState = useAgentFields(context.serverId, target.agentId, (agent) => ({
provider: agent.provider,
title: agent.title,
status: agent.status,
pendingPermissionCount: agent.pendingPermissions.length,
requiresAttention: agent.requiresAttention ?? false,
attentionReason: agent.attentionReason ?? null,
}));
const isTurnActive = useAgentTurn(context.serverId, target.agentId, (turn) => turn.isActive);
const provider = descriptorState?.provider ?? "codex";
const label = resolveWorkspaceAgentTabLabel(descriptorState?.title ?? null);
const icon = getProviderIcon(provider, context.serverId);
return {
@@ -370,9 +363,9 @@ function useAgentPanelDescriptor(
tooltip: label ?? `${formatProviderLabel(provider)} agent`,
titleState: label ? "ready" : "loading",
icon,
statusBucket: descriptorState.status
statusBucket: descriptorState?.status
? deriveSidebarStateBucket({
status: descriptorState.isTurnActive ? "running" : descriptorState.status,
status: isTurnActive ? "running" : descriptorState.status,
pendingPermissionCount: descriptorState.pendingPermissionCount,
requiresAttention: descriptorState.requiresAttention,
attentionReason: descriptorState.attentionReason,
@@ -414,11 +407,7 @@ function DraftPanel() {
(agentSnapshot: Parameters<typeof normalizeAgentSnapshot>[0]) => {
const normalized = normalizeAgentSnapshot(agentSnapshot, serverId);
const agent = applyLegacyDaemonWorkspaceOwnership({ serverId, agent: normalized });
useSessionStore.getState().setAgents(serverId, (prev) => {
const next = new Map(prev);
next.set(agentSnapshot.id, agent);
return next;
});
acceptAgentSnapshot(serverId, agent);
retargetCurrentTab({ kind: "agent", agentId: agentSnapshot.id });
},
[retargetCurrentTab, serverId],
@@ -543,13 +532,14 @@ function AgentPanelContent({
const runtimeIsConnected = useHostRuntimeIsConnected(runtimeServerId);
const runtimeConnectionStatus = useHostRuntimeConnectionStatus(runtimeServerId);
const runtimeLastError = useHostRuntimeLastError(runtimeServerId);
const hasCachedAgent = useSessionStore((state) => {
if (!resolvedServerId || !resolvedAgentId) return false;
const session = state.sessions[resolvedServerId];
return Boolean(
session?.agents.has(resolvedAgentId) || session?.agentDetails.has(resolvedAgentId),
);
});
const hasCachedAgent = Boolean(
useAgentFields(
resolvedServerId ?? null,
resolvedAgentId ?? null,
(agent) => agent.id,
Object.is,
),
);
const connectionServerId = resolvedServerId ?? null;
const daemon = connectionServerId
@@ -611,25 +601,13 @@ function AgentPanelBody({
}) {
const { t } = useTranslation();
const { isArchivingAgent: _isArchivingAgent } = useArchiveAgent();
const hasSession = useSessionStore((state) => Boolean(state.sessions[serverId]));
const projectPlacement = useStoreWithEqualityFn(
useSessionStore,
(state) => {
if (!agentId) {
return null;
}
const session = state.sessions[serverId];
return (
session?.agents?.get(agentId)?.projectPlacement ??
session?.agentDetails?.get(agentId)?.projectPlacement ??
null
);
},
(a, b) => a === b || JSON.stringify(a) === JSON.stringify(b),
);
const agentState = useSessionStore(
useShallow((state) => selectChatAgentState(state, serverId, agentId)),
);
const hasSession = useConnection(serverId, (connection) => connection.exists);
const selected = useAgentFields(serverId, agentId, (agent) => ({
projectPlacement: selectAgentProjectPlacement(agent),
agentState: selectChatAgentState(agent),
}));
const projectPlacement = selected?.projectPlacement ?? null;
const agentState = selected?.agentState ?? EMPTY_CHAT_AGENT_STATE;
const [lookupState, setLookupState] = useState<AgentLookupState>({ tag: "idle" });
const lookupAttemptTokenRef = useRef(0);
const retryAgentLookup = useCallback(() => setLookupState({ tag: "idle" }), []);
@@ -804,42 +782,22 @@ function ChatAgentContent({
routeKey: string;
reason: "initial-entry" | "resume";
} | null>(null);
const agentState = useSessionStore(
useShallow((state) => selectChatAgentState(state, serverId, agentId)),
);
const projectPlacement = useStoreWithEqualityFn(
useSessionStore,
(state) => {
if (!agentId) {
return null;
}
const session = state.sessions[serverId];
return (
session?.agents?.get(agentId)?.projectPlacement ??
session?.agentDetails?.get(agentId)?.projectPlacement ??
null
);
},
(a, b) => a === b || JSON.stringify(a) === JSON.stringify(b),
);
const isInitializingFromMap = useSessionStore((state) =>
agentId ? (state.sessions[serverId]?.initializingAgents?.get(agentId) ?? false) : false,
);
const historySyncGeneration = useSessionStore(
(state) => state.sessions[serverId]?.historySyncGeneration ?? 0,
);
const replicaTimelineStatus = useSessionStore((state) =>
agentId
? selectAgentTimelineState(state.sessions[serverId], agentId).status
: ("cold" as const),
const { agentState, projectPlacement } = resolveReadyAgent(
useAgentFields(serverId, agentId, selectReadyAgent),
);
const conversation = useAgentConversation(serverId, agentId, (value) => ({
isInitializing: value.isInitializing,
historySyncGeneration: value.historySyncGeneration,
agentHistorySyncGeneration: value.agentHistorySyncGeneration,
timelineStatus: value.timeline.status,
viewedTimelineSync: value.viewedTimelineSync,
}));
const isInitializingFromMap = conversation.isInitializing;
const historySyncGeneration = conversation.historySyncGeneration;
const replicaTimelineStatus = conversation.timelineStatus;
const hasAppliedAuthoritativeHistory = replicaTimelineStatus === "synced";
const agentHistorySyncGeneration = useSessionStore((state) =>
agentId ? (state.sessions[serverId]?.agentHistorySyncGeneration?.get(agentId) ?? -1) : -1,
);
const viewedTimelineSync = useSessionStore(
(state) => state.sessions[serverId]?.viewedTimelineSync ?? null,
);
const agentHistorySyncGeneration = conversation.agentHistorySyncGeneration;
const viewedTimelineSync = conversation.viewedTimelineSync;
const subscribeToVisibilityCatchUp = useCallback(
(listener: () => void) => viewedTimelineSync?.subscribe(listener) ?? (() => {}),
[viewedTimelineSync],
@@ -865,7 +823,7 @@ function ChatAgentContent({
const hasActiveCreateHandoff = useCreateFlowStore((state) =>
findActiveCreateHandoff({ pendingByDraftId: state.pendingByDraftId, serverId, agentId }),
);
const hasSession = useSessionStore((state) => Boolean(state.sessions[serverId]));
const hasSession = useConnection(serverId, (connection) => connection.exists);
const [missingAgentState, setMissingAgentState] = useState<AgentScreenMissingState>({
kind: "idle",
});
@@ -1117,9 +1075,7 @@ function ChatAgentContent({
if (attemptToken !== initAttemptTokenRef.current) {
return;
}
const currentSession = useSessionStore.getState().sessions[serverId];
const currentAgent =
currentSession?.agents.get(agentId) ?? currentSession?.agentDetails.get(agentId);
const currentAgent = getAgentSnapshot(serverId, agentId);
if (!currentAgent) {
const result = await client.fetchAgent({ agentId });
if (attemptToken !== initAttemptTokenRef.current) {
@@ -1270,9 +1226,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
}) {
const { t } = useTranslation();
const subagentRows = useSubagentsForParent({ serverId, parentAgentId: agentId });
const tasks = useSessionStore((state): TodoEntry[] | undefined =>
state.sessions[serverId]?.agentTasks.get(agentId),
);
const tasks = useAgentConversation(serverId, agentId, (value) => value.tasks, Object.is);
const archiveFinishedSubagents = useArchiveFinishedSubagents({
serverId,
parentAgentId: agentId,
@@ -1485,44 +1439,21 @@ const AgentStreamSection = memo(function AgentStreamSection({
const bottomOverlayControlClearance = hasVisibleComposerTracks
? resolveComposerTrackControlClearance(isCompactFormFactor)
: 0;
const streamItemsRaw = useSessionStore((state) =>
agentId ? state.sessions[serverId]?.agentStreamTail?.get(agentId) : undefined,
);
const pendingMessageSubmissions = useSessionStore(
useShallow((state) =>
agentId
? getActiveMessageSubmissions(state.sessions[serverId]?.messageSubmissions.get(agentId))
: EMPTY_MESSAGE_SUBMISSIONS,
),
);
const turnPresentation = useSessionStore(
useShallow((state) =>
agentId
? selectAgentTurnPresentation(state.sessions[serverId], agentId)
: { isActive: false, isCancelling: false, startedAt: null, turnId: null },
),
);
const conversation = useAgentConversation(serverId, agentId, (value) => ({
streamItems: value.streamItems,
messageSubmissions: value.messageSubmissions,
pendingPermissions: value.pendingPermissions,
turn: value.turn,
}));
const streamItemsRaw = conversation.streamItems;
const pendingMessageSubmissions = agentId
? getActiveMessageSubmissions(conversation.messageSubmissions)
: EMPTY_MESSAGE_SUBMISSIONS;
const turnPresentation = conversation.turn;
const streamItems = streamItemsRaw ?? EMPTY_STREAM_ITEMS;
const pendingPermissionList = useStoreWithEqualityFn(
useSessionStore,
(state) => {
if (!agentId) {
return EMPTY_PENDING_PERMISSION_LIST;
}
const allPendingPermissions = state.sessions[serverId]?.pendingPermissions;
if (!allPendingPermissions) {
return EMPTY_PENDING_PERMISSION_LIST;
}
const filtered: PendingPermission[] = [];
for (const permission of allPendingPermissions.values()) {
if (permission.agentId === agentId) {
filtered.push(permission);
}
}
return filtered.length > 0 ? filtered : EMPTY_PENDING_PERMISSION_LIST;
},
shallow,
);
const pendingPermissionList = agentId
? conversation.pendingPermissions
: EMPTY_PENDING_PERMISSION_LIST;
const pendingPermissions = useMemo(() => {
if (pendingPermissionList.length === 0) {
return EMPTY_PENDING_PERMISSIONS;
@@ -1665,7 +1596,7 @@ function ActiveAgentComposer({
const handleClientSlashCommand = useCallback(
async (command: ClientSlashCommand) => {
const agent = resolveChatAgentFromSession(useSessionStore.getState(), serverId, agentId);
const agent = getAgentSnapshot(serverId, agentId);
if (!agent) {
throw new Error("Agent not found");
}
+4 -6
View File
@@ -7,7 +7,8 @@ import { supportsDesktopPaneSplits, useIsCompactFormFactor } from "@/constants/l
import { usePaneContext } from "@/panels/pane-context";
import { useSettings } from "@/hooks/use-settings";
import { PluginComposerPills } from "@/plugins";
import { useSessionStore } from "@/stores/session-store";
import { useServerFeature } from "@/stores/session-store-hooks";
import { getAgentSnapshot } from "@/runtime/session-data";
import {
type ArchiveFinishedStatus,
useArchiveSubagent,
@@ -55,15 +56,12 @@ export const AgentTracks = memo(function AgentTracks({
const canSplit = supportsDesktopPaneSplits() && !isCompact;
const openInSidePane = useSettings((settings) => settings.openInSidePane);
const workspaceKey = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
const canDetachSubagents = useSessionStore(
(state) => state.sessions[serverId]?.serverInfo?.features?.agentDetach === true,
);
const canDetachSubagents = useServerFeature(serverId, "agentDetach");
const archiveSubagent = useArchiveSubagent({ serverId });
const detachSubagent = useDetachSubagent({ serverId });
const handleOpenSubagent = useCallback(
(subagentId: string) => {
const session = useSessionStore.getState().sessions[serverId];
const agent = session?.agents.get(subagentId) ?? session?.agentDetails.get(subagentId);
const agent = getAgentSnapshot(serverId, subagentId);
if (agent?.workspaceId && agent.workspaceId !== workspaceId) {
navigateToAgent({ serverId, agentId: subagentId });
return;
@@ -14,7 +14,8 @@ import { useIsCompactFormFactor } from "@/constants/layout";
import type { AgentScreenAgent } from "@/hooks/use-agent-screen-state-machine";
import { usePaneContext } from "@/panels/pane-context";
import { definePanel, type PanelDescriptor } from "@/panels/panel-registry";
import { useSessionStore } from "@/stores/session-store";
import { useActiveAgentFields, useAgentFields, useConnection } from "@/stores/session-store-hooks";
import { useHostRuntimeClient } from "@/runtime/host-runtime";
import { useSubagentsForParent } from "@/subagents/select";
import { SubagentsTrack } from "@/subagents/track";
import {
@@ -82,8 +83,11 @@ function useProviderSubagentDescriptor(
providerSubagentKey(context.serverId, target.parentAgentId, target.subagentId),
),
);
const parentProvider = useSessionStore(
(state) => state.sessions[context.serverId]?.agents.get(target.parentAgentId)?.provider,
const parentProvider = useActiveAgentFields(
context.serverId,
target.parentAgentId,
(agent) => agent.provider,
Object.is,
);
const provider = descriptor?.provider ?? parentProvider ?? "agent";
// The task names the tab; the subagent type is supporting detail beside the provider.
@@ -118,14 +122,14 @@ function ProviderSubagentPanel() {
timeline: state.timelines.get(key) ?? null,
})),
);
const parent = useSessionStore(
(state) =>
state.sessions[serverId]?.agents.get(target.parentAgentId) ??
state.sessions[serverId]?.agentDetails.get(target.parentAgentId) ??
null,
);
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
const serverInfo = useSessionStore((state) => state.sessions[serverId]?.serverInfo ?? null);
const parent = useAgentFields(serverId, target.parentAgentId, (agent) => ({
provider: agent.provider,
cwd: agent.cwd,
workspaceId: agent.workspaceId,
projectPlacement: agent.projectPlacement,
}));
const client = useHostRuntimeClient(serverId);
const serverInfo = useConnection(serverId, (connection) => connection.serverInfo);
// COMPAT(providerSubagents): added in v0.2.11, remove after 2027-01-12.
const supported = serverInfo?.features?.providerSubagents === true;
const [isLoadingOlder, setIsLoadingOlder] = useState(false);
+5 -5
View File
@@ -12,8 +12,8 @@ import { definePanel, type PanelDescriptor } from "@/panels/panel-registry";
import { queryClient } from "@/data/query-client";
import { buildTerminalsQueryKey } from "@/screens/workspace/terminals/state";
import { usePanelStore } from "@/stores/panel-store";
import { useSessionStore } from "@/stores/session-store";
import { useWorkspaceDirectory, useWorkspaceFields } from "@/stores/session-store-hooks";
import { useHostRuntimeClient } from "@/runtime/host-runtime";
type ListTerminalsPayload = ListTerminalsResponse["payload"];
@@ -37,7 +37,7 @@ function useTerminalPanelDescriptor(
context: { serverId: string; workspaceId: string },
): PanelDescriptor {
const { t } = useTranslation();
const client = useSessionStore((state) => state.sessions[context.serverId]?.client ?? null);
const client = useHostRuntimeClient(context.serverId);
const workspaceDirectory = useWorkspaceDirectory(context.serverId, context.workspaceId);
const terminalsQuery = useQuery(
{
@@ -78,9 +78,9 @@ function useTerminalPanelDescriptor(
function TerminalPanel() {
const { serverId, workspaceId, target, openFileInWorkspace } = usePaneContext();
const { isWorkspaceFocused, isPaneFocused } = usePaneFocus();
const workspaceFields = useWorkspaceFields(serverId, workspaceId, (w) => ({
workspaceDirectory: w.workspaceDirectory,
isGitCheckout: w.projectKind === "git",
const workspaceFields = useWorkspaceFields(serverId, workspaceId, (workspace) => ({
workspaceDirectory: workspace.workspaceDirectory,
isGitCheckout: workspace.projectKind === "git",
}));
const workspaceDirectory = workspaceFields?.workspaceDirectory || null;
const isGitCheckout = workspaceFields?.isGitCheckout ?? false;
+2 -1
View File
@@ -11,6 +11,7 @@ export function PluginCatalogSync({
serverId: string;
client: DaemonClient;
}) {
const viewedTimeline = useViewedTimelineSync(serverId);
const connected = useHostRuntimeIsConnected(serverId);
const supported = useHostFeature(serverId, "plugins");
@@ -58,7 +59,7 @@ export function PluginCatalogSync({
cancelled = true;
unsubscribe();
};
}, [client, connected, serverId, supported]);
}, [client, connected, serverId, supported, viewedTimeline]);
useEffect(() => () => pluginRegistry.removeHost(serverId), [serverId]);
return null;
@@ -25,7 +25,7 @@ const agent: Agent = {
id: "agent-1",
provider: "codex",
status: "running",
activeTurn: null,
turn: { phase: "idle", cancellationRequestId: null },
createdAt: new Date("2026-08-16T10:01:00.000Z"),
updatedAt: new Date("2026-08-16T10:02:00.000Z"),
lastUserMessageAt: null,
@@ -4,10 +4,8 @@ import { useCommandCenterActions } from "@/command-center/provider";
import { useToast } from "@/contexts/toast-context";
import { useHostRuntimeClient } from "@/runtime/host-runtime";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { useSessionStore } from "@/stores/session-store";
import { useWorkspaceExists } from "@/stores/session-store-hooks";
import { useAgentBelongsToWorkspace, useWorkspaceExists } from "@/stores/session-store-hooks";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity";
import { createPluginClientStateSource } from "../client-state/source";
import { hostIdFromPathname } from "../routes";
import { useInstalledPlugins } from "../registry";
@@ -27,17 +25,7 @@ export function PluginCommandCenterActions() {
workspaceKey ? (state.layoutByWorkspace[workspaceKey] ?? null) : null,
);
const focusedAgentId = getFocusedAgentId(layout);
const agentExists = useSessionStore((state) => {
if (!serverId || !focusedAgentId) return null;
const session = state.sessions[serverId];
const agent =
session?.agents.get(focusedAgentId) ?? session?.agentDetails.get(focusedAgentId) ?? null;
return (
Boolean(agent) &&
Boolean(workspaceId) &&
normalizeWorkspaceOpaqueId(agent?.workspaceId) === normalizeWorkspaceOpaqueId(workspaceId)
);
});
const agentExists = useAgentBelongsToWorkspace(serverId, focusedAgentId, workspaceId);
const client = useHostRuntimeClient(serverId ?? "");
const installed = useInstalledPlugins();
const plugins = useMemo(
@@ -14,10 +14,8 @@ import { useIsCompactFormFactor } from "@/constants/layout";
import { usePaneContext } from "@/panels/pane-context";
import { definePanel, type PanelDescriptor } from "@/panels/panel-registry";
import { useHostRuntimeClient, useHosts } from "@/runtime/host-runtime";
import { useSessionStore } from "@/stores/session-store";
import { useWorkspaceExists } from "@/stores/session-store-hooks";
import { useAgentBelongsToWorkspace, useWorkspaceExists } from "@/stores/session-store-hooks";
import type { Theme } from "@/styles/theme";
import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity";
import { usePluginHostNavigation } from "../host-navigation";
import { createPluginClientStateSource } from "../client-state/source";
import { toPluginTheme } from "../theme";
@@ -44,15 +42,11 @@ function PluginPanelBody({ theme }: { theme: PluginTheme }) {
const plugin = useInstalledPlugin(serverId, target.pluginId);
const contribution = resolvePluginWorkspacePanel(plugin, target);
const workspaceExists = useWorkspaceExists(serverId, workspaceId);
const agentExists = useSessionStore((state) => {
if (target.context !== "agent") return null;
const session = state.sessions[serverId];
const agent = session?.agents.get(target.agentId) ?? session?.agentDetails.get(target.agentId);
return (
Boolean(agent) &&
normalizeWorkspaceOpaqueId(agent?.workspaceId) === normalizeWorkspaceOpaqueId(workspaceId)
);
});
const agentExists = useAgentBelongsToWorkspace(
serverId,
target.context === "agent" ? target.agentId : null,
workspaceId,
);
const client = useHostRuntimeClient(serverId);
const runtime = useMemo(
() => createPluginSurfaceRuntime(client, target.pluginId),
+5 -4
View File
@@ -6,8 +6,8 @@ import { useHostFeatureAvailabilityMap } from "@/runtime/host-features";
import { projectIconCache } from "@/projects/icon-cache";
import type { ProjectIconTarget } from "@/projects/icon-target";
import {
getHostRuntimeStore,
isHostRuntimeConnected,
getHostClient,
useHostRuntimeConnectionStatuses,
useHostRuntimeClient,
useHostRuntimeIsConnected,
} from "@/runtime/host-runtime";
@@ -80,6 +80,7 @@ export function useProjectIcons(input: {
[input.projects],
);
const supportsCustomIcons = useHostFeatureAvailabilityMap(serverIds, "projectCustomIcon");
const connectionStatuses = useHostRuntimeConnectionStatuses(serverIds);
const requests = useMemo(() => {
const unique = new Map<string, ProjectIconTarget>();
for (const project of input.projects) {
@@ -95,8 +96,8 @@ export function useProjectIcons(input: {
...projectIconCache.query(
request,
supportsCustomIcons.get(request.serverId) ?? null,
() => getHostRuntimeStore().getClient(request.serverId),
isHostRuntimeConnected(getHostRuntimeStore().getSnapshot(request.serverId)),
() => getHostClient(request.serverId),
connectionStatuses.get(request.serverId) === "online",
),
select: iconDataUri,
};
+2 -4
View File
@@ -1,6 +1,5 @@
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { useSessionStore } from "@/stores/session-store";
import { selectHostFeature } from "@/runtime/host-features";
import { hostSupports } from "@/runtime/session-data";
interface ProjectRemoveHost {
serverId: string;
@@ -55,10 +54,9 @@ export function getProjectRemoveReadiness(input: {
export function getCurrentProjectRemoveReadiness(
project: ProjectRemoveProject,
): ProjectRemoveReadiness {
const sessionState = useSessionStore.getState();
return getProjectRemoveReadiness({
project,
supportsProjectRemove: (serverId) => selectHostFeature(sessionState, serverId, "projectRemove"),
supportsProjectRemove: (serverId) => hostSupports(serverId, "projectRemove"),
});
}
@@ -2,7 +2,7 @@ import { useCallback, useMemo } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useSessionStore } from "@/stores/session-store";
import { useServerFeature } from "@/stores/session-store-hooks";
import { providerUsageCopy } from "./copy";
import type { ProviderUsageListPayload, ProviderUsageView } from "./types";
@@ -33,9 +33,7 @@ export function useProviderUsage(
const queryClient = useQueryClient();
const client = useHostRuntimeClient(serverId ?? "");
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
const supportsProviderUsage = useSessionStore(
(state) => state.sessions[serverId ?? ""]?.serverInfo?.features?.providerUsageList === true,
);
const supportsProviderUsage = useServerFeature(serverId, "providerUsageList");
const queryKey = useMemo(() => providerUsageQueryKey(serverId), [serverId]);
const canFetch = Boolean(serverId && client && isConnected && supportsProviderUsage);
const enabled = Boolean((options.enabled ?? true) && canFetch);
@@ -0,0 +1,85 @@
import type {
Agent,
AgentTimelineCursorState,
ProjectDescriptor,
WorkspaceDescriptor,
} from "@/stores/session-store";
import type { StreamItem } from "@/types/stream";
import { ReplicaCache } from "./internal/cache";
import { createReplicaRowStore } from "./internal/cache/row-store-factory";
export type DirectoryReplicaMutation =
| { kind: "agent"; type: "upsert"; id: string; value: Agent }
| { kind: "agent"; type: "delete"; id: string }
| { kind: "workspace"; type: "upsert"; id: string; value: WorkspaceDescriptor }
| { kind: "workspace"; type: "delete"; id: string }
| { kind: "project"; type: "upsert"; id: string; value: ProjectDescriptor }
| { kind: "project"; type: "delete"; id: string };
export interface CachedDirectory {
agents: Map<string, Agent>;
workspaces: Map<string, WorkspaceDescriptor>;
projects: Map<string, ProjectDescriptor>;
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<void>;
}
export interface ClientReplicaDirectory {
readAgent(serverId: string, agentId: string): Promise<Agent | undefined>;
readWorkspace(serverId: string, workspaceId: string): Promise<CachedWorkspace | undefined>;
readDirectory(serverId: string): Promise<CachedDirectory>;
commitDirectoryMutations(
serverId: string,
mutations: readonly DirectoryReplicaMutation[],
checkpoint?: DirectoryCheckpoint,
): void;
replaceDirectoryBaseline(serverId: string, directory: CachedDirectory): void;
}
export interface ClientReplicaTimeline {
readTimeline(serverId: string, agentId: string): Promise<CachedTimeline | undefined>;
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,
};
}
@@ -7,7 +7,8 @@ import {
} from "@/stores/session-store";
import type { StreamItem } from "@/types/stream";
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
import { ReplicaCache, type DirectoryCheckpoint } from ".";
import { ReplicaCache } from ".";
import type { DirectoryCheckpoint } from "@/runtime/client-replica";
import type { ReplicaHostRows, ReplicaRow, ReplicaRowChanges, ReplicaRowStore } from "./row-store";
const SERVER_ID = "cached-host";
@@ -200,6 +201,45 @@ function timeline(text = "Cached") {
};
}
function commitDirectory(
cache: ReplicaCache,
serverId: string,
value: ReturnType<typeof directory>,
): void {
cache.commitDirectoryMutations(
serverId,
[
...Array.from(value.agents.values(), (cachedAgent) => ({
kind: "agent" as const,
type: "upsert" as const,
id: cachedAgent.id,
value: cachedAgent,
})),
...Array.from(value.workspaces.values(), (workspace) => ({
kind: "workspace" as const,
type: "upsert" as const,
id: workspace.id,
value: workspace,
})),
...Array.from(value.projects.values(), (project) => ({
kind: "project" as const,
type: "upsert" as const,
id: project.projectId,
value: project,
})),
],
value.checkpoint,
);
}
function deleteDirectory(cache: ReplicaCache, serverId: string): void {
cache.commitDirectoryMutations(serverId, [
{ kind: "agent", type: "delete", id: "agent-1" },
{ kind: "workspace", type: "delete", id: "workspace-1" },
{ kind: "project", type: "delete", id: "project-1" },
]);
}
describe("ReplicaCache", () => {
it("does nothing until an owner explicitly commits data", async () => {
const storage = new MemoryStorage();
@@ -214,7 +254,7 @@ describe("ReplicaCache", () => {
it("round-trips explicit directory and timeline commits", async () => {
const storage = new MemoryStorage();
const writer = createCache(storage);
writer.commitDirectory(SERVER_ID, directory());
commitDirectory(writer, SERVER_ID, directory());
writer.commitTimeline(SERVER_ID, "agent-1", timeline());
await writer.flush();
@@ -229,17 +269,114 @@ describe("ReplicaCache", () => {
expect(restoredTimeline).toEqual(timeline());
});
it("serializes and writes only keyed directory mutations", async () => {
const storage = new MemoryStorage();
const cache = createCache(storage);
const changed = agent("changed-agent");
let titleReads = 0;
Object.defineProperty(changed, "title", {
configurable: true,
enumerable: true,
get: () => {
titleReads += 1;
return "Changed agent";
},
});
cache.commitDirectoryMutations(
SERVER_ID,
[{ kind: "agent", type: "upsert", id: changed.id, value: changed }],
{ agents: { generation: "g", afterSeq: 13 } },
);
expect(titleReads).toBe(0);
await cache.flush();
expect(titleReads).toBe(1);
expect(storage.changes).toHaveLength(1);
expect(storage.changes[0]?.upserts.map(({ kind, id }) => ({ kind, id }))).toEqual([
{ kind: "agent", id: "changed-agent" },
{ kind: "checkpoint", id: "singleton" },
]);
expect(storage.changes[0]?.deletes).toEqual([]);
});
it("round-trips identified and anonymous open turns without changing protocol status", async () => {
const storage = new MemoryStorage();
const writer = createCache(storage);
const identified = agent("identified");
identified.turn = {
phase: "open",
turnId: "turn-1",
startedAt: new Date("2026-08-31T12:00:00.000Z"),
cancellationRequestId: null,
};
const anonymous = agent("anonymous");
anonymous.turn = {
phase: "open",
turnId: null,
startedAt: null,
cancellationRequestId: null,
};
writer.commitDirectoryMutations(SERVER_ID, [
{ kind: "agent", type: "upsert", id: identified.id, value: identified },
{ kind: "agent", type: "upsert", id: anonymous.id, value: anonymous },
]);
await writer.flush();
const restored = await createCache(storage).readDirectory(SERVER_ID);
expect(restored.agents.get("identified")).toMatchObject({
status: "idle",
turn: {
phase: "open",
turnId: "turn-1",
startedAt: new Date("2026-08-31T12:00:00.000Z"),
cancellationRequestId: null,
},
});
expect(restored.agents.get("anonymous")).toMatchObject({
status: "idle",
turn: {
phase: "open",
turnId: null,
startedAt: null,
cancellationRequestId: null,
},
});
});
it("coalesces timeline values before serialization", async () => {
const storage = new MemoryStorage();
const cache = createCache(storage);
let textReads = 0;
const changedTimeline = timeline();
Object.defineProperty(changedTimeline.items[0], "text", {
configurable: true,
enumerable: true,
get: () => {
textReads += 1;
return "Latest";
},
});
cache.commitTimeline(SERVER_ID, "agent-1", timeline("Old"));
cache.commitTimeline(SERVER_ID, "agent-1", changedTimeline);
expect(textReads).toBe(0);
await cache.flush();
expect(textReads).toBe(1);
expect(storage.changes[0]?.upserts.map(({ kind, id }) => ({ kind, id }))).toEqual([
{ kind: "timeline", id: "agent-1" },
]);
});
it("never reads directory rows older than an accepted deferred deletion", async () => {
const storage = new MemoryStorage();
const cache = createCache(storage);
cache.commitDirectory(SERVER_ID, directory());
commitDirectory(cache, SERVER_ID, directory());
await cache.flush();
cache.commitDirectory(SERVER_ID, {
agents: new Map(),
workspaces: new Map(),
projects: new Map(),
});
deleteDirectory(cache, SERVER_ID);
expect(await cache.readAgent(SERVER_ID, "agent-1")).toBeUndefined();
expect(await cache.readWorkspace(SERVER_ID, "workspace-1")).toBeUndefined();
@@ -249,15 +386,11 @@ describe("ReplicaCache", () => {
it("fails closed when an accepted deletion cannot be persisted before a read", async () => {
const storage = new MemoryStorage();
const cache = createCache(storage);
cache.commitDirectory(SERVER_ID, directory());
commitDirectory(cache, SERVER_ID, directory());
await cache.flush();
storage.nextWriteFailure = new Error("disk busy");
cache.commitDirectory(SERVER_ID, {
agents: new Map(),
workspaces: new Map(),
projects: new Map(),
});
deleteDirectory(cache, SERVER_ID);
expect(await cache.readWorkspace(SERVER_ID, "workspace-1")).toBeUndefined();
});
@@ -265,7 +398,7 @@ describe("ReplicaCache", () => {
it("discards a durable read when the host changes while it is in flight", async () => {
const storage = new MemoryStorage();
const cache = createCache(storage);
cache.commitDirectory(SERVER_ID, directory());
commitDirectory(cache, SERVER_ID, directory());
await cache.flush();
const started = deferred();
const release = deferred();
@@ -274,11 +407,7 @@ describe("ReplicaCache", () => {
const reading = cache.readAgent(SERVER_ID, "agent-1");
await started.promise;
cache.commitDirectory(SERVER_ID, {
agents: new Map(),
workspaces: new Map(),
projects: new Map(),
});
deleteDirectory(cache, SERVER_ID);
release.resolve();
expect(await reading).toBeUndefined();
@@ -356,7 +485,7 @@ describe("ReplicaCache", () => {
it("reads one requested agent and the focused timeline without scanning directory rows", async () => {
const storage = new MemoryStorage();
const writer = createCache(storage);
writer.commitDirectory(SERVER_ID, directory());
commitDirectory(writer, SERVER_ID, directory());
writer.commitTimeline(SERVER_ID, "agent-1", timeline());
await writer.flush();
@@ -372,7 +501,7 @@ describe("ReplicaCache", () => {
it("reads one requested workspace and its project without scanning the directory", async () => {
const storage = new MemoryStorage();
const writer = createCache(storage);
writer.commitDirectory(SERVER_ID, directory());
commitDirectory(writer, SERVER_ID, directory());
await writer.flush();
const reader = createCache(storage);
@@ -422,9 +551,9 @@ describe("ReplicaCache", () => {
const storage = new MemoryStorage();
const writer = new ReplicaCache(storage, noLegacyCleanup);
writer.setHosts([SERVER_ID, otherServerId]);
writer.commitDirectory(SERVER_ID, directory());
commitDirectory(writer, SERVER_ID, directory());
writer.commitTimeline(SERVER_ID, "agent-1", timeline());
writer.commitDirectory(otherServerId, directory());
commitDirectory(writer, otherServerId, directory());
await writer.flush();
storage.rows.set(`${SERVER_ID}:agent:agent-1`, {
serverId: SERVER_ID,
@@ -438,7 +567,7 @@ describe("ReplicaCache", () => {
);
const cache = new ReplicaCache(storage, { ...noLegacyCleanup, maxBytes: initialBytes + 100 });
cache.setHosts([SERVER_ID, otherServerId]);
cache.commitDirectory(otherServerId, directory());
commitDirectory(cache, otherServerId, directory());
await cache.flush();
expect(await cache.readAgent(SERVER_ID, "agent-1")).toBeUndefined();
@@ -451,7 +580,8 @@ describe("ReplicaCache", () => {
it("atomically removes a targeted corrupt row and its matching persisted cursor", async () => {
const storage = new MemoryStorage();
const writer = createCache(storage);
writer.commitDirectory(
commitDirectory(
writer,
SERVER_ID,
directory({
agents: { generation: "g", afterSeq: 12 },
@@ -480,7 +610,8 @@ describe("ReplicaCache", () => {
it("drops only the cursor whose cached entity baseline is corrupt", async () => {
const storage = new MemoryStorage();
const writer = createCache(storage);
writer.commitDirectory(
commitDirectory(
writer,
SERVER_ID,
directory({
agents: { generation: "g", afterSeq: 12 },
@@ -514,7 +645,7 @@ describe("ReplicaCache", () => {
const storage = new MemoryStorage();
const cache = createCache(storage);
cache.commitDirectory(SERVER_ID, directory());
commitDirectory(cache, SERVER_ID, directory());
await cache.flush();
expect(storage.changes).toHaveLength(1);
@@ -548,7 +679,7 @@ describe("ReplicaCache", () => {
await cache.flush();
storage.onRead = () => {
storage.onRead = null;
cache.commitDirectory(SERVER_ID, directory());
commitDirectory(cache, SERVER_ID, directory());
};
expect((await cache.readTimeline(SERVER_ID, "agent-1"))?.items).toEqual([
@@ -561,13 +692,23 @@ describe("ReplicaCache", () => {
const cache = new ReplicaCache(storage, { ...noLegacyCleanup, maxBytes: 2_500 });
cache.setHosts([SERVER_ID, "other-host"]);
const cachedDirectory = directory();
cache.commitDirectory(SERVER_ID, cachedDirectory);
commitDirectory(cache, SERVER_ID, cachedDirectory);
await cache.flush();
cache.commitDirectory("other-host", cachedDirectory);
commitDirectory(cache, "other-host", cachedDirectory);
await cache.flush();
expect([...storage.rows.values()].some((row) => row.serverId === SERVER_ID)).toBe(false);
cache.commitDirectory(SERVER_ID, cachedDirectory);
cache.commitDirectoryMutations(
SERVER_ID,
[{ kind: "agent", type: "upsert", id: "agent-1", value: agent() }],
{ agents: { generation: "new-generation", afterSeq: 99 } },
);
await cache.flush();
expect([...storage.rows.values()].some((row) => row.serverId === SERVER_ID)).toBe(false);
expect((await cache.readDirectory(SERVER_ID)).checkpoint).toBeUndefined();
cache.replaceDirectoryBaseline(SERVER_ID, cachedDirectory);
await cache.flush();
expect(
@@ -588,7 +729,7 @@ describe("ReplicaCache", () => {
cache.setHosts([SERVER_ID]);
await cache.readAgent(SERVER_ID, "missing");
cache.commitDirectory(SERVER_ID, directory());
commitDirectory(cache, SERVER_ID, directory());
await cache.flush();
expect(storage.cleanups).toBe(1);
@@ -10,7 +10,6 @@ import {
normalizeProjectDescriptor,
normalizeWorkspaceDescriptor,
type Agent,
type AgentTimelineCursorState,
type ProjectDescriptor,
type WorkspaceDescriptor,
} from "@/stores/session-store";
@@ -19,6 +18,13 @@ import {
type AgentToolCallData,
type StreamItem,
} from "@/types/stream";
import type {
CachedDirectory,
CachedTimeline,
CachedWorkspace,
DirectoryCheckpoint,
DirectoryReplicaMutation,
} from "@/runtime/client-replica";
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
import { clearLegacyReplicaCache } from "./legacy-cleanup";
import {
@@ -30,7 +36,7 @@ import {
type ReplicaRowStore,
} from "./row-store";
const PERSIST_DELAY_MS = 5_000;
const PERSIST_DELAY_MS = 1_000;
const MAX_TIMELINE_ITEMS = 50;
const MAX_CACHE_BYTES = 32 * 1024 * 1024;
const IsoDateSchema = z.iso.datetime();
@@ -217,6 +223,16 @@ const StoredAgentSnapshotSchema = z.strictObject({
const StoredAgentSchema = z.strictObject({
snapshot: StoredAgentSnapshotSchema,
turn: z
.discriminatedUnion("phase", [
z.strictObject({ phase: z.literal("idle") }),
z.strictObject({
phase: z.literal("open"),
turnId: z.string().nullable(),
startedAt: IsoDateSchema.nullable(),
}),
])
.optional(),
projectPlacement: StoredProjectPlacementSchema.nullable(),
lastActivityAt: IsoDateSchema,
});
@@ -313,28 +329,12 @@ interface ReplicaCacheOptions {
clearLegacyCache?: () => Promise<void>;
}
export interface CachedDirectory {
agents: Map<string, Agent>;
workspaces: Map<string, WorkspaceDescriptor>;
projects: Map<string, ProjectDescriptor>;
checkpoint?: DirectoryCheckpoint;
}
export interface CachedWorkspace {
workspace: WorkspaceDescriptor;
project?: ProjectDescriptor;
}
export interface DirectoryCursor {
generation: string;
afterSeq: number;
}
export interface DirectoryCheckpoint {
projects?: DirectoryCursor;
workspaces?: DirectoryCursor;
agents?: DirectoryCursor;
}
type StructuredReplicaUpsert =
| { serverId: string; kind: "agent"; id: string; value: Agent }
| { serverId: string; kind: "workspace"; id: string; value: WorkspaceDescriptor }
| { serverId: string; kind: "project"; id: string; value: ProjectDescriptor }
| { serverId: string; kind: "timeline"; id: string; value: CachedTimeline }
| { serverId: string; kind: "checkpoint"; id: string; value: DirectoryCheckpoint };
const DirectoryCursorSchema = z.strictObject({
generation: z.string(),
@@ -347,13 +347,6 @@ const DirectoryCheckpointSchema = z.strictObject({
agents: DirectoryCursorSchema.optional(),
});
export interface CachedTimeline {
agentId: string;
items: StreamItem[];
range: AgentTimelineCursorState | null;
hasOlder: boolean;
}
function deserializeTimeline(stored: StoredTimeline | null): CachedTimeline | null {
if (!stored) {
return null;
@@ -560,6 +553,15 @@ function serializeProjectPlacement(agent: Agent): StoredAgent["projectPlacement"
return agent.projectPlacement ?? null;
}
function serializeAgentTurn(agent: Agent): NonNullable<StoredAgent["turn"]> {
if (agent.turn.phase === "idle") return { phase: "idle" };
return {
phase: "open",
turnId: agent.turn.turnId,
startedAt: agent.turn.startedAt?.toISOString() ?? null,
};
}
function serializeAgent(agent: Agent): StoredAgent {
const snapshot = {
id: agent.id,
@@ -572,11 +574,11 @@ function serializeAgent(agent: Agent): StoredAgent {
updatedAt: agent.updatedAt.toISOString(),
lastUserMessageAt: agent.lastUserMessageAt?.toISOString() ?? null,
status: agent.status,
...(agent.activeTurn?.turnId
...(agent.turn.phase === "open" && agent.turn.turnId
? {
activeTurn: {
turnId: agent.activeTurn.turnId,
startedAt: agent.activeTurn.startedAt?.toISOString() ?? null,
turnId: agent.turn.turnId,
startedAt: agent.turn.startedAt?.toISOString() ?? null,
},
}
: {}),
@@ -614,14 +616,27 @@ function serializeAgent(agent: Agent): StoredAgent {
};
return {
snapshot,
turn: serializeAgentTurn(agent),
projectPlacement: serializeProjectPlacement(agent),
lastActivityAt: agent.lastActivityAt.toISOString(),
};
}
function deserializeAgent(serverId: string, stored: StoredAgent): Agent {
const normalized = normalizeAgentSnapshot(stored.snapshot, serverId);
let turn = normalized.turn;
if (stored.turn?.phase === "idle") turn = { phase: "idle", cancellationRequestId: null };
if (stored.turn?.phase === "open") {
turn = {
phase: "open",
turnId: stored.turn.turnId,
startedAt: stored.turn.startedAt ? new Date(stored.turn.startedAt) : null,
cancellationRequestId: null,
};
}
return {
...normalizeAgentSnapshot(stored.snapshot, serverId),
...normalized,
turn,
lastActivityAt: new Date(stored.lastActivityAt),
projectPlacement: stored.projectPlacement,
};
@@ -773,8 +788,10 @@ interface DirectoryReadAccumulator {
checkpoint?: DirectoryCheckpoint;
}
interface PendingReplicaChanges extends ReplicaRowChanges {
directoryReplacements: Map<string, Set<string>>;
interface PendingReplicaChanges {
upserts: StructuredReplicaUpsert[];
deletes: ReplicaRowKey[];
baselines: Set<string>;
}
function applyDirectoryRow(
@@ -825,9 +842,10 @@ export class ReplicaCache {
private readonly storedRows = new Map<string, Map<string, ReplicaRow>>();
private readonly hostBytes = new Map<string, number>();
private readonly hostWriteOrder = new Map<string, true>();
private pendingUpserts = new Map<string, ReplicaRow>();
private pendingUpserts = new Map<string, StructuredReplicaUpsert>();
private pendingDeletes = new Map<string, ReplicaRowKey>();
private pendingDirectoryReplacements = new Map<string, Set<string>>();
private pendingBaselines = new Set<string>();
private readonly invalidatedHosts = new Set<string>();
private readonly maxBytes: number;
private totalBytes = 0;
private persistTimer: ReturnType<typeof setTimeout> | null = null;
@@ -1019,39 +1037,59 @@ export class ReplicaCache {
});
}
commitDirectory(
commitDirectoryMutations(
serverId: string,
directory: {
agents: Map<string, Agent>;
workspaces: Map<string, WorkspaceDescriptor>;
projects: Map<string, ProjectDescriptor>;
checkpoint?: DirectoryCheckpoint;
},
mutations: readonly DirectoryReplicaMutation[],
checkpoint?: DirectoryCheckpoint,
): void {
if (!this.activeServerIds.has(serverId)) return;
if (this.invalidatedHosts.has(serverId)) return;
this.advanceHostRevision(serverId);
this.clearPendingDirectoryChanges(serverId);
const desiredRows: ReplicaRow[] = [];
for (const agent of directory.agents.values()) {
desiredRows.push(this.entityRow(serverId, "agent", agent.id, serializeAgent(agent)));
for (const mutation of mutations) {
if (mutation.type === "delete") {
this.queueEntityDelete(serverId, mutation.kind, mutation.id);
} else {
this.queueUpsert({
serverId,
kind: mutation.kind,
id: mutation.id,
value: mutation.value,
} as StructuredReplicaUpsert);
}
}
for (const workspace of directory.workspaces.values()) {
desiredRows.push(
this.entityRow(serverId, "workspace", workspace.id, serializeWorkspace(workspace)),
);
if (checkpoint) {
this.queueUpsert({
serverId,
kind: "checkpoint",
id: REPLICA_SINGLETON_ROW_ID,
value: checkpoint,
});
}
for (const project of directory.projects.values()) {
desiredRows.push(
this.entityRow(serverId, "project", project.projectId, serializeProject(project)),
);
this.schedulePersist();
}
replaceDirectoryBaseline(serverId: string, directory: CachedDirectory): void {
if (!this.activeServerIds.has(serverId)) return;
this.advanceHostRevision(serverId);
this.dropPendingHostChanges(serverId);
this.pendingBaselines.add(serverId);
for (const [id, value] of directory.agents) {
this.queueUpsert({ serverId, kind: "agent", id, value });
}
for (const [id, value] of directory.workspaces) {
this.queueUpsert({ serverId, kind: "workspace", id, value });
}
for (const [id, value] of directory.projects) {
this.queueUpsert({ serverId, kind: "project", id, value });
}
if (directory.checkpoint) {
desiredRows.push(
this.entityRow(serverId, "checkpoint", REPLICA_SINGLETON_ROW_ID, directory.checkpoint),
);
this.queueUpsert({
serverId,
kind: "checkpoint",
id: REPLICA_SINGLETON_ROW_ID,
value: directory.checkpoint,
});
}
for (const row of desiredRows) this.queueUpsert(row);
this.pendingDirectoryReplacements.set(serverId, new Set(desiredRows.map((row) => rowKey(row))));
this.schedulePersist();
}
@@ -1059,9 +1097,7 @@ export class ReplicaCache {
if (!this.activeServerIds.has(serverId)) return;
if (timeline.agentId !== agentId) throw new Error("Timeline cache key does not match payload");
this.advanceHostRevision(serverId);
const stored = serializeTimeline(timeline);
if (stored) this.queueEntityUpsert(serverId, "timeline", agentId, stored);
else this.queueEntityDelete(serverId, "timeline", agentId);
this.queueUpsert({ serverId, kind: "timeline", id: agentId, value: timeline });
this.schedulePersist();
}
@@ -1075,6 +1111,7 @@ export class ReplicaCache {
for (const serverId of removed) {
this.removeStoredHost(serverId);
this.dropPendingHostChanges(serverId);
this.invalidatedHosts.delete(serverId);
this.queueOperation(() => this.rowStore.deleteHost(serverId));
}
}
@@ -1124,12 +1161,15 @@ export class ReplicaCache {
try {
await this.prepareStore();
await this.ensureStoredIndex();
const changes = this.materializeDirectoryReplacements(pending);
const boundedChanges = await this.fitChangesToBudget(changes);
const changes = this.materializePendingChanges(pending);
const { changes: boundedChanges, evicted } = await this.fitChangesToBudget(changes);
if (boundedChanges.upserts.length > 0 || boundedChanges.deletes.length > 0) {
await this.rowStore.apply(boundedChanges);
this.applyStoredChanges(boundedChanges);
}
for (const serverId of pending.baselines) {
if (!evicted.has(serverId)) this.invalidatedHosts.delete(serverId);
}
} catch {
this.restorePendingChanges(pending);
if (this.hasPendingChanges()) this.schedulePersist();
@@ -1140,39 +1180,11 @@ export class ReplicaCache {
await write;
}
private queueEntityUpsert(
serverId: string,
kind: ReplicaRowKind,
id: string,
value: unknown,
): void {
this.queueUpsert(this.entityRow(serverId, kind, id, value));
}
private entityRow(
serverId: string,
kind: ReplicaRowKind,
id: string,
value: unknown,
): ReplicaRow {
return { serverId, kind, id, payload: JSON.stringify(value) };
}
private clearPendingDirectoryChanges(serverId: string): void {
const isDirectoryRow = (row: ReplicaRowKey) => row.kind !== "timeline";
for (const [key, row] of this.pendingUpserts) {
if (row.serverId === serverId && isDirectoryRow(row)) this.pendingUpserts.delete(key);
}
for (const [key, row] of this.pendingDeletes) {
if (row.serverId === serverId && isDirectoryRow(row)) this.pendingDeletes.delete(key);
}
}
private queueEntityDelete(serverId: string, kind: ReplicaRowKind, id: string): void {
this.queueDelete({ serverId, kind, id });
}
private queueUpsert(row: ReplicaRow): void {
private queueUpsert(row: StructuredReplicaUpsert): void {
const key = pendingRowKey(row);
this.pendingDeletes.delete(key);
this.pendingUpserts.set(key, row);
@@ -1186,9 +1198,7 @@ export class ReplicaCache {
private hasPendingChanges(): boolean {
return (
this.pendingUpserts.size > 0 ||
this.pendingDeletes.size > 0 ||
this.pendingDirectoryReplacements.size > 0
this.pendingUpserts.size > 0 || this.pendingDeletes.size > 0 || this.pendingBaselines.size > 0
);
}
@@ -1201,7 +1211,7 @@ export class ReplicaCache {
}
private hasPendingHostChanges(serverId: string): boolean {
if (this.pendingDirectoryReplacements.has(serverId)) return true;
if (this.pendingBaselines.has(serverId)) return true;
for (const row of this.pendingUpserts.values()) {
if (row.serverId === serverId) return true;
}
@@ -1219,31 +1229,66 @@ export class ReplicaCache {
const changes = {
upserts: [...this.pendingUpserts.values()],
deletes: [...this.pendingDeletes.values()],
directoryReplacements: this.pendingDirectoryReplacements,
baselines: this.pendingBaselines,
};
this.pendingUpserts = new Map();
this.pendingDeletes = new Map();
this.pendingDirectoryReplacements = new Map();
this.pendingBaselines = new Set();
return changes;
}
private materializeDirectoryReplacements(pending: PendingReplicaChanges): ReplicaRowChanges {
private materializePendingChanges(pending: PendingReplicaChanges): ReplicaRowChanges {
const deletes = new Map(pending.deletes.map((row) => [pendingRowKey(row), row]));
for (const [serverId, desiredRows] of pending.directoryReplacements) {
const upserts: ReplicaRow[] = [];
for (const upsert of pending.upserts) {
const row = this.materializeUpsert(upsert);
if (row) upserts.push(row);
else deletes.set(pendingRowKey(upsert), upsert);
}
for (const serverId of pending.baselines) {
const replacementKeys = new Set(
upserts.filter((row) => row.serverId === serverId && row.kind !== "timeline").map(rowKey),
);
for (const row of this.storedRows.get(serverId)?.values() ?? []) {
if (row.kind !== "timeline" && !desiredRows.has(rowKey(row))) {
if (row.kind !== "timeline" && !replacementKeys.has(rowKey(row))) {
deletes.set(pendingRowKey(row), row);
}
}
}
return { upserts: pending.upserts, deletes: [...deletes.values()] };
return { upserts, deletes: [...deletes.values()] };
}
private materializeUpsert(upsert: StructuredReplicaUpsert): ReplicaRow | null {
let value: unknown;
switch (upsert.kind) {
case "agent":
value = serializeAgent(upsert.value);
break;
case "workspace":
value = serializeWorkspace(upsert.value);
break;
case "project":
value = serializeProject(upsert.value);
break;
case "timeline":
value = serializeTimeline(upsert.value);
if (!value) return null;
break;
case "checkpoint":
value = upsert.value;
break;
}
return {
serverId: upsert.serverId,
kind: upsert.kind,
id: upsert.id,
payload: JSON.stringify(value),
};
}
private restorePendingChanges(changes: PendingReplicaChanges): void {
for (const [serverId, desiredRows] of changes.directoryReplacements) {
if (this.activeServerIds.has(serverId) && !this.pendingDirectoryReplacements.has(serverId)) {
this.pendingDirectoryReplacements.set(serverId, desiredRows);
}
for (const serverId of changes.baselines) {
if (this.activeServerIds.has(serverId)) this.pendingBaselines.add(serverId);
}
for (const key of changes.deletes) {
const pendingKey = pendingRowKey(key);
@@ -1306,7 +1351,9 @@ export class ReplicaCache {
this.hostWriteOrder.set(serverId, true);
}
private async fitChangesToBudget(changes: ReplicaRowChanges): Promise<ReplicaRowChanges> {
private async fitChangesToBudget(
changes: ReplicaRowChanges,
): Promise<{ changes: ReplicaRowChanges; evicted: Set<string> }> {
const touchedServerIds = new Set<string>();
for (const key of changes.deletes) touchedServerIds.add(key.serverId);
for (const row of changes.upserts) touchedServerIds.add(row.serverId);
@@ -1342,15 +1389,17 @@ export class ReplicaCache {
projectedTotal -= bytes;
projectedBytes.delete(serverId);
evicted.add(serverId);
this.invalidatedHosts.add(serverId);
if (this.storedRows.has(serverId)) await this.rowStore.deleteHost(serverId);
this.removeStoredHost(serverId);
// The next accepted owner commit must rebuild the complete host replica. Retaining the
// directory replacement marker ensures its checkpoint and rows stay atomic.
}
return {
upserts: changes.upserts.filter((row) => !evicted.has(row.serverId)),
deletes: changes.deletes.filter((key) => !evicted.has(key.serverId)),
changes: {
upserts: changes.upserts.filter((row) => !evicted.has(row.serverId)),
deletes: changes.deletes.filter((key) => !evicted.has(key.serverId)),
},
evicted,
};
}
@@ -1362,7 +1411,7 @@ export class ReplicaCache {
}
private dropPendingHostChanges(serverId: string): void {
this.pendingDirectoryReplacements.delete(serverId);
this.pendingBaselines.delete(serverId);
for (const [key, row] of this.pendingUpserts) {
if (row.serverId === serverId) this.pendingUpserts.delete(key);
}
@@ -1373,18 +1422,16 @@ export class ReplicaCache {
private renamePendingHostChanges(oldServerId: string, newServerId: string): void {
const changes = this.drainPendingChanges();
for (const [serverId, desiredRows] of changes.directoryReplacements) {
this.pendingDirectoryReplacements.set(
serverId === oldServerId ? newServerId : serverId,
desiredRows,
);
}
for (const key of changes.deletes) {
this.queueDelete(key.serverId === oldServerId ? { ...key, serverId: newServerId } : key);
}
for (const row of changes.upserts) {
this.queueUpsert(row.serverId === oldServerId ? { ...row, serverId: newServerId } : row);
}
for (const serverId of changes.baselines) {
this.pendingBaselines.add(serverId === oldServerId ? newServerId : serverId);
}
if (this.invalidatedHosts.delete(oldServerId)) this.invalidatedHosts.add(newServerId);
}
private prepareStore(): Promise<void> {

Some files were not shown because too many files have changed in this diff Show More