mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-06 08:24:46 +00:00
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:
@@ -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
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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"]>;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.");
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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 ?? "";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
+174
-33
@@ -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);
|
||||
+170
-123
@@ -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
Reference in New Issue
Block a user