mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-06 08:24:46 +00:00
fix(app): preserve pending chat cache writes and trim resume refactor
Directory baseline replacement must preserve independently accepted timeline changes until the deferred flush. Keep the reconnect and persistence fixes while removing the session-data facade and broad read-hook migration.
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/client-replica` owns typed storage behind narrow host-lifecycle, directory, and timeline capabilities. Its cache internals never escape the module or observe `SessionStore`.
|
||||
- `runtime/replica-cache` is typed storage behind the directory and timeline owners. It never observes or mutates `SessionStore`.
|
||||
- `runtime/directory-sync` owns directory cache selection and network reconciliation. On demand it paints accepted rows for one host, then passes the persisted per-entity cursor through `project.list`, `fetch_workspaces`, and `fetch_agents`; the daemon returns each entity's latest projection when its sequence is newer, plus tombstones.
|
||||
- `workspace-labels` owns one sequenced catalog replica per connected host, the deterministic cross-host projection that surfaces spanning hosts use (the filter page, the manager), and the per-host resolution a workspace row's chips use. Two hosts may give one name different colors, so a row resolves against its own host's catalog and a merged answer would be wrong there. Catalogs never synchronize between hosts; assignment creates a missing definition only on the target host. On the daemon, catalog and assignment rewrites share a journaled commit boundary. Startup recovery completes that commit before workspace or catalog publication.
|
||||
- `SessionContext` wraps the daemon client for the active session
|
||||
|
||||
@@ -152,7 +152,7 @@ remaining page through the existing stream reducer. It must not append full proj
|
||||
live prefix.
|
||||
|
||||
Every path that sends a message to an agent — composer send, dictation accept-and-send, queued
|
||||
send-now, and the session-data owner's automatic queue drain — goes through
|
||||
send-now, and the host runtime's automatic queue drain — goes through
|
||||
`dispatchComposerAgentMessage` with a submission writer. There is no second transport for the same
|
||||
product action: calling `client.sendAgentMessage` directly skips the submitted row and the pending
|
||||
footer, and permanently drops attachments because the daemon does not echo them back.
|
||||
|
||||
@@ -551,8 +551,8 @@ async function expectProviderAcknowledgementBeforeRpcAcceptanceSettlesSubmission
|
||||
await gate.waitForAgentStreamItem("user_message");
|
||||
gate.releaseHeldServerMessage();
|
||||
await gate.drop();
|
||||
await expect(page.getByTestId("turn-working-indicator")).toHaveCount(0);
|
||||
await expect(userMessage).toHaveAttribute("aria-busy", "false");
|
||||
await expect(page.getByTestId("turn-working-indicator")).toBeVisible();
|
||||
} finally {
|
||||
gate.restore();
|
||||
await agent.cleanup();
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { mergeCreateAgentSelectionPreferences } from "@/create-agent-preferences/preferences";
|
||||
import { useFormPreferences } from "@/hooks/use-form-preferences";
|
||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
import { useHostRuntimeClient } from "@/runtime/host-runtime";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { toErrorMessage } from "@/utils/error-messages";
|
||||
import { showProviderNoticeToast } from "@/utils/provider-notice-toast";
|
||||
@@ -73,7 +73,7 @@ export function useAgentProfilePicker(
|
||||
// so every composer on a host shares one query instead of adding its own.
|
||||
const { entries } = useProvidersSnapshot(serverId, { cwd: null });
|
||||
const { updatePreferences } = useFormPreferences();
|
||||
const client = useHostRuntimeClient(serverId ?? "");
|
||||
const client = useSessionStore((state) => state.sessions[serverId ?? ""]?.client ?? null);
|
||||
const toast = useToast();
|
||||
|
||||
const applicableProfiles = useMemo(() => {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useCallback } from "react";
|
||||
import type { AgentProfile } from "@getpaseo/protocol/messages";
|
||||
import { useDaemonConfig } from "@/hooks/use-daemon-config";
|
||||
import { useServerFeature } from "@/stores/session-store-hooks";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { supportsAgentProfiles } from "./capabilities";
|
||||
|
||||
export interface UseAgentProfilesResult {
|
||||
/** `null` until the daemon config has arrived. */
|
||||
@@ -14,9 +15,9 @@ export interface UseAgentProfilesResult {
|
||||
|
||||
export function useAgentProfiles(serverId: string | null): UseAgentProfilesResult {
|
||||
const { config, patchConfig } = useDaemonConfig(serverId);
|
||||
const supportsProfiles = useServerFeature(serverId, "agentProfiles");
|
||||
const supportsApply = useServerFeature(serverId, "agentConfigApply");
|
||||
const isSupported = supportsProfiles && supportsApply;
|
||||
const isSupported = useSessionStore((state) => {
|
||||
return supportsAgentProfiles(state.sessions[serverId ?? ""]?.serverInfo?.features);
|
||||
});
|
||||
|
||||
const saveProfiles = useCallback(
|
||||
async (next: AgentProfile[]) => {
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import type { AgentSkillSelection } from "@getpaseo/protocol/messages";
|
||||
import { getHostClient } from "@/runtime/host-runtime";
|
||||
|
||||
export async function importLegacyAgentSkillsSelection(
|
||||
serverId: string,
|
||||
selection: AgentSkillSelection,
|
||||
): Promise<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 { useConnectedDesktopManagedHostIds } from "@/stores/session-store-hooks";
|
||||
import { importLegacyAgentSkillsSelection } from "./legacy-migration-service";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
|
||||
interface LegacySelectionClient {
|
||||
importLegacyAgentSkillsSelection(selection: AgentSkillSelection): Promise<unknown>;
|
||||
@@ -26,16 +26,21 @@ export async function migrateLegacyAgentSkillsSelection(
|
||||
|
||||
// COMPAT(desktopSkillSelectionMigration): added in v0.4.0; remove after 2027-02-16.
|
||||
export function LegacyAgentSkillsMigration() {
|
||||
const connectedManagedHosts = useConnectedDesktopManagedHostIds().join(",");
|
||||
const connectedManagedHosts = useSessionStore((state) =>
|
||||
Object.entries(state.sessions)
|
||||
.filter(([, session]) => session.serverInfo?.desktopManaged === true)
|
||||
.map(([serverId]) => serverId)
|
||||
.sort()
|
||||
.join(","),
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!shouldUseDesktopDaemon() || !connectedManagedHosts) return;
|
||||
async function migrate(): Promise<void> {
|
||||
const status = await getDesktopDaemonStatus();
|
||||
if (status.status !== "running" || !status.desktopManaged) return;
|
||||
const selection = await readLegacySkillSelection();
|
||||
if (!selection) return;
|
||||
const imported = await importLegacyAgentSkillsSelection(status.serverId, selection);
|
||||
if (imported) await deleteLegacySkillSelection();
|
||||
const client = getHostRuntimeStore().getSnapshot(status.serverId)?.client;
|
||||
if (!client) return;
|
||||
await migrateLegacyAgentSkillsSelection(client);
|
||||
}
|
||||
void migrate().catch((error) => {
|
||||
console.error("[Agent skills] Legacy selection migration failed; will retry", error);
|
||||
|
||||
@@ -24,8 +24,6 @@ const runtime = vi.hoisted(() => ({
|
||||
|
||||
vi.mock("@/constants/platform", () => ({ isWeb: true }));
|
||||
vi.mock("@/runtime/host-runtime", () => ({
|
||||
fetchAgentTimeline: runtime.fetchAgentTimeline,
|
||||
getHostClient: () => runtime,
|
||||
getHostRuntimeStore: () => ({
|
||||
getClient: () => runtime,
|
||||
fetchAgentTimeline: runtime.fetchAgentTimeline,
|
||||
|
||||
@@ -2,11 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } fro
|
||||
import type { AgentTimelinePromptIndexPayload } from "@getpaseo/client/internal/daemon-client";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import {
|
||||
listAgentTimelinePrompts,
|
||||
loadAgentTimelineWindow,
|
||||
subscribeToAgentTimelinePrompts,
|
||||
} from "@/timeline/agent-timeline-service";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { planTimelinePromptJump } from "@/timeline/timeline-sync-plan";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import type { StreamViewportHandle } from "../strategy";
|
||||
@@ -75,13 +71,15 @@ export function useChatOutline({
|
||||
return;
|
||||
}
|
||||
setIndex(null);
|
||||
const client = getHostRuntimeStore().getClient(serverId);
|
||||
if (!client) return;
|
||||
let active = true;
|
||||
const refresh = () => {
|
||||
const requestId = ++nextIndexRequestIdRef.current;
|
||||
void listAgentTimelinePrompts(serverId, agentId)
|
||||
void client
|
||||
.listAgentTimelinePrompts(agentId)
|
||||
.then((payload) => {
|
||||
if (
|
||||
payload &&
|
||||
active &&
|
||||
requestId === nextIndexRequestIdRef.current &&
|
||||
shouldAcceptPromptIndexEpoch(timelineEpoch, payload.epoch)
|
||||
@@ -93,7 +91,16 @@ export function useChatOutline({
|
||||
.catch(() => undefined);
|
||||
};
|
||||
refresh();
|
||||
const unsubscribe = subscribeToAgentTimelinePrompts(serverId, agentId, refresh);
|
||||
const unsubscribe = client.on("agent_stream", (message) => {
|
||||
if (
|
||||
message.type === "agent_stream" &&
|
||||
message.payload.agentId === agentId &&
|
||||
message.payload.event.type === "timeline" &&
|
||||
message.payload.event.item.type === "user_message"
|
||||
) {
|
||||
refresh();
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
unsubscribe();
|
||||
@@ -165,11 +172,8 @@ export function useChatOutline({
|
||||
if (!index) return;
|
||||
const requestId = nextJumpRequestIdRef.current;
|
||||
setPendingJump({ requestId, seq, fetchSettled: false, hasScrolled: false });
|
||||
void loadAgentTimelineWindow(
|
||||
serverId,
|
||||
agentId,
|
||||
planTimelinePromptJump({ epoch: index.epoch, seq }),
|
||||
)
|
||||
void getHostRuntimeStore()
|
||||
.fetchAgentTimeline(serverId, agentId, planTimelinePromptJump({ epoch: index.epoch, seq }))
|
||||
.catch((error: unknown) => {
|
||||
console.warn("Failed to load a Chat outline window", error);
|
||||
onJumpError();
|
||||
|
||||
@@ -50,8 +50,7 @@ import type {
|
||||
AgentPermissionResponse,
|
||||
} from "@getpaseo/protocol/agent-types";
|
||||
import type { AgentScreenAgent } from "@/hooks/use-agent-screen-state-machine";
|
||||
import { useHostRuntimeClient } from "@/runtime/host-runtime";
|
||||
import { useAgentStreamView, useServerFeature } from "@/stores/session-store-hooks";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useRevealedText } from "@/hooks/use-revealed-text";
|
||||
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
|
||||
import { useLoadOlderAgentHistory } from "@/hooks/use-load-older-agent-history";
|
||||
@@ -72,7 +71,7 @@ import { resolveStreamRenderStrategy } from "./strategy-resolver";
|
||||
import { type StreamSegmentRenderers, type StreamViewportHandle } from "./strategy";
|
||||
import { ChatOutlineRail } from "@/agent-stream/chat-outline/rail";
|
||||
import { useChatOutline } from "@/agent-stream/chat-outline/use-chat-outline";
|
||||
import { loadAgentTimelineWindow } from "@/timeline/agent-timeline-service";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { planTimelineTailFetch } from "@/timeline/timeline-sync-plan";
|
||||
import {
|
||||
CompletedTurnFooterRow,
|
||||
@@ -379,18 +378,26 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
const resolvedServerId = serverId ?? context.serverId ?? "";
|
||||
const transformTimelineItem = useInstalledTimelineTransform(resolvedServerId);
|
||||
|
||||
const client = useHostRuntimeClient(resolvedServerId);
|
||||
const sessionStream = useAgentStreamView(resolvedServerId, agentId);
|
||||
const sessionStreamHead = sessionStream.head;
|
||||
const client = useSessionStore((state) => state.sessions[resolvedServerId]?.client ?? null);
|
||||
const sessionStreamHead = useSessionStore((state) =>
|
||||
state.sessions[resolvedServerId]?.agentStreamHead?.get(agentId),
|
||||
);
|
||||
const streamHead = providedStreamHead ?? sessionStreamHead;
|
||||
const forkAgent = useForkAgent({ serverId: resolvedServerId, toast, readOnly });
|
||||
const supportsAgentForkContextCursor = useServerFeature(
|
||||
resolvedServerId,
|
||||
"agentForkContextCursor",
|
||||
const supportsAgentForkContextCursor = useSessionStore(
|
||||
(state) =>
|
||||
state.sessions[resolvedServerId]?.serverInfo?.features?.agentForkContextCursor === true,
|
||||
);
|
||||
const supportsChatOutline = useSessionStore(
|
||||
(state) =>
|
||||
state.sessions[resolvedServerId]?.serverInfo?.features?.agentTimelinePromptIndex === true,
|
||||
);
|
||||
const timelineEpoch = useSessionStore(
|
||||
(state) => state.sessions[resolvedServerId]?.agentTimelineCursor.get(agentId)?.epoch ?? null,
|
||||
);
|
||||
const isTimelineDetached = useSessionStore(
|
||||
(state) => state.sessions[resolvedServerId]?.agentTimelineHasNewer.get(agentId) === true,
|
||||
);
|
||||
const supportsChatOutline = useServerFeature(resolvedServerId, "agentTimelinePromptIndex");
|
||||
const timelineEpoch = sessionStream.timelineEpoch;
|
||||
const isTimelineDetached = sessionStream.hasNewerTimeline;
|
||||
|
||||
const workspaceRoot = context.cwd?.trim() || "";
|
||||
const { requestDirectoryListing } = useFileExplorerActions({
|
||||
@@ -651,7 +658,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
}
|
||||
void returnToTimelineTail({
|
||||
fetchTail: () =>
|
||||
loadAgentTimelineWindow(resolvedServerId, agentId, {
|
||||
getHostRuntimeStore().fetchAgentTimeline(resolvedServerId, agentId, {
|
||||
...planTimelineTailFetch(),
|
||||
}),
|
||||
scrollToBottom: () => viewportRef.current?.scrollToBottom("jump-to-bottom"),
|
||||
|
||||
@@ -68,7 +68,7 @@ import {
|
||||
resolveStartupBlocker,
|
||||
resolveStartupNavigationReady,
|
||||
shouldRunStartupGiveUpTimer,
|
||||
startDefaultHostRuntimeBootstrap,
|
||||
startHostRuntimeBootstrap,
|
||||
type StartupBlocker,
|
||||
} from "@/navigation/host-runtime-bootstrap";
|
||||
import { registerWorkspaceRouteNavigationRef } from "@/navigation/workspace-route-navigation";
|
||||
@@ -105,20 +105,19 @@ import { polyfillCrypto } from "@/polyfills/crypto";
|
||||
import { polyfillNavigator } from "@/polyfills/navigator";
|
||||
import { queryClient } from "@/data/query-client";
|
||||
import {
|
||||
getHostRuntimeStore,
|
||||
hasConfiguredLocalDaemonOverride,
|
||||
setAppVisible,
|
||||
useEarliestOnlineHostServerId as useRuntimeEarliestOnlineHostServerId,
|
||||
useHostRegistryLoaded,
|
||||
useHostMutations,
|
||||
useHostRuntimeClient,
|
||||
useHostRuntimeIsConnected,
|
||||
useHosts,
|
||||
} from "@/runtime/host-runtime";
|
||||
import { getManagedDaemonStartService } from "@/runtime/daemon-start-service";
|
||||
import { getDaemonStartService } from "@/runtime/daemon-start-service";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { flushDraftPersistStorage } from "@/stores/draft-store";
|
||||
import { getNextThemePreference } from "@/styles/theme";
|
||||
import { useConnection } from "@/stores/session-store-hooks";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { installWebScrollbarStyles } from "@/styles/install-web-scrollbar-styles";
|
||||
import type { HostProfile } from "@/types/host-connection";
|
||||
import {
|
||||
@@ -290,7 +289,7 @@ function LegacyFavoriteProfileMigrationBootstrap({
|
||||
serverId: string;
|
||||
client: NonNullable<ReturnType<typeof useHostRuntimeClient>>;
|
||||
}) {
|
||||
const serverInfo = useConnection(serverId, (connection) => connection.serverInfo);
|
||||
const serverInfo = useSessionStore((state) => state.sessions[serverId]?.serverInfo ?? null);
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -322,11 +321,27 @@ function HostSessionManager() {
|
||||
}
|
||||
|
||||
export function useEarliestOnlineHostServerId(): string | null {
|
||||
return useRuntimeEarliestOnlineHostServerId();
|
||||
const store = getHostRuntimeStore();
|
||||
const subscribe = useCallback(
|
||||
(listener: () => void) => {
|
||||
const unsubscribeAll = store.subscribeAll(listener);
|
||||
const unsubscribeHostList = store.subscribeHostList(listener);
|
||||
return () => {
|
||||
unsubscribeAll();
|
||||
unsubscribeHostList();
|
||||
};
|
||||
},
|
||||
[store],
|
||||
);
|
||||
return useSyncExternalStore(
|
||||
subscribe,
|
||||
() => store.getEarliestOnlineHostServerId(),
|
||||
() => store.getEarliestOnlineHostServerId(),
|
||||
);
|
||||
}
|
||||
|
||||
function useDaemonStartLastError(): string | null {
|
||||
const service = getManagedDaemonStartService();
|
||||
const service = getDaemonStartService({ store: getHostRuntimeStore() });
|
||||
return useSyncExternalStore(
|
||||
(listener) => service.subscribe(listener),
|
||||
() => service.getLastError(),
|
||||
@@ -335,7 +350,7 @@ function useDaemonStartLastError(): string | null {
|
||||
}
|
||||
|
||||
function useDaemonStartIsRunning(): boolean {
|
||||
const service = getManagedDaemonStartService();
|
||||
const service = getDaemonStartService({ store: getHostRuntimeStore() });
|
||||
return useSyncExternalStore(
|
||||
(listener) => service.subscribe(listener),
|
||||
() => service.isRunning(),
|
||||
@@ -357,19 +372,23 @@ async function shouldStartBuiltInDaemon(): Promise<boolean> {
|
||||
}
|
||||
|
||||
function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
const routeServerId = useMemo(() => parseServerIdFromPathname(pathname), [pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
startDefaultHostRuntimeBootstrap(shouldStartBuiltInDaemon);
|
||||
const store = getHostRuntimeStore();
|
||||
const subscription = AppState.addEventListener("change", (nextState) => {
|
||||
store.setAppVisible(nextState === "active");
|
||||
});
|
||||
return () => subscription.remove();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = AppState.addEventListener("change", (nextState) => {
|
||||
setAppVisible(nextState === "active", routeServerId);
|
||||
const store = getHostRuntimeStore();
|
||||
const daemonStartService = getDaemonStartService({ store });
|
||||
startHostRuntimeBootstrap({
|
||||
store,
|
||||
daemonStartService,
|
||||
shouldStartDaemon: shouldStartBuiltInDaemon,
|
||||
});
|
||||
return () => subscription.remove();
|
||||
}, [routeServerId]);
|
||||
}, []);
|
||||
|
||||
const anyOnlineHostServerId = useEarliestOnlineHostServerId();
|
||||
const daemonStartError = useDaemonStartLastError();
|
||||
@@ -405,7 +424,7 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
|
||||
}, [shouldRunGiveUpTimer]);
|
||||
|
||||
const retry = useCallback(() => {
|
||||
const daemonStartService = getManagedDaemonStartService();
|
||||
const daemonStartService = getDaemonStartService({ store: getHostRuntimeStore() });
|
||||
void daemonStartService.startIfEnabled({ shouldStart: shouldStartBuiltInDaemon });
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -4,13 +4,8 @@ import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-bo
|
||||
import { useFetchQuery } from "@/data/query";
|
||||
import { resolveAgentRoute, type AgentRouteLookup } from "@/navigation/agent-route-resolution";
|
||||
import { AgentRouteResolutionView } from "@/navigation/agent-route-resolution-view";
|
||||
import { useAgentWorkspaceId } from "@/stores/session-store-hooks";
|
||||
import {
|
||||
prepareAgentRoute,
|
||||
probeHostConnections,
|
||||
useHostRuntimeSnapshot,
|
||||
useHosts,
|
||||
} from "@/runtime/host-runtime";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { getHostRuntimeStore, useHostRuntimeSnapshot, useHosts } from "@/runtime/host-runtime";
|
||||
import { buildHostRootRoute, buildSettingsHostRoute } from "@/utils/host-routes";
|
||||
import { toErrorMessage } from "@/utils/error-messages";
|
||||
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||
@@ -37,10 +32,17 @@ function HostAgentReadyRouteContent() {
|
||||
const client = runtimeSnapshot?.client ?? null;
|
||||
const connectionStatus = runtimeSnapshot?.connectionStatus ?? "connecting";
|
||||
const hostName = hosts.find((host) => host.serverId === serverId)?.label ?? serverId;
|
||||
const agentWorkspaceId = useAgentWorkspaceId(serverId, agentId);
|
||||
const agentWorkspaceId = useSessionStore((state) => {
|
||||
if (!serverId || !agentId) {
|
||||
return null;
|
||||
}
|
||||
return state.sessions[serverId]?.agents?.get(agentId)?.workspaceId ?? null;
|
||||
});
|
||||
useEffect(() => {
|
||||
if (!serverId || !agentId || agentWorkspaceId) return;
|
||||
void prepareAgentRoute(serverId, agentId).catch(() => undefined);
|
||||
void getHostRuntimeStore()
|
||||
.prepareAgentRoute(serverId, agentId)
|
||||
.catch(() => undefined);
|
||||
}, [agentId, agentWorkspaceId, serverId]);
|
||||
const shouldLookupAgent = Boolean(
|
||||
serverId && agentId && client && connectionStatus === "online" && !agentWorkspaceId,
|
||||
@@ -116,7 +118,7 @@ function HostAgentReadyRouteContent() {
|
||||
return;
|
||||
}
|
||||
if (serverId) {
|
||||
void probeHostConnections(serverId);
|
||||
void getHostRuntimeStore().runProbeCycleNow(serverId);
|
||||
}
|
||||
}, [lookupQuery, resolution.kind, serverId]);
|
||||
const handleManageHost = useCallback(() => {
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { dirname, extname, join, relative } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const SOURCE_ROOT = dirname(fileURLToPath(import.meta.url));
|
||||
const UI_ROOTS = [
|
||||
"app",
|
||||
"agent-skills",
|
||||
"agent-stream",
|
||||
"command-center",
|
||||
"components",
|
||||
"composer",
|
||||
"contexts",
|
||||
"desktop",
|
||||
"file-pane",
|
||||
"git",
|
||||
"hooks",
|
||||
"panels",
|
||||
"plugins",
|
||||
"projects",
|
||||
"provider-usage",
|
||||
"screens",
|
||||
"subagents",
|
||||
"workspace",
|
||||
"workspace-recovery",
|
||||
"workspace-tabs",
|
||||
] as const;
|
||||
const SESSION_STORE_OWNER_FILES = new Set(["plugins/client-state/source.ts"]);
|
||||
|
||||
function productionSources(root: string): string[] {
|
||||
const sources: string[] = [];
|
||||
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
||||
const path = join(root, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
sources.push(...productionSources(path));
|
||||
continue;
|
||||
}
|
||||
if (![".ts", ".tsx"].includes(extname(entry.name))) continue;
|
||||
if (/\.(test|spec)\.[^.]+$/.test(entry.name)) continue;
|
||||
sources.push(path);
|
||||
}
|
||||
return sources;
|
||||
}
|
||||
|
||||
function importedModules(source: string): string[] {
|
||||
return Array.from(
|
||||
source.matchAll(/(?:from\s+|import\s*)["']([^"']+)["']/g),
|
||||
(match) => match[1] ?? "",
|
||||
);
|
||||
}
|
||||
|
||||
function importsSessionStoreValue(source: string): boolean {
|
||||
return Array.from(source.matchAll(/import\s+(?!type\b)[\s\S]*?from\s+["']([^"']+)["']/g)).some(
|
||||
(match) => match[1] === "@/stores/session-store",
|
||||
);
|
||||
}
|
||||
|
||||
function importsAgentStoreProjection(path: string): boolean {
|
||||
return importedModules(readFileSync(path, "utf8")).some((module) =>
|
||||
module.includes("runtime/directory-sync/internal/agent-store"),
|
||||
);
|
||||
}
|
||||
|
||||
describe("app architecture boundaries", () => {
|
||||
it("keeps UI leaves behind semantic session queries and commands", () => {
|
||||
const violations: string[] = [];
|
||||
for (const root of UI_ROOTS) {
|
||||
for (const path of productionSources(join(SOURCE_ROOT, root))) {
|
||||
const source = readFileSync(path, "utf8");
|
||||
const sourcePath = relative(SOURCE_ROOT, path);
|
||||
const modules = importedModules(source);
|
||||
const reasons = [
|
||||
...(importsSessionStoreValue(source) && !SESSION_STORE_OWNER_FILES.has(sourcePath)
|
||||
? ["imports session-store"]
|
||||
: []),
|
||||
...(modules.some(
|
||||
(module) =>
|
||||
module.includes("runtime/replica-cache") ||
|
||||
module.includes("runtime/client-replica/internal/cache"),
|
||||
)
|
||||
? ["imports replica cache internals"]
|
||||
: []),
|
||||
...(source.includes("useSessionStore") && !SESSION_STORE_OWNER_FILES.has(sourcePath)
|
||||
? ["uses useSessionStore"]
|
||||
: []),
|
||||
...(source.includes("getHostRuntimeStore") ? ["uses getHostRuntimeStore"] : []),
|
||||
];
|
||||
for (const reason of reasons) {
|
||||
violations.push(`${sourcePath}: ${reason}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(violations).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps cache internals private to the client-replica entry point", () => {
|
||||
const publicEntry = join(SOURCE_ROOT, "runtime/client-replica/index.ts");
|
||||
const violations: string[] = [];
|
||||
for (const path of productionSources(SOURCE_ROOT)) {
|
||||
if (path === publicEntry || path.includes("/runtime/client-replica/internal/cache/"))
|
||||
continue;
|
||||
const modules = importedModules(readFileSync(path, "utf8"));
|
||||
if (
|
||||
modules.some(
|
||||
(module) =>
|
||||
module.includes("client-replica/internal/cache") ||
|
||||
module.includes("runtime/replica-cache"),
|
||||
)
|
||||
) {
|
||||
violations.push(relative(SOURCE_ROOT, path));
|
||||
}
|
||||
}
|
||||
expect(violations).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps the session hook entry point query-only", () => {
|
||||
const source = readFileSync(join(SOURCE_ROOT, "stores/session-store-hooks/index.ts"), "utf8");
|
||||
expect(source).not.toMatch(/export\s+(?:const|function)\s+read[A-Z]/);
|
||||
expect(source).not.toMatch(/export\s+type\s+\{[^}]*SessionState/s);
|
||||
expect(source).not.toMatch(/Commands\s*[={]/);
|
||||
expect(source).not.toContain("useSessionStore.getState");
|
||||
expect(importedModules(source)).not.toContain("@getpaseo/client/internal/daemon-client");
|
||||
});
|
||||
|
||||
it("keeps agent store projection private to the agent directory owner", () => {
|
||||
const owner = join(SOURCE_ROOT, "runtime/directory-sync/agent-replica.ts");
|
||||
const violations = productionSources(SOURCE_ROOT)
|
||||
.filter((path) => path !== owner && !path.includes("/runtime/directory-sync/internal/"))
|
||||
.filter(importsAgentStoreProjection)
|
||||
.map((path) => relative(SOURCE_ROOT, path));
|
||||
expect(violations).toEqual([]);
|
||||
expect(readFileSync(join(SOURCE_ROOT, "utils/agent-directory-sync.ts"), "utf8")).not.toContain(
|
||||
"useSessionStore",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { openWorkspaceFileFromExplorer } from "@/screens/workspace/workspace-file-open-command";
|
||||
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { useHostRuntimeClient } from "@/runtime/host-runtime";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useWorkspaceDirectory } from "@/stores/session-store-hooks";
|
||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||
import { clearCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
|
||||
@@ -57,7 +57,9 @@ export function useWorkspaceFileSearch(input: { enabled: boolean; query: string
|
||||
const serverId = selection?.serverId ?? null;
|
||||
const workspaceId = selection?.workspaceId ?? null;
|
||||
const cwd = useWorkspaceDirectory(serverId, workspaceId);
|
||||
const client = useHostRuntimeClient(serverId ?? "");
|
||||
const client = useSessionStore((state) =>
|
||||
serverId ? (state.sessions[serverId]?.client ?? null) : null,
|
||||
);
|
||||
const [state, setState] = useState<WorkspaceFileSearchState>(EMPTY_STATE);
|
||||
const sourceKey = useMemo(
|
||||
() => (serverId && workspaceId && cwd && client ? `${serverId}\0${workspaceId}\0${cwd}` : null),
|
||||
|
||||
@@ -179,6 +179,10 @@ export function useWorkspaceCommandCenterActions(): void {
|
||||
const activeTabIndex = focusedTabs.findIndex((tab) => tab.tabId === focusedPane?.focusedTabId);
|
||||
const activeTabKind =
|
||||
activeTabIndex >= 0 ? (focusedTabs[activeTabIndex]?.target.kind ?? null) : null;
|
||||
// One narrow projection for the workspace management contribution fields. The registry's snapshot
|
||||
// dedup is unreachable (registry.ts spreads a fresh object per contribution, then compares by
|
||||
// reference), so the array-identity guard in registry.replace() is the only thing stopping a
|
||||
// re-render — a churny subscription here rebuilds the list while the user is looking at it.
|
||||
const fields = useWorkspaceFields(serverId, workspaceId, (workspace) => ({
|
||||
id: workspace.id,
|
||||
workspaceDirectory: workspace.workspaceDirectory ?? null,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { router } from "expo-router";
|
||||
import type { WorkspaceProjectDescriptorPayload } from "@getpaseo/protocol/messages";
|
||||
import {
|
||||
@@ -89,11 +90,8 @@ import {
|
||||
useHostRuntimeConnectionStatuses,
|
||||
} from "@/runtime/host-runtime";
|
||||
import { useHostFeatureMap } from "@/runtime/host-features";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useRecommendedProjectPaths } from "@/stores/session-store-hooks";
|
||||
import {
|
||||
acceptProjectSnapshot as upsertProject,
|
||||
publishWorkspaceHydration as setHasHydratedWorkspaces,
|
||||
} from "@/runtime/session-data";
|
||||
import type { AddProjectFlowRequest } from "@/stores/add-project-flow-store";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { shortenPath } from "@/utils/shorten-path";
|
||||
@@ -375,6 +373,16 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
|
||||
const recommendedPaths = useRecommendedProjectPaths(hostId);
|
||||
const openProject = useOpenProject(hostId);
|
||||
const cloneGithubProject = useCloneGithubProject(hostId);
|
||||
const upsertProject = useCallback(
|
||||
(
|
||||
targetServerId: string,
|
||||
project: Parameters<ReturnType<typeof getHostRuntimeStore>["acceptProjectSnapshot"]>[1],
|
||||
) => {
|
||||
getHostRuntimeStore().acceptProjectSnapshot(targetServerId, project);
|
||||
},
|
||||
[],
|
||||
);
|
||||
const setHasHydratedWorkspaces = useSessionStore((store) => store.setHasHydratedWorkspaces);
|
||||
const inputRef = useRef<EditingTextInputHandle>(null);
|
||||
const submissionInFlightRef = useRef(false);
|
||||
const browseInFlightRef = useRef(false);
|
||||
@@ -752,7 +760,7 @@ export function AddProjectFlow({ request, onClose }: AddProjectFlowProps) {
|
||||
} finally {
|
||||
submissionInFlightRef.current = false;
|
||||
}
|
||||
}, [client, openNewWorkspaceForProject, page]);
|
||||
}, [client, openNewWorkspaceForProject, page, setHasHydratedWorkspaces, upsertProject]);
|
||||
|
||||
const submitActive = useCallback(() => {
|
||||
if (page.kind === "new-directory-name") {
|
||||
|
||||
@@ -16,7 +16,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { formatTimeAgo } from "@/utils/time";
|
||||
import { type AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||
import { getHostClient, useHostRuntimeClient } from "@/runtime/host-runtime";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { Archive, ChevronRight } from "lucide-react-native";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||
@@ -390,7 +390,9 @@ export function AgentList({
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const { archiveAgent } = useArchiveAgent();
|
||||
|
||||
const actionClient = useHostRuntimeClient(actionAgent?.serverId ?? "");
|
||||
const actionClient = useSessionStore((state) =>
|
||||
actionAgent?.serverId ? (state.sessions[actionAgent.serverId]?.client ?? null) : null,
|
||||
);
|
||||
|
||||
const isActionSheetVisible = actionAgent !== null;
|
||||
const isActionDaemonUnavailable = Boolean(actionAgent?.serverId && !actionClient);
|
||||
@@ -417,13 +419,13 @@ export function AgentList({
|
||||
|
||||
const handleAgentLongPress = useCallback(
|
||||
(agent: AggregatedAgent) => {
|
||||
const isRunning = agent.turn.phase === "open";
|
||||
const isRunning = agent.status === "running";
|
||||
if (isRunning) {
|
||||
setActionAgent(agent);
|
||||
return;
|
||||
}
|
||||
|
||||
const client = getHostClient(agent.serverId);
|
||||
const client = useSessionStore.getState().sessions[agent.serverId]?.client ?? null;
|
||||
if (!client) {
|
||||
setActionAgent(agent);
|
||||
return;
|
||||
|
||||
@@ -17,11 +17,7 @@ import {
|
||||
redactAppDiagnosticReport,
|
||||
} from "@/diagnostics/app-diagnostic-report";
|
||||
import { collectDesktopDiagnosticSections } from "@/diagnostics/desktop-diagnostic-report";
|
||||
import {
|
||||
readHostRuntimeSnapshot,
|
||||
useHosts,
|
||||
type HostRuntimeSnapshot,
|
||||
} from "@/runtime/host-runtime";
|
||||
import { getHostRuntimeStore, useHosts, type HostRuntimeSnapshot } from "@/runtime/host-runtime";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
import type { HostProfile } from "@/types/host-connection";
|
||||
|
||||
@@ -112,10 +108,11 @@ export function AppDiagnosticSheet({
|
||||
updateRunProgress("desktop", desktopLabel, desktopResult.status);
|
||||
}
|
||||
|
||||
const store = getHostRuntimeStore();
|
||||
for (const host of hosts) {
|
||||
const hostProgressId = `host:${host.serverId}`;
|
||||
updateRunProgress(hostProgressId, host.label, "running");
|
||||
const snapshot = readHostRuntimeSnapshot(host.serverId);
|
||||
const snapshot = store.getSnapshot(host.serverId);
|
||||
const hostResult = await collectHostDiagnosticSections(host, snapshot);
|
||||
sections.push(...hostResult.sections);
|
||||
updateRunProgress(hostProgressId, host.label, hostResult.status);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
||||
import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store-hooks";
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
|
||||
export interface CompactExplorerSidebarHostModel {
|
||||
serverId: string;
|
||||
|
||||
@@ -54,8 +54,8 @@ import type {
|
||||
AgentFileExplorerState,
|
||||
ExplorerDirectory,
|
||||
ExplorerEntry,
|
||||
} from "@/stores/session-store-hooks";
|
||||
import { useFileExplorer, useServerFeature } from "@/stores/session-store-hooks";
|
||||
} from "@/stores/session-store";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { FileActionsContextMenuContent } from "@/components/file-actions-menu";
|
||||
import { ContextMenu, ContextMenuTrigger, useContextMenu } from "@/components/ui/context-menu";
|
||||
import { useFileDownload } from "@/hooks/use-file-download";
|
||||
@@ -426,7 +426,11 @@ export function FileExplorerPane({
|
||||
[normalizedWorkspaceRoot, workspaceId],
|
||||
);
|
||||
const hasWorkspaceScope = Boolean(workspaceStateKey && normalizedWorkspaceRoot);
|
||||
const explorerState = useFileExplorer(serverId, workspaceStateKey);
|
||||
const explorerState = useSessionStore((state) =>
|
||||
workspaceStateKey && state.sessions[serverId]
|
||||
? state.sessions[serverId]?.fileExplorer.get(workspaceStateKey)
|
||||
: undefined,
|
||||
);
|
||||
|
||||
const {
|
||||
requestDirectoryListing,
|
||||
@@ -451,9 +455,13 @@ export function FileExplorerPane({
|
||||
workspaceDirectory: normalizedWorkspaceRoot,
|
||||
});
|
||||
// COMPAT(fsEntryOps): added in v0.3.0, remove gate after 2027-02-08.
|
||||
const fsEntryOpsEnabled = useServerFeature(serverId, "fsEntryOps");
|
||||
const fsEntryOpsEnabled = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.serverInfo?.features?.fsEntryOps === true,
|
||||
);
|
||||
// COMPAT(fsEntryDuplicate): added in v0.3.0, remove gate after 2027-02-09.
|
||||
const fsEntryDuplicateEnabled = useServerFeature(serverId, "fsEntryDuplicate");
|
||||
const fsEntryDuplicateEnabled = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.serverInfo?.features?.fsEntryDuplicate === true,
|
||||
);
|
||||
const [pendingEdit, setPendingEdit] = useState<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 { getAgentTimelineLoadSnapshot } from "@/runtime/session-data";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { shouldRestoreComposerForRewindMode } from "./rewind-mode";
|
||||
import { fetchAgentTimeline } from "@/runtime/host-runtime";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
|
||||
interface UseRewindAgentMutationInput {
|
||||
serverId?: string;
|
||||
@@ -35,12 +35,13 @@ export function useRewindAgentMutation(input: UseRewindAgentMutationInput): {
|
||||
}
|
||||
await input.client.rewindAgent(input.agentId, input.messageId, mode);
|
||||
if (mode !== "files") {
|
||||
const timeline = input.serverId
|
||||
? getAgentTimelineLoadSnapshot(input.serverId, input.agentId).timeline
|
||||
: null;
|
||||
const cursor = timeline?.status === "synced" ? timeline.range : undefined;
|
||||
const cursor = input.serverId
|
||||
? useSessionStore
|
||||
.getState()
|
||||
.sessions[input.serverId]?.agentTimelineCursor.get(input.agentId)
|
||||
: undefined;
|
||||
if (!input.serverId) throw new Error(t("common.errors.daemonClientUnavailable"));
|
||||
await fetchAgentTimeline(input.serverId, input.agentId, {
|
||||
await getHostRuntimeStore().fetchAgentTimeline(input.serverId, input.agentId, {
|
||||
direction: "tail",
|
||||
projection: "projected",
|
||||
...(cursor ? { cursor: { epoch: cursor.epoch, seq: cursor.endSeq } } : {}),
|
||||
|
||||
@@ -14,6 +14,7 @@ import { Brain, Folder, GitBranch } from "lucide-react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
|
||||
import type { ScheduleCadence, ScheduleSummary } from "@getpaseo/protocol/schedule/types";
|
||||
import { useStoreWithEqualityFn } from "zustand/traditional";
|
||||
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||
import { ComboboxItem } from "@/components/ui/combobox";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -42,7 +43,7 @@ import { useScheduleMutations } from "@/hooks/use-schedule-mutations";
|
||||
import { useAggregatedAgents } from "@/hooks/use-aggregated-agents";
|
||||
import { useProjects } from "@/hooks/use-projects";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import { useConnections, type ConnectionView } from "@/stores/session-store-hooks";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { buildScheduleProjectTargets } from "@/schedules/schedule-project-targets";
|
||||
import { useScheduleFormModel } from "@/schedules/use-schedule-form-model";
|
||||
import { useScheduleFormProviderSnapshot } from "@/schedules/use-schedule-form-provider-snapshot";
|
||||
@@ -113,14 +114,14 @@ function openKey(props: ScheduleFormSheetProps): string {
|
||||
|
||||
function selectScheduleHosts(
|
||||
hosts: readonly { serverId: string; label: string }[],
|
||||
connections: ReadonlyMap<string, ConnectionView>,
|
||||
): ScheduleFormHost[] {
|
||||
return hosts.map((host) => ({
|
||||
serverId: host.serverId,
|
||||
label: host.label,
|
||||
supportsWorkspaceMultiplicity:
|
||||
connections.get(host.serverId)?.serverInfo?.features?.workspaceMultiplicity === true,
|
||||
}));
|
||||
): (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,
|
||||
}));
|
||||
}
|
||||
|
||||
function buildSnapshot(input: {
|
||||
@@ -241,10 +242,9 @@ function OpenScheduleFormSheet({
|
||||
const controlSize: FieldControlSize = useIsCompactFormFactor() ? "md" : "sm";
|
||||
const { projects } = useProjects();
|
||||
const hostProfiles = useHosts();
|
||||
const hostIds = useMemo(() => hostProfiles.map((host) => host.serverId), [hostProfiles]);
|
||||
const hosts = useConnections(
|
||||
hostIds,
|
||||
(connections) => selectScheduleHosts(hostProfiles, connections),
|
||||
const hosts = useStoreWithEqualityFn(
|
||||
useSessionStore,
|
||||
useMemo(() => selectScheduleHosts(hostProfiles), [hostProfiles]),
|
||||
equal,
|
||||
);
|
||||
const { preferences, updatePreferences } = useFormPreferences();
|
||||
|
||||
@@ -38,7 +38,7 @@ import {
|
||||
} from "@/runtime/host-runtime";
|
||||
import type { HostProfile } from "@/types/host-connection";
|
||||
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { publishSessionWorkspaces } from "@/test/seed-session";
|
||||
import { seedSessionWorkspaces } from "@/test/seed-session";
|
||||
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
|
||||
import { useWorkspaceFields } from "@/stores/session-store-hooks";
|
||||
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
@@ -167,7 +167,7 @@ function initializeSidebarState(workspaces: WorkspaceDescriptor[]): void {
|
||||
act(() => {
|
||||
setHostProfiles([makeHost()]);
|
||||
useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient);
|
||||
publishSessionWorkspaces(SERVER_ID, new Map(workspaces.map((entry) => [entry.id, entry])));
|
||||
seedSessionWorkspaces(SERVER_ID, new Map(workspaces.map((entry) => [entry.id, entry])));
|
||||
useSessionStore.getState().setHasHydratedWorkspaces(SERVER_ID, true);
|
||||
useSidebarOrderStore.setState({
|
||||
projectOrder: ["project-a", "project-b"],
|
||||
|
||||
@@ -40,7 +40,7 @@ import { ExternalLink, Settings, MoreVertical, Plus, Trash2 } from "lucide-react
|
||||
import { NestableScrollContainer } from "react-native-draggable-flatlist";
|
||||
import { DraggableList, type DraggableRenderItemInfo } from "./draggable-list";
|
||||
import type { DraggableListDragHandleProps } from "./draggable-list.types";
|
||||
import { getHostClient, useHosts } from "@/runtime/host-runtime";
|
||||
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
|
||||
import type { PinnedSidebarGroups } from "@/hooks/use-sidebar-pins";
|
||||
import {
|
||||
useSidebarWorkspacePinController,
|
||||
@@ -1703,7 +1703,7 @@ function ProjectBlock({
|
||||
|
||||
void removeProjectFromHosts({
|
||||
targets: readiness.targets,
|
||||
getClient: (serverId) => getHostClient(serverId),
|
||||
getClient: (serverId) => getHostRuntimeStore().getClient(serverId),
|
||||
})
|
||||
.then((outcome) => {
|
||||
if (outcome.kind === "host_disconnected") {
|
||||
|
||||
@@ -19,7 +19,7 @@ import { useAppDiagnosticStore } from "@/diagnostics/store";
|
||||
import { useKeyboardShortcutsAvailable } from "@/keyboard/availability";
|
||||
import { useHostRuntimeIsConnected, useHosts } from "@/runtime/host-runtime";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { useConnection } from "@/stores/session-store-hooks";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
import type { HostProfile } from "@/types/host-connection";
|
||||
import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates";
|
||||
@@ -58,9 +58,8 @@ const changelogLeadingIcon = (
|
||||
function HostVersionHint({ host }: { host: HostProfile }) {
|
||||
const { t } = useTranslation();
|
||||
const isConnected = useHostRuntimeIsConnected(host.serverId);
|
||||
const daemonVersion = useConnection(
|
||||
host.serverId,
|
||||
({ serverInfo }) => serverInfo?.version ?? null,
|
||||
const daemonVersion = useSessionStore(
|
||||
(state) => state.sessions[host.serverId]?.serverInfo?.version ?? null,
|
||||
);
|
||||
const version = isConnected
|
||||
? formatVersionWithPrefix(daemonVersion)
|
||||
|
||||
@@ -50,8 +50,7 @@ import {
|
||||
import { resolveTerminalRestoreOptions } from "@/terminal/runtime/terminal-restore-options";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { useBlockMobilePanelOpenGestures } from "@/mobile-panels/provider";
|
||||
import { useServerFeature } from "@/stores/session-store-hooks";
|
||||
import { publishFocusedTerminal as setFocusedTerminalId } from "@/runtime/session-data";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { toXtermTheme } from "@/utils/to-xterm-theme";
|
||||
import TerminalEmulator, { type TerminalEmulatorHandle } from "./terminal-emulator";
|
||||
import { TerminalFloatingCopyAction, TerminalPasteAction } from "./terminal-copy-paste-actions";
|
||||
@@ -234,9 +233,17 @@ export function TerminalPane({
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
const isTerminalPresented = retainedPanelActive && isWorkspaceFocused;
|
||||
const supportsTerminalRestoreModes = useServerFeature(serverId, "terminal-restore-modes");
|
||||
const supportsTerminalInputModeReplay = useServerFeature(serverId, "terminal-input-mode-replay");
|
||||
const supportsTerminalSizeOwnership = useServerFeature(serverId, "terminal-size-ownership");
|
||||
const supportsTerminalRestoreModes = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.serverInfo?.features?.["terminal-restore-modes"] === true,
|
||||
);
|
||||
const supportsTerminalInputModeReplay = useSessionStore(
|
||||
(state) =>
|
||||
state.sessions[serverId]?.serverInfo?.features?.["terminal-input-mode-replay"] === true,
|
||||
);
|
||||
const supportsTerminalSizeOwnership = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.serverInfo?.features?.["terminal-size-ownership"] === true,
|
||||
);
|
||||
const setFocusedTerminalId = useSessionStore((state) => state.setFocusedTerminalId);
|
||||
|
||||
const scopeKey = useMemo(() => terminalScopeKey({ serverId, cwd }), [serverId, cwd]);
|
||||
const terminalStreamKey = useMemo(() => `${scopeKey}:${terminalId}`, [scopeKey, terminalId]);
|
||||
@@ -386,7 +393,14 @@ export function TerminalPane({
|
||||
lastSentTerminalSizeRef.current = null;
|
||||
requestTerminalReflow();
|
||||
emulatorRef.current?.claimSize();
|
||||
}, [isPaneFocused, isWorkspaceFocused, requestTerminalReflow, serverId, terminalId]);
|
||||
}, [
|
||||
isPaneFocused,
|
||||
isWorkspaceFocused,
|
||||
requestTerminalReflow,
|
||||
serverId,
|
||||
setFocusedTerminalId,
|
||||
terminalId,
|
||||
]);
|
||||
|
||||
const clearKeyboardRefitTimeouts = useCallback(() => {
|
||||
if (keyboardRefitTimeoutsRef.current.length === 0) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Pressable, Text, View, ScrollView } from "react-native";
|
||||
import { useRouter } from "expo-router";
|
||||
@@ -13,12 +13,7 @@ import {
|
||||
} from "lucide-react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import type { HostProfile } from "@/types/host-connection";
|
||||
import {
|
||||
isHostRuntimeConnected,
|
||||
readHostRuntimeSnapshot,
|
||||
useHostRuntimeVersion,
|
||||
useHosts,
|
||||
} from "@/runtime/host-runtime";
|
||||
import { getHostRuntimeStore, isHostRuntimeConnected, useHosts } from "@/runtime/host-runtime";
|
||||
import { AddHostModal } from "./add-host-modal";
|
||||
import { AddRemoteSshHostModal } from "./add-remote-ssh-host-modal";
|
||||
import { PairLinkModal } from "./pair-link-modal";
|
||||
@@ -129,24 +124,42 @@ const styles = StyleSheet.create((theme) => ({
|
||||
}));
|
||||
|
||||
function useAnyHostOnline(serverIds: string[]): string | null {
|
||||
const runtimeVersion = useHostRuntimeVersion();
|
||||
return useMemo(() => {
|
||||
void runtimeVersion;
|
||||
let firstOnlineServerId: string | null = null;
|
||||
let firstOnlineAt: string | null = null;
|
||||
for (const serverId of serverIds) {
|
||||
const snapshot = readHostRuntimeSnapshot(serverId);
|
||||
const lastOnlineAt = snapshot?.lastOnlineAt ?? null;
|
||||
if (!isHostRuntimeConnected(snapshot) || !lastOnlineAt) {
|
||||
continue;
|
||||
const runtime = getHostRuntimeStore();
|
||||
return useSyncExternalStore(
|
||||
(onStoreChange) => runtime.subscribeAll(onStoreChange),
|
||||
() => {
|
||||
let firstOnlineServerId: string | null = null;
|
||||
let firstOnlineAt: string | null = null;
|
||||
for (const serverId of serverIds) {
|
||||
const snapshot = runtime.getSnapshot(serverId);
|
||||
const lastOnlineAt = snapshot?.lastOnlineAt ?? null;
|
||||
if (!isHostRuntimeConnected(snapshot) || !lastOnlineAt) {
|
||||
continue;
|
||||
}
|
||||
if (!firstOnlineAt || lastOnlineAt < firstOnlineAt) {
|
||||
firstOnlineAt = lastOnlineAt;
|
||||
firstOnlineServerId = serverId;
|
||||
}
|
||||
}
|
||||
if (!firstOnlineAt || lastOnlineAt < firstOnlineAt) {
|
||||
firstOnlineAt = lastOnlineAt;
|
||||
firstOnlineServerId = serverId;
|
||||
return firstOnlineServerId;
|
||||
},
|
||||
() => {
|
||||
let firstOnlineServerId: string | null = null;
|
||||
let firstOnlineAt: string | null = null;
|
||||
for (const serverId of serverIds) {
|
||||
const snapshot = runtime.getSnapshot(serverId);
|
||||
const lastOnlineAt = snapshot?.lastOnlineAt ?? null;
|
||||
if (!isHostRuntimeConnected(snapshot) || !lastOnlineAt) {
|
||||
continue;
|
||||
}
|
||||
if (!firstOnlineAt || lastOnlineAt < firstOnlineAt) {
|
||||
firstOnlineAt = lastOnlineAt;
|
||||
firstOnlineServerId = serverId;
|
||||
}
|
||||
}
|
||||
}
|
||||
return firstOnlineServerId;
|
||||
}, [runtimeVersion, serverIds]);
|
||||
return firstOnlineServerId;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export interface WelcomeScreenProps {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { AdaptiveRenameModal } from "@/components/rename-modal";
|
||||
import { getHostClient } from "@/runtime/host-runtime";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
|
||||
// The subset of a workspace the rename dialog needs. Narrower than SidebarWorkspaceEntry so the
|
||||
// command center can build one from the active route selection without a sidebar row.
|
||||
@@ -39,7 +39,7 @@ export function WorkspaceRenameModal({
|
||||
|
||||
const renameMutation = useMutation({
|
||||
mutationFn: async (title: string) => {
|
||||
const client = getHostClient(workspace.serverId);
|
||||
const client = getHostRuntimeStore().getClient(workspace.serverId);
|
||||
if (!client) {
|
||||
throw new Error(t("sidebar.workspace.toasts.hostDisconnected"));
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
@@ -13,11 +14,10 @@ import { useAgentInputDraft } from "@/composer/draft/input-draft";
|
||||
import { useProjectIcon } from "@/projects/icons";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import {
|
||||
acceptAgentSnapshot,
|
||||
acceptWorkspaceSnapshots as mergeWorkspaces,
|
||||
publishWorkspaceHydration as setHasHydratedWorkspaces,
|
||||
} from "@/runtime/session-data";
|
||||
import { normalizeWorkspaceDescriptor, useServerFeature } from "@/stores/session-store-hooks";
|
||||
normalizeWorkspaceDescriptor,
|
||||
useSessionStore,
|
||||
type WorkspaceDescriptor,
|
||||
} from "@/stores/session-store";
|
||||
import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
|
||||
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
||||
import { applyLegacyDaemonWorkspaceOwnership } from "@/workspace/legacy-daemon-workspaces";
|
||||
@@ -169,6 +169,13 @@ export function WorkspaceSetupDialog() {
|
||||
const toast = useToast();
|
||||
const pendingWorkspaceSetup = useWorkspaceSetupStore((state) => state.pendingWorkspaceSetup);
|
||||
const clearWorkspaceSetup = useWorkspaceSetupStore((state) => state.clearWorkspaceSetup);
|
||||
const mergeWorkspaces = useCallback(
|
||||
(targetServerId: string, workspaces: Iterable<WorkspaceDescriptor>) => {
|
||||
getHostRuntimeStore().acceptWorkspaceSnapshots(targetServerId, Array.from(workspaces));
|
||||
},
|
||||
[],
|
||||
);
|
||||
const setHasHydratedWorkspaces = useSessionStore((state) => state.setHasHydratedWorkspaces);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [createdWorkspace, setCreatedWorkspace] = useState<ReturnType<
|
||||
typeof normalizeWorkspaceDescriptor
|
||||
@@ -176,7 +183,9 @@ export function WorkspaceSetupDialog() {
|
||||
const [pendingAction, setPendingAction] = useState<"chat" | null>(null);
|
||||
|
||||
const serverId = pendingWorkspaceSetup?.serverId ?? "";
|
||||
const supportsForgeSearch = useServerFeature(serverId, "forgeSearch");
|
||||
const supportsForgeSearch = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.serverInfo?.features?.forgeSearch === true,
|
||||
);
|
||||
const sourceDirectory = pendingWorkspaceSetup?.sourceDirectory ?? "";
|
||||
const displayName = pendingWorkspaceSetup?.displayName?.trim() ?? "";
|
||||
const workspace = createdWorkspace;
|
||||
@@ -278,7 +287,14 @@ export function WorkspaceSetupDialog() {
|
||||
setCreatedWorkspace(normalizedWorkspace);
|
||||
return normalizedWorkspace;
|
||||
},
|
||||
[createdWorkspace, pendingWorkspaceSetup, t, withConnectedClient],
|
||||
[
|
||||
createdWorkspace,
|
||||
mergeWorkspaces,
|
||||
pendingWorkspaceSetup,
|
||||
setHasHydratedWorkspaces,
|
||||
t,
|
||||
withConnectedClient,
|
||||
],
|
||||
);
|
||||
|
||||
const getIsStillActive = useCallback(() => {
|
||||
@@ -334,7 +350,7 @@ export function WorkspaceSetupDialog() {
|
||||
return;
|
||||
}
|
||||
|
||||
acceptAgentSnapshot(
|
||||
getHostRuntimeStore().acceptAgentSnapshot(
|
||||
serverId,
|
||||
applyLegacyDaemonWorkspaceOwnership({
|
||||
serverId,
|
||||
|
||||
@@ -9,7 +9,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { publishSessionWorkspaces } from "@/test/seed-session";
|
||||
import { seedSessionWorkspaces } from "@/test/seed-session";
|
||||
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
|
||||
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
|
||||
import { useSidebarViewStore } from "@/stores/sidebar-view-store";
|
||||
@@ -96,7 +96,7 @@ describe("WorkspaceShortcutTargetsSubscriber", () => {
|
||||
act(() => {
|
||||
setHostProfiles([hostProfile()]);
|
||||
useSessionStore.getState().initializeSession("srv", null as unknown as DaemonClient);
|
||||
publishSessionWorkspaces(
|
||||
seedSessionWorkspaces(
|
||||
"srv",
|
||||
new Map([
|
||||
["ws-1", workspaceDescriptor({ id: "ws-1", name: "Workspace 1" })],
|
||||
@@ -142,7 +142,7 @@ describe("WorkspaceShortcutTargetsSubscriber", () => {
|
||||
it("publishes status-mode shortcut targets in visual status order", async () => {
|
||||
act(() => {
|
||||
useSidebarViewStore.getState().setGroupMode("status");
|
||||
publishSessionWorkspaces(
|
||||
seedSessionWorkspaces(
|
||||
"srv",
|
||||
new Map([
|
||||
[
|
||||
@@ -214,11 +214,11 @@ describe("WorkspaceShortcutTargetsSubscriber", () => {
|
||||
setHostProfiles([hostProfile("host-a"), hostProfile("host-b")]);
|
||||
useSessionStore.getState().initializeSession("host-a", null as unknown as DaemonClient);
|
||||
useSessionStore.getState().initializeSession("host-b", null as unknown as DaemonClient);
|
||||
publishSessionWorkspaces(
|
||||
seedSessionWorkspaces(
|
||||
"host-a",
|
||||
new Map([["a-1", workspaceDescriptor({ id: "a-1", name: "Host A" })]]),
|
||||
);
|
||||
publishSessionWorkspaces(
|
||||
seedSessionWorkspaces(
|
||||
"host-b",
|
||||
new Map([["b-1", workspaceDescriptor({ id: "b-1", name: "Host B" })]]),
|
||||
);
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useShallow } from "zustand/shallow";
|
||||
import { Settings2 } from "lucide-react-native";
|
||||
import { getAgentFeatureIcon, ThinkingIcon } from "@/agent-controls/icons";
|
||||
import { formatThinkingOptionLabel } from "@/agent-controls/labels";
|
||||
@@ -33,8 +34,7 @@ import {
|
||||
type ProviderSelectorProvider,
|
||||
} from "@/provider-selection/provider-selection";
|
||||
import { filterSelectableModels } from "@/provider-selection/model-catalog";
|
||||
import { useActiveAgentFields, type Agent } from "@/stores/session-store-hooks";
|
||||
import { useHostRuntimeClient } from "@/runtime/host-runtime";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
import { resolveProviderDefinition } from "@/utils/provider-definitions";
|
||||
import { mergeProviderPreferences, useFormPreferences } from "@/hooks/use-form-preferences";
|
||||
@@ -398,7 +398,15 @@ type AgentControlsSlice = {
|
||||
lastUsage: unknown;
|
||||
} | null;
|
||||
|
||||
function selectAgentControlsSlice(currentAgent: Agent): AgentControlsSlice {
|
||||
function selectAgentControlsSlice(
|
||||
state: ReturnType<typeof useSessionStore.getState>,
|
||||
serverId: string,
|
||||
agentId: string,
|
||||
): AgentControlsSlice {
|
||||
const currentAgent = state.sessions[serverId]?.agents?.get(agentId) ?? null;
|
||||
if (!currentAgent) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
provider: currentAgent.provider,
|
||||
cwd: currentAgent.cwd,
|
||||
@@ -1533,8 +1541,10 @@ export const AgentControls = memo(function AgentControls({
|
||||
isCompactLayout,
|
||||
}: AgentControlsProps) {
|
||||
const { updatePreferences } = useFormPreferences();
|
||||
const agent = useActiveAgentFields(serverId, agentId, selectAgentControlsSlice);
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const agent = useSessionStore(
|
||||
useShallow((state) => selectAgentControlsSlice(state, serverId, agentId)),
|
||||
);
|
||||
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
|
||||
const toast = useToast();
|
||||
const modeControl = useLiveAgentModeControl(serverId, agentId);
|
||||
const commandCenterModes = toCommandCenterModes(modeControl);
|
||||
|
||||
@@ -2,6 +2,8 @@ import { useCallback, useMemo, useRef, useState, type ReactElement } from "react
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useShallow } from "zustand/shallow";
|
||||
import { useStoreWithEqualityFn } from "zustand/traditional";
|
||||
import { type SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
@@ -15,8 +17,7 @@ import { resolveNextAgentModeId } from "@/composer/agent-controls/mode";
|
||||
import { useComposerKeyboardScope } from "@/composer/keyboard-scope";
|
||||
import { useComposerControlLayout } from "@/composer/agent-controls/layout-context";
|
||||
import { AgentControlTrigger } from "@/composer/agent-controls/control";
|
||||
import { useActiveAgentFields } from "@/stores/session-store-hooks";
|
||||
import { useHostRuntimeClient } from "@/runtime/host-runtime";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
import { mergeProviderPreferences, useFormPreferences } from "@/hooks/use-form-preferences";
|
||||
import { resolveProviderDefinition } from "@/utils/provider-definitions";
|
||||
@@ -237,18 +238,31 @@ export function AgentModeControl({
|
||||
|
||||
const EMPTY_MODES: AgentMode[] = [];
|
||||
|
||||
function compareAvailableModes(a: AgentMode[], b: AgentMode[]): boolean {
|
||||
return a === b || JSON.stringify(a) === JSON.stringify(b);
|
||||
}
|
||||
|
||||
export function useLiveAgentModeControl(
|
||||
serverId: string,
|
||||
agentId: string,
|
||||
): AgentModeControlValue | null {
|
||||
const slice = useActiveAgentFields(serverId, agentId, (agent) => ({
|
||||
provider: agent.provider,
|
||||
cwd: agent.cwd,
|
||||
currentModeId: agent.currentModeId,
|
||||
availableModes: agent.availableModes,
|
||||
}));
|
||||
const availableModes = slice?.availableModes ?? EMPTY_MODES;
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const slice = useSessionStore(
|
||||
useShallow((state) => {
|
||||
const agent = state.sessions[serverId]?.agents?.get(agentId);
|
||||
if (!agent) return null;
|
||||
return {
|
||||
provider: agent.provider,
|
||||
cwd: agent.cwd,
|
||||
currentModeId: agent.currentModeId,
|
||||
};
|
||||
}),
|
||||
);
|
||||
const availableModes = useStoreWithEqualityFn(
|
||||
useSessionStore,
|
||||
(state) => state.sessions[serverId]?.agents?.get(agentId)?.availableModes ?? EMPTY_MODES,
|
||||
compareAvailableModes,
|
||||
);
|
||||
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
|
||||
const { updatePreferences } = useFormPreferences();
|
||||
const toast = useToast();
|
||||
const { entries: snapshotEntries } = useProvidersSnapshot(serverId, { cwd: slice?.cwd });
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
} from "@/composer/attachments/submit";
|
||||
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
||||
import { handoffCreatedAgentMessageSubmission } from "@/composer/submission/writer";
|
||||
import { hostSupports } from "@/runtime/session-data";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import {
|
||||
createUserMessage,
|
||||
generateMessageId,
|
||||
@@ -267,7 +267,9 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
||||
dispatch({ type: "DRAFT_SET_ERROR", message: error.message });
|
||||
throw error;
|
||||
}
|
||||
const supportsForgeSearch = hostSupports(pendingServerId, "forgeSearch");
|
||||
const supportsForgeSearch =
|
||||
useSessionStore.getState().sessions[pendingServerId]?.serverInfo?.features?.forgeSearch ===
|
||||
true;
|
||||
const wirePayload = splitComposerAttachmentsForSubmit(attachments, {
|
||||
format: resolveComposerAttachmentSubmitFormat({
|
||||
supportsForgeAttachments: supportsForgeSearch,
|
||||
|
||||
@@ -20,7 +20,7 @@ import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-
|
||||
import { buildWorkspaceDraftAgentConfig } from "@/screens/workspace/workspace-draft-agent-config";
|
||||
import { buildDraftStoreKey } from "@/stores/draft-keys";
|
||||
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
||||
import type { Agent } from "@/stores/session-store-hooks";
|
||||
import type { Agent } from "@/stores/session-store";
|
||||
import { useWorkspaceFields } from "@/stores/session-store-hooks";
|
||||
import { useWorkspaceDraftSubmissionStore } from "@/stores/workspace-draft-submission-store";
|
||||
import { useAgentControlCommandCenterActions } from "@/command-center/agent-control-registration";
|
||||
@@ -248,12 +248,7 @@ function buildDraftAgentSnapshot(input: {
|
||||
id: tabId,
|
||||
provider,
|
||||
status: "running",
|
||||
turn: {
|
||||
phase: "open",
|
||||
turnId: null,
|
||||
startedAt: now,
|
||||
cancellationRequestId: null,
|
||||
},
|
||||
turn: { phase: "idle", cancellationRequestId: null },
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
lastUserMessageAt: now,
|
||||
@@ -347,9 +342,9 @@ export function WorkspaceDraftAgentTab({
|
||||
const insets = useSafeAreaInsets();
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
const workspaceFields = useWorkspaceFields(serverId, workspaceId, (workspace) => ({
|
||||
id: workspace.id,
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
const workspaceFields = useWorkspaceFields(serverId, workspaceId, (w) => ({
|
||||
workspaceDirectory: w.workspaceDirectory,
|
||||
id: w.id,
|
||||
}));
|
||||
const workspaceDirectory = workspaceFields?.workspaceDirectory || null;
|
||||
const draftSetup = initialSetup ?? null;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import {
|
||||
View,
|
||||
@@ -20,6 +21,7 @@ import {
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useShallow } from "zustand/shallow";
|
||||
import {
|
||||
ArrowUp,
|
||||
Square,
|
||||
@@ -42,18 +44,7 @@ import {
|
||||
import { ContextWindowMeter } from "@/components/context-window-meter";
|
||||
import { KeyboardTranslateView } from "@/components/keyboard-translate-view";
|
||||
import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker";
|
||||
import {
|
||||
useActiveAgentFields,
|
||||
type Agent,
|
||||
useAgentTurn,
|
||||
useComposerSession,
|
||||
useServerFeature,
|
||||
} from "@/stores/session-store-hooks";
|
||||
import {
|
||||
getAgentTurnSnapshot,
|
||||
getQueuedMessagesSnapshot,
|
||||
publishQueuedMessages as setQueuedMessages,
|
||||
} from "@/runtime/session-data";
|
||||
import { selectAgentTurnPresentation, useSessionStore } from "@/stores/session-store";
|
||||
import { useFilePicker } from "@/hooks/use-file-picker";
|
||||
import { useFileDrop } from "@/components/file-drop/use-file-drop";
|
||||
import type { DroppedItem } from "@/components/file-drop/types";
|
||||
@@ -103,7 +94,6 @@ import {
|
||||
useHostRuntimeClient,
|
||||
useHostRuntimeIsConnected,
|
||||
} from "@/runtime/host-runtime";
|
||||
import { beginAgentCancellation, settleAgentCancellation } from "@/runtime/session-data";
|
||||
import {
|
||||
deleteAttachments,
|
||||
persistAttachmentFromBlob,
|
||||
@@ -265,14 +255,17 @@ function buildRealtimeVoiceButtonStyle(
|
||||
);
|
||||
}
|
||||
|
||||
function buildAgentState(agent: Agent) {
|
||||
return {
|
||||
status: agent.status,
|
||||
contextWindowMaxTokens: agent.lastUsage?.contextWindowMaxTokens ?? null,
|
||||
contextWindowUsedTokens: agent.lastUsage?.contextWindowUsedTokens ?? null,
|
||||
totalCostUsd: agent.lastUsage?.totalCostUsd ?? null,
|
||||
model: agent.model,
|
||||
provider: agent.provider,
|
||||
function buildAgentStateSelector(serverId: string, agentId: string) {
|
||||
return (state: ReturnType<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,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -989,6 +982,7 @@ interface ComposerProps {
|
||||
|
||||
const MAX_FILE_SIZE_BYTES = 50 * 1024 * 1024;
|
||||
|
||||
const EMPTY_ARRAY: readonly QueuedMessage[] = [];
|
||||
const StableMessageInput = memo(MessageInput);
|
||||
|
||||
function resolveContextWindowValues(
|
||||
@@ -1210,15 +1204,14 @@ function ComposerContentImpl({
|
||||
|
||||
const { settings: appSettings } = useAppSettings();
|
||||
|
||||
const agentState = useActiveAgentFields(serverId, agentId, buildAgentState) ?? {
|
||||
status: null,
|
||||
contextWindowMaxTokens: null,
|
||||
contextWindowUsedTokens: null,
|
||||
totalCostUsd: null,
|
||||
model: null,
|
||||
provider: null,
|
||||
};
|
||||
const { queuedMessages, hasPendingPermission } = useComposerSession(serverId, agentId);
|
||||
const agentState = useSessionStore(useShallow(buildAgentStateSelector(serverId, agentId)));
|
||||
|
||||
const queuedMessagesRaw = useSessionStore((state) =>
|
||||
state.sessions[serverId]?.queuedMessages?.get(agentId),
|
||||
);
|
||||
const queuedMessages = queuedMessagesRaw ?? EMPTY_ARRAY;
|
||||
|
||||
const setQueuedMessages = useSessionStore((state) => state.setQueuedMessages);
|
||||
|
||||
const isCompactFormFactor = useIsCompactFormFactor();
|
||||
const isCompactLayout = resolveCompactLayout(isCompactLayoutOverride, isCompactFormFactor);
|
||||
@@ -1244,7 +1237,9 @@ function ComposerContentImpl({
|
||||
});
|
||||
const setSelectedAttachments = onChangeAttachments;
|
||||
const checkoutStatusQuery = useCheckoutStatusQuery({ serverId, cwd });
|
||||
const supportsForgeSearch = useServerFeature(serverId, "forgeSearch");
|
||||
const supportsForgeSearch = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.serverInfo?.features?.forgeSearch === true,
|
||||
);
|
||||
const forgeAutoAttach = useComposerForgeAutoAttach({
|
||||
text: userInput,
|
||||
remoteUrl: resolveCheckoutRemoteUrl(checkoutStatusQuery.status),
|
||||
@@ -1493,7 +1488,10 @@ function ComposerContentImpl({
|
||||
activeTurnBehavior,
|
||||
activeTurnId:
|
||||
activeTurnBehavior === "steer"
|
||||
? (getAgentTurnSnapshot(serverId, targetAgentId).turnId ?? undefined)
|
||||
? (selectAgentTurnPresentation(
|
||||
useSessionStore.getState().sessions[serverId],
|
||||
targetAgentId,
|
||||
).turnId ?? undefined)
|
||||
: undefined,
|
||||
});
|
||||
onAttentionPromptSend?.();
|
||||
@@ -1504,11 +1502,23 @@ function ComposerContentImpl({
|
||||
onSubmitMessageRef.current = onSubmitMessage;
|
||||
}, [onSubmitMessage]);
|
||||
|
||||
const hasActiveTurn = useAgentTurn(serverId, agentId, (turn) => turn.isActive);
|
||||
const isCancellingAgent = useAgentTurn(serverId, agentId, (turn) => turn.isCancelling);
|
||||
const hasActiveTurn = useSessionStore(
|
||||
(state) => selectAgentTurnPresentation(state.sessions[serverId], agentId).isActive,
|
||||
);
|
||||
const isCancellingAgent = useSessionStore(
|
||||
(state) => selectAgentTurnPresentation(state.sessions[serverId], agentId).isCancelling,
|
||||
);
|
||||
const isAgentRunning = hasActiveTurn;
|
||||
// Queueing behind a permission prompt would strand the message: the turn is
|
||||
// parked until the request is answered.
|
||||
const hasPendingPermission = useSessionStore((state) => {
|
||||
const pendingPermissions = state.sessions[serverId]?.pendingPermissions;
|
||||
if (!pendingPermissions) return false;
|
||||
for (const permission of pendingPermissions.values()) {
|
||||
if (permission.agentId === agentId) return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
const activeSendBehavior = resolveActiveSendBehavior(
|
||||
appSettings.sendBehavior,
|
||||
hasPendingPermission,
|
||||
@@ -1517,10 +1527,10 @@ function ComposerContentImpl({
|
||||
|
||||
const queueWriter = useMemo<QueueWriter>(
|
||||
() => ({
|
||||
read: (id) => getQueuedMessagesSnapshot(serverId, id),
|
||||
read: (id) => useSessionStore.getState().sessions[serverId]?.queuedMessages?.get(id) ?? [],
|
||||
write: (updater) => setQueuedMessages(serverId, updater),
|
||||
}),
|
||||
[serverId],
|
||||
[serverId, setQueuedMessages],
|
||||
);
|
||||
|
||||
const queueMessage = useCallback(
|
||||
@@ -1803,7 +1813,7 @@ function ComposerContentImpl({
|
||||
isConnected,
|
||||
});
|
||||
if (!cancellation) return;
|
||||
const requestId = beginAgentCancellation(serverId, targetAgentId);
|
||||
const requestId = getHostRuntimeStore().beginAgentCancellation(serverId, targetAgentId);
|
||||
void cancellation
|
||||
.catch((error) => {
|
||||
const message = resolveErrorMessage(error);
|
||||
@@ -1812,7 +1822,7 @@ function ComposerContentImpl({
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
settleAgentCancellation(serverId, targetAgentId, requestId);
|
||||
getHostRuntimeStore().settleAgentCancellation(serverId, targetAgentId, requestId);
|
||||
});
|
||||
messageInputRef.current?.focus();
|
||||
}, [client, isAgentRunning, isCancellingAgent, isConnected, serverId]);
|
||||
|
||||
@@ -26,7 +26,7 @@ import { useDictation } from "@/hooks/use-dictation";
|
||||
import { DictationOverlay } from "@/components/dictation-controls";
|
||||
import { RealtimeVoiceOverlay } from "@/components/realtime-voice-overlay";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { useConnection } from "@/stores/session-store-hooks";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useVoiceOptional } from "@/contexts/voice-context";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { resolveVoiceUnavailableMessage } from "@/utils/server-info-capabilities";
|
||||
@@ -1274,11 +1274,16 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
getNativeElement: () => (isWeb ? getTextInputNativeElement(textInputRef.current) : null),
|
||||
}));
|
||||
const sendAfterTranscriptRef = useRef(false);
|
||||
const serverInfo = useConnection(
|
||||
voiceServerId,
|
||||
useCallback((connection) => {
|
||||
return connection.serverInfo;
|
||||
}, []),
|
||||
const serverInfo = useSessionStore(
|
||||
useCallback(
|
||||
(state) => {
|
||||
if (!voiceServerId) {
|
||||
return null;
|
||||
}
|
||||
return state.sessions[voiceServerId]?.serverInfo ?? null;
|
||||
},
|
||||
[voiceServerId],
|
||||
),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
import type { MessageSubmissionWriter } from "@/composer/actions";
|
||||
import {
|
||||
acceptMessageSubmission,
|
||||
beginMessageSubmission,
|
||||
handoffCreatedAgentSubmission,
|
||||
getAgentStreamSnapshot,
|
||||
hostSupports,
|
||||
publishAgentStreamState,
|
||||
rejectMessageSubmission,
|
||||
} from "@/runtime/session-data";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import {
|
||||
appendSubmittedUserMessage,
|
||||
removeSubmittedUserMessage,
|
||||
@@ -15,8 +7,13 @@ import {
|
||||
} from "@/types/stream";
|
||||
|
||||
function appendUntrackedSubmission(serverId: string, agentId: string, message: UserMessageItem) {
|
||||
const { tail, head } = getAgentStreamSnapshot(serverId, agentId);
|
||||
publishAgentStreamState(serverId, agentId, appendSubmittedUserMessage({ tail, head, message }));
|
||||
const session = useSessionStore.getState().sessions[serverId];
|
||||
if (!session) return;
|
||||
const tail = session.agentStreamTail.get(agentId) ?? [];
|
||||
const head = session.agentStreamHead.get(agentId) ?? [];
|
||||
useSessionStore
|
||||
.getState()
|
||||
.setAgentStreamState(serverId, agentId, appendSubmittedUserMessage({ tail, head, message }));
|
||||
}
|
||||
|
||||
function removeUntrackedSubmission(
|
||||
@@ -24,12 +21,17 @@ function removeUntrackedSubmission(
|
||||
agentId: string,
|
||||
clientMessageId: string,
|
||||
): void {
|
||||
const { tail, head } = getAgentStreamSnapshot(serverId, agentId);
|
||||
publishAgentStreamState(
|
||||
serverId,
|
||||
agentId,
|
||||
removeSubmittedUserMessage({ tail, head, clientMessageId }),
|
||||
);
|
||||
const session = useSessionStore.getState().sessions[serverId];
|
||||
if (!session) return;
|
||||
const tail = session.agentStreamTail.get(agentId) ?? [];
|
||||
const head = session.agentStreamHead.get(agentId) ?? [];
|
||||
useSessionStore
|
||||
.getState()
|
||||
.setAgentStreamState(
|
||||
serverId,
|
||||
agentId,
|
||||
removeSubmittedUserMessage({ tail, head, clientMessageId }),
|
||||
);
|
||||
}
|
||||
|
||||
function createUntrackedMessageSubmissionWriter(serverId: string): MessageSubmissionWriter {
|
||||
@@ -44,17 +46,20 @@ function createUntrackedMessageSubmissionWriter(serverId: string): MessageSubmis
|
||||
}
|
||||
|
||||
export function createMessageSubmissionWriter(serverId: string): MessageSubmissionWriter {
|
||||
const supportsTrackedMessageSubmissions = hostSupports(serverId, "canonicalSubmittedPrompts");
|
||||
const supportsTrackedMessageSubmissions =
|
||||
useSessionStore.getState().sessions[serverId]?.serverInfo?.features
|
||||
?.canonicalSubmittedPrompts === true;
|
||||
if (!supportsTrackedMessageSubmissions) {
|
||||
// COMPAT(canonicalSubmittedPrompts): added in v0.2.6; remove the gate after 2027-01-31 once daemon floor >= v0.2.6.
|
||||
return createUntrackedMessageSubmissionWriter(serverId);
|
||||
}
|
||||
return {
|
||||
begin: (agentId, message) => beginMessageSubmission(serverId, agentId, message),
|
||||
begin: (agentId, message) =>
|
||||
useSessionStore.getState().beginAgentMessageSubmission(serverId, agentId, message),
|
||||
accept: (agentId, clientMessageId) =>
|
||||
acceptMessageSubmission(serverId, agentId, clientMessageId),
|
||||
useSessionStore.getState().acceptAgentMessageSubmission(serverId, agentId, clientMessageId),
|
||||
reject: (agentId, clientMessageId) =>
|
||||
rejectMessageSubmission(serverId, agentId, clientMessageId),
|
||||
useSessionStore.getState().rejectAgentMessageSubmission(serverId, agentId, clientMessageId),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -63,5 +68,5 @@ export function handoffCreatedAgentMessageSubmission(
|
||||
agentId: string,
|
||||
message: UserMessageItem,
|
||||
): boolean {
|
||||
return handoffCreatedAgentSubmission(serverId, agentId, message);
|
||||
return useSessionStore.getState().handoffCreatedAgentUserMessage(serverId, agentId, message);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store-hooks";
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { useWorkspaceFields } from "@/stores/session-store-hooks";
|
||||
|
||||
type DiffStat = NonNullable<WorkspaceDescriptor["diffStat"]>;
|
||||
|
||||
@@ -33,29 +33,14 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { AgentSessionConfig } from "@getpaseo/protocol/agent-types";
|
||||
import type { GitSetupOptions } from "@getpaseo/protocol/messages";
|
||||
import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types";
|
||||
import {
|
||||
createViewedTimelineOwnerForHost,
|
||||
fetchAgentTimeline,
|
||||
useHostRuntimeIsConnected,
|
||||
} from "@/runtime/host-runtime";
|
||||
import {
|
||||
applyAgentTurn,
|
||||
getAgentConversationSnapshot,
|
||||
getAgentSnapshot,
|
||||
getAgentStreamSnapshot,
|
||||
getAgentTimelineSnapshot,
|
||||
getSessionFocusSnapshot,
|
||||
publishAudioPlayback as setIsPlayingAudio,
|
||||
publishAgentInitialization as setInitializingAgents,
|
||||
advanceHistorySyncGeneration as bumpHistorySyncGeneration,
|
||||
flushAgentActivity as flushAgentLastActivity,
|
||||
publishPendingPermissions as setPendingPermissions,
|
||||
publishServerInfo as updateSessionServerInfo,
|
||||
publishViewedTimeline as setViewedTimelineSync,
|
||||
} from "@/runtime/session-data";
|
||||
import { getHostRuntimeStore, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { useVoiceAudioEngineOptional, useVoiceRuntimeOptional } from "@/contexts/voice-context";
|
||||
import type { AudioPlaybackSource } from "@/voice/audio-engine-types";
|
||||
import { useSessionFocus } from "@/stores/session-store-hooks";
|
||||
import {
|
||||
selectAgentTimelineState,
|
||||
useSessionStore,
|
||||
type SessionState,
|
||||
} from "@/stores/session-store";
|
||||
import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
|
||||
import { sendOsNotification } from "@/utils/os-notifications";
|
||||
import { getIsAppActivelyVisible, getIsAppVisible } from "@/utils/app-visibility";
|
||||
@@ -85,7 +70,7 @@ export type {
|
||||
ExplorerFileKind,
|
||||
ExplorerEncoding,
|
||||
AgentFileExplorerState,
|
||||
} from "@/stores/session-store-hooks";
|
||||
} from "@/stores/session-store";
|
||||
|
||||
type AudioOutputPayload = Extract<SessionOutboundMessage, { type: "audio_output" }>["payload"];
|
||||
|
||||
@@ -141,6 +126,32 @@ const findLatestAssistantMessageText = (items: StreamItem[]): string | null => {
|
||||
return null;
|
||||
};
|
||||
|
||||
const getLatestPermissionRequest = (
|
||||
session: SessionState | undefined,
|
||||
agentId: string,
|
||||
): NotificationPermissionRequest | null => {
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let latest: NotificationPermissionRequest | null = null;
|
||||
for (const pending of session.pendingPermissions.values()) {
|
||||
if (pending.agentId === agentId) {
|
||||
latest = pending.request;
|
||||
}
|
||||
}
|
||||
if (latest) {
|
||||
return latest;
|
||||
}
|
||||
|
||||
const agentPending = session.agents.get(agentId)?.pendingPermissions;
|
||||
if (agentPending && agentPending.length > 0) {
|
||||
return agentPending[agentPending.length - 1] as NotificationPermissionRequest;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
interface AgentAttentionNotificationInput {
|
||||
notification?: AgentAttentionNotificationPayload;
|
||||
reason: AgentAttentionReason;
|
||||
@@ -216,10 +227,27 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
const toast = useToast();
|
||||
|
||||
// Zustand store actions
|
||||
const setIsPlayingAudio = useSessionStore((state) => state.setIsPlayingAudio);
|
||||
const setAgentStreamTail = useSessionStore((state) => state.setAgentStreamTail);
|
||||
const setAgentStreamHead = useSessionStore((state) => state.setAgentStreamHead);
|
||||
const clearAgentStreamHead = useSessionStore((state) => state.clearAgentStreamHead);
|
||||
const setInitializingAgents = useSessionStore((state) => state.setInitializingAgents);
|
||||
const bumpHistorySyncGeneration = useSessionStore((state) => state.bumpHistorySyncGeneration);
|
||||
const setAgents = useSessionStore((state) => state.setAgents);
|
||||
const flushAgentLastActivity = useSessionStore((state) => state.flushAgentLastActivity);
|
||||
const setPendingPermissions = useSessionStore((state) => state.setPendingPermissions);
|
||||
const updateSessionServerInfo = useSessionStore((state) => state.updateSessionServerInfo);
|
||||
const setViewedTimelineSync = useSessionStore((state) => state.setViewedTimelineSync);
|
||||
const upsertWorkspaceSetupProgress = useWorkspaceSetupStore((state) => state.upsertProgress);
|
||||
|
||||
// Track focused agent for heartbeat
|
||||
const { focusedAgentId, focusedTerminalId } = useSessionFocus(serverId);
|
||||
const focusedAgentId = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.focusedAgentId ?? null,
|
||||
);
|
||||
const focusedTerminalId = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.focusedTerminalId ?? null,
|
||||
);
|
||||
const _sessionStateTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const attentionNotifiedRef = useRef<Map<string, number>>(new Map());
|
||||
const appStateRef = useRef(AppState.currentState);
|
||||
@@ -251,7 +279,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
bumpHistorySyncGeneration,
|
||||
});
|
||||
},
|
||||
[serverId],
|
||||
[bumpHistorySyncGeneration, serverId],
|
||||
);
|
||||
|
||||
// Client activity tracking (heartbeat, push token registration)
|
||||
@@ -271,7 +299,8 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
notification?: AgentAttentionNotificationPayload;
|
||||
}) => {
|
||||
const appState = appStateRef.current;
|
||||
const attentionFocusedAgentId = getSessionFocusSnapshot(serverId).focusedAgentId;
|
||||
const session = useSessionStore.getState().sessions[serverId];
|
||||
const attentionFocusedAgentId = session?.focusedAgentId ?? null;
|
||||
if (params.reason === "error") {
|
||||
return;
|
||||
}
|
||||
@@ -288,16 +317,12 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
}
|
||||
attentionNotifiedRef.current.set(params.agentId, timestampMs);
|
||||
|
||||
const { head, tail } = getAgentStreamSnapshot(serverId, params.agentId);
|
||||
const conversation = getAgentConversationSnapshot(serverId, params.agentId);
|
||||
const agent = getAgentSnapshot(serverId, params.agentId);
|
||||
const head = session?.agentStreamHead.get(params.agentId) ?? [];
|
||||
const tail = session?.agentStreamTail.get(params.agentId) ?? [];
|
||||
const assistantMessage =
|
||||
findLatestAssistantMessageText(head) ?? findLatestAssistantMessageText(tail);
|
||||
const permissionRequest =
|
||||
conversation.pendingPermissions.at(-1)?.request ??
|
||||
(agent?.pendingPermissions.at(-1) as NotificationPermissionRequest | undefined) ??
|
||||
null;
|
||||
const workspaceId = agent?.workspaceId;
|
||||
const permissionRequest = getLatestPermissionRequest(session, params.agentId);
|
||||
const workspaceId = session?.agents?.get(params.agentId)?.workspaceId;
|
||||
|
||||
const notification = resolveAgentAttentionNotification({
|
||||
notification: params.notification,
|
||||
@@ -337,7 +362,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
...(serverInfo.capabilities ? { capabilities: serverInfo.capabilities } : {}),
|
||||
...(serverInfo.features ? { features: serverInfo.features } : {}),
|
||||
});
|
||||
}, [client, serverId]);
|
||||
}, [client, serverId, updateSessionServerInfo]);
|
||||
|
||||
useEffect(() => {
|
||||
const unregister = voiceRuntime?.registerSession({
|
||||
@@ -371,7 +396,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
},
|
||||
});
|
||||
return () => unregister?.();
|
||||
}, [client, serverId, t, voiceRuntime]);
|
||||
}, [client, serverId, setIsPlayingAudio, t, voiceRuntime]);
|
||||
|
||||
useEffect(() => {
|
||||
voiceRuntime?.updateSessionConnection(serverId, isConnected);
|
||||
@@ -383,7 +408,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
flushAgentLastActivity();
|
||||
setInitializingAgents(serverId, new Map());
|
||||
}
|
||||
}, [serverId, isConnected]);
|
||||
}, [flushAgentLastActivity, serverId, isConnected, setInitializingAgents]);
|
||||
|
||||
const applyWorkspaceSetupProgress = useCallback(
|
||||
(payload: WorkspaceSetupProgressPayload) => {
|
||||
@@ -407,18 +432,22 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
const initialDeliveryMode = getTimelineDeliveryMode(
|
||||
client.getLastServerInfoMessage()?.features?.selectiveAgentTimeline,
|
||||
);
|
||||
const sync = createViewedTimelineOwnerForHost(serverId, {
|
||||
const sync = getHostRuntimeStore().createViewedTimelineOwner(serverId, {
|
||||
initialDeliveryMode,
|
||||
setSubscription: (agentIds) => client.setAgentTimelineSubscription(agentIds),
|
||||
readCursor: (agentId) => {
|
||||
const timeline = getAgentTimelineSnapshot(serverId, agentId);
|
||||
const timeline = selectAgentTimelineState(
|
||||
useSessionStore.getState().sessions[serverId],
|
||||
agentId,
|
||||
);
|
||||
return timeline.status === "synced" && timeline.range
|
||||
? { epoch: timeline.range.epoch, endSeq: timeline.range.endSeq }
|
||||
: undefined;
|
||||
},
|
||||
fetchPage: async (agentId, request) => {
|
||||
const session = useSessionStore.getState().sessions[serverId];
|
||||
const initKey = getInitKey(serverId, agentId);
|
||||
const shouldInitialize = getAgentTimelineSnapshot(serverId, agentId).status !== "synced";
|
||||
const shouldInitialize = selectAgentTimelineState(session, agentId).status !== "synced";
|
||||
if (shouldInitialize) {
|
||||
if (!getInitDeferred(initKey)) {
|
||||
const deferred = createInitDeferred(initKey, request.direction ?? "tail");
|
||||
@@ -432,7 +461,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
setAgentInitializing(agentId, true);
|
||||
}
|
||||
try {
|
||||
const page = await fetchAgentTimeline(serverId, agentId, request);
|
||||
const page = await getHostRuntimeStore().fetchAgentTimeline(serverId, agentId, request);
|
||||
if (shouldInitialize && getInitDeferred(initKey)) {
|
||||
refreshAgentInitializationTimeout({ key: initKey, agentId, setAgentInitializing });
|
||||
}
|
||||
@@ -448,7 +477,11 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
fetchLatestTail: async (agentId) => {
|
||||
forcedTimelineTailReplacements.current.add(agentId);
|
||||
try {
|
||||
return await fetchAgentTimeline(serverId, agentId, planTimelineTailFetch());
|
||||
return await getHostRuntimeStore().fetchAgentTimeline(
|
||||
serverId,
|
||||
agentId,
|
||||
planTimelineTailFetch(),
|
||||
);
|
||||
} finally {
|
||||
forcedTimelineTailReplacements.current.delete(agentId);
|
||||
}
|
||||
@@ -472,7 +505,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
setViewedTimelineSync(serverId, null);
|
||||
sync.dispose();
|
||||
};
|
||||
}, [client, serverId]);
|
||||
}, [client, serverId, setInitializingAgents, setViewedTimelineSync]);
|
||||
|
||||
useEffect(() => {
|
||||
viewedTimelineSyncRef.current?.setConnected(isConnected);
|
||||
@@ -500,7 +533,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
{ event: streamEvent, seq, epoch, timestamp: parsedTimestamp },
|
||||
]);
|
||||
if (turnLiveness.length > 0) {
|
||||
applyAgentTurn(serverId, agentId, turnLiveness);
|
||||
getHostRuntimeStore().applyAgentTurnLiveness(serverId, agentId, turnLiveness);
|
||||
}
|
||||
owner.enqueueStreamEvent(agentId, {
|
||||
event: streamEvent,
|
||||
@@ -530,7 +563,8 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
if (message.type !== "agent.timeline.replacement") return;
|
||||
void requestTimelineReplacement(
|
||||
{
|
||||
fetchAgentTimeline: (agentId, request) => fetchAgentTimeline(serverId, agentId, request),
|
||||
fetchAgentTimeline: (agentId, request) =>
|
||||
getHostRuntimeStore().fetchAgentTimeline(serverId, agentId, request),
|
||||
},
|
||||
message.payload.agentId,
|
||||
).catch((error: unknown) => {
|
||||
@@ -749,9 +783,17 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
client,
|
||||
queryClient,
|
||||
serverId,
|
||||
setIsPlayingAudio,
|
||||
setAgentStreamTail,
|
||||
setAgentStreamHead,
|
||||
clearAgentStreamHead,
|
||||
setInitializingAgents,
|
||||
setAgents,
|
||||
setPendingPermissions,
|
||||
notifyAgentAttention,
|
||||
applyWorkspaceSetupProgress,
|
||||
applyTimelineResponse,
|
||||
updateSessionServerInfo,
|
||||
toast,
|
||||
voiceRuntime,
|
||||
voiceAudioEngine,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { AgentLifecycleStatus } from "@getpaseo/protocol/agent-lifecycle";
|
||||
import type { Agent } from "@/stores/session-store-hooks";
|
||||
import type { Agent } from "@/stores/session-store";
|
||||
|
||||
export function reconcilePreviousAgentStatuses(
|
||||
previousStatuses: Map<string, AgentLifecycleStatus>,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store-hooks";
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity";
|
||||
|
||||
interface PendingWorkspaceArchive {
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake";
|
||||
import { readServerInfo } from "@/runtime/session-data";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { createAudioEngine } from "@/voice/audio-engine";
|
||||
import type { AudioEngine } from "@/voice/audio-engine-types";
|
||||
import {
|
||||
@@ -137,7 +137,8 @@ export function VoiceProvider({ children }: VoiceProviderProps) {
|
||||
|
||||
runtime = createVoiceRuntime({
|
||||
engine,
|
||||
getServerInfo: readServerInfo,
|
||||
getServerInfo: (serverId) =>
|
||||
useSessionStore.getState().getSession(serverId)?.serverInfo ?? null,
|
||||
activateKeepAwake: async (tag) => {
|
||||
await activateKeepAwakeAsync(tag);
|
||||
},
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
|
||||
import {
|
||||
normalizeWorkspaceDescriptor,
|
||||
type WorkspaceDescriptor,
|
||||
} from "@/stores/session-store-hooks";
|
||||
import { normalizeWorkspaceDescriptor, type WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { shouldSuppressWorkspaceForLocalArchive } from "./session-workspace-upserts";
|
||||
|
||||
type WorkspaceDelta = Extract<SessionOutboundMessage, { type: "workspace_update" }>["payload"];
|
||||
|
||||
@@ -17,7 +17,8 @@ import {
|
||||
} from "@/desktop/daemon/daemon-management-error";
|
||||
import { useDesktopIpcErrorReporter } from "@/desktop/hooks/desktop-ipc-error";
|
||||
import type { DesktopSettings } from "@/desktop/settings/desktop-settings";
|
||||
import { registerDesktopDaemonConnection } from "@/runtime/daemon-start-service";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { upsertDesktopDaemonConnection } from "@/runtime/daemon-start-service";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
|
||||
type DesktopDaemonSettings = DesktopSettings["daemon"];
|
||||
@@ -67,7 +68,10 @@ export function useBuiltInDaemonManagement(
|
||||
stopDaemon: () => stopDesktopDaemon("settings"),
|
||||
});
|
||||
if (result.kind === "enabled") {
|
||||
const upsertResult = await registerDesktopDaemonConnection(result.newStatus);
|
||||
const upsertResult = await upsertDesktopDaemonConnection(
|
||||
getHostRuntimeStore(),
|
||||
result.newStatus,
|
||||
);
|
||||
if (!upsertResult.ok) {
|
||||
throw new DaemonConnectionRegistrationError(upsertResult.error);
|
||||
}
|
||||
|
||||
@@ -13,8 +13,7 @@ import { ScrollView as RNScrollView, Text, View } from "react-native";
|
||||
import { StyleSheet, UnistylesRuntime, withUnistyles } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useHostRuntimeClient } from "@/runtime/host-runtime";
|
||||
import { useServerFeature, type ExplorerFile } from "@/stores/session-store-hooks";
|
||||
import { useSessionStore, type ExplorerFile } from "@/stores/session-store";
|
||||
import { filePreviewRenderKind } from "@/components/file-pane-render-mode";
|
||||
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
|
||||
import { getFileNameFromPath } from "@/attachments/utils";
|
||||
@@ -235,9 +234,11 @@ export function FilePane({
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const [previewMode, setPreviewMode] = useState<"preview" | "source">("preview");
|
||||
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
|
||||
// COMPAT(workspaceFileEditing): added in v0.2.0, remove after 2027-01-18 once daemon floor >= v0.2.0.
|
||||
const supportsEditing = useServerFeature(serverId, "workspaceFileEditing");
|
||||
const supportsEditing = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.serverInfo?.features?.workspaceFileEditing === true,
|
||||
);
|
||||
const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]);
|
||||
const normalizedFilePath = useMemo(() => trimNonEmpty(location.path), [location.path]);
|
||||
const readTarget = useMemo(
|
||||
|
||||
@@ -7,12 +7,6 @@ import {
|
||||
useCheckoutGitActionsStore,
|
||||
} from "@/git/actions-store";
|
||||
|
||||
const hostClients = vi.hoisted(() => new Map<string, DaemonClient>());
|
||||
|
||||
vi.mock("@/runtime/host-runtime", () => ({
|
||||
getHostClient: (serverId: string) => hostClients.get(serverId) ?? null,
|
||||
}));
|
||||
|
||||
vi.mock("@react-native-async-storage/async-storage", () => ({
|
||||
default: {
|
||||
getItem: vi.fn(async () => null),
|
||||
@@ -39,7 +33,6 @@ describe("checkout-git-actions-store", () => {
|
||||
vi.useFakeTimers();
|
||||
__resetCheckoutGitActionsStoreForTests();
|
||||
appQueryClient.clear();
|
||||
hostClients.clear();
|
||||
useSessionStore.setState((state) => ({ ...state, sessions: {} }));
|
||||
});
|
||||
|
||||
@@ -47,7 +40,6 @@ describe("checkout-git-actions-store", () => {
|
||||
vi.useRealTimers();
|
||||
__resetCheckoutGitActionsStoreForTests();
|
||||
appQueryClient.clear();
|
||||
hostClients.clear();
|
||||
useSessionStore.setState((state) => ({ ...state, sessions: {} }));
|
||||
});
|
||||
|
||||
@@ -57,7 +49,13 @@ describe("checkout-git-actions-store", () => {
|
||||
checkoutCommit: vi.fn(() => deferred.promise),
|
||||
};
|
||||
|
||||
hostClients.set(serverId, client as unknown as DaemonClient);
|
||||
useSessionStore.setState((state) => ({
|
||||
...state,
|
||||
sessions: {
|
||||
...state.sessions,
|
||||
[serverId]: { client } as unknown as (typeof state.sessions)[string],
|
||||
},
|
||||
}));
|
||||
|
||||
const store = useCheckoutGitActionsStore.getState();
|
||||
|
||||
@@ -87,7 +85,13 @@ describe("checkout-git-actions-store", () => {
|
||||
return {};
|
||||
}),
|
||||
};
|
||||
hostClients.set(serverId, client as unknown as DaemonClient);
|
||||
useSessionStore.setState((state) => ({
|
||||
...state,
|
||||
sessions: {
|
||||
...state.sessions,
|
||||
[serverId]: { client } as unknown as (typeof state.sessions)[string],
|
||||
},
|
||||
}));
|
||||
|
||||
await useCheckoutGitActionsStore.getState().pullAndPush({ serverId, cwd });
|
||||
|
||||
@@ -102,7 +106,13 @@ describe("checkout-git-actions-store", () => {
|
||||
checkoutPull: vi.fn(async () => ({ error: { message: "pull conflict" } })),
|
||||
checkoutPush: vi.fn(async () => ({})),
|
||||
};
|
||||
hostClients.set(serverId, client as unknown as DaemonClient);
|
||||
useSessionStore.setState((state) => ({
|
||||
...state,
|
||||
sessions: {
|
||||
...state.sessions,
|
||||
[serverId]: { client } as unknown as (typeof state.sessions)[string],
|
||||
},
|
||||
}));
|
||||
|
||||
await expect(
|
||||
useCheckoutGitActionsStore.getState().pullAndPush({ serverId, cwd }),
|
||||
@@ -117,7 +127,13 @@ describe("checkout-git-actions-store", () => {
|
||||
checkoutPull: vi.fn(async () => ({})),
|
||||
checkoutPush: vi.fn(async () => ({ error: { message: "push rejected" } })),
|
||||
};
|
||||
hostClients.set(serverId, client as unknown as DaemonClient);
|
||||
useSessionStore.setState((state) => ({
|
||||
...state,
|
||||
sessions: {
|
||||
...state.sessions,
|
||||
[serverId]: { client } as unknown as (typeof state.sessions)[string],
|
||||
},
|
||||
}));
|
||||
|
||||
await expect(
|
||||
useCheckoutGitActionsStore.getState().pullAndPush({ serverId, cwd }),
|
||||
@@ -131,7 +147,13 @@ describe("checkout-git-actions-store", () => {
|
||||
const client = {
|
||||
checkoutRefresh: vi.fn(async () => ({ success: true, error: null })),
|
||||
};
|
||||
hostClients.set(serverId, client as unknown as DaemonClient);
|
||||
useSessionStore.setState((state) => ({
|
||||
...state,
|
||||
sessions: {
|
||||
...state.sessions,
|
||||
[serverId]: { client } as unknown as (typeof state.sessions)[string],
|
||||
},
|
||||
}));
|
||||
|
||||
await useCheckoutGitActionsStore.getState().refresh({ serverId, cwd });
|
||||
|
||||
@@ -145,7 +167,13 @@ describe("checkout-git-actions-store", () => {
|
||||
const client = {
|
||||
checkoutRefresh: vi.fn(async () => ({ error: { message: "not a git repository" } })),
|
||||
};
|
||||
hostClients.set(serverId, client as unknown as DaemonClient);
|
||||
useSessionStore.setState((state) => ({
|
||||
...state,
|
||||
sessions: {
|
||||
...state.sessions,
|
||||
[serverId]: { client } as unknown as (typeof state.sessions)[string],
|
||||
},
|
||||
}));
|
||||
|
||||
await expect(useCheckoutGitActionsStore.getState().refresh({ serverId, cwd })).rejects.toThrow(
|
||||
"not a git repository",
|
||||
@@ -158,7 +186,13 @@ describe("checkout-git-actions-store", () => {
|
||||
it("discards selected paths through the shared checkout action workflow", async () => {
|
||||
const checkoutDiscardChanges = vi.fn(async () => ({ success: true, error: null }));
|
||||
const client = { checkoutDiscardChanges };
|
||||
hostClients.set(serverId, client as unknown as DaemonClient);
|
||||
useSessionStore.setState((state) => ({
|
||||
...state,
|
||||
sessions: {
|
||||
...state.sessions,
|
||||
[serverId]: { client } as unknown as (typeof state.sessions)[string],
|
||||
},
|
||||
}));
|
||||
|
||||
await useCheckoutGitActionsStore
|
||||
.getState()
|
||||
@@ -193,7 +227,6 @@ describe("checkout-git-actions-store", () => {
|
||||
error: null,
|
||||
}));
|
||||
const client = { [rpc.method]: setAutoMerge };
|
||||
hostClients.set(serverId, client as unknown as DaemonClient);
|
||||
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient);
|
||||
useSessionStore.getState().updateSessionServerInfo(serverId, {
|
||||
serverId,
|
||||
@@ -224,7 +257,6 @@ describe("checkout-git-actions-store", () => {
|
||||
error: null,
|
||||
}));
|
||||
const client = { [rpc.method]: setAutoMerge };
|
||||
hostClients.set(serverId, client as unknown as DaemonClient);
|
||||
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient);
|
||||
useSessionStore.getState().updateSessionServerInfo(serverId, {
|
||||
serverId,
|
||||
@@ -252,7 +284,6 @@ describe("checkout-git-actions-store", () => {
|
||||
error: null,
|
||||
})),
|
||||
};
|
||||
hostClients.set(serverId, client as unknown as DaemonClient);
|
||||
useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient);
|
||||
useSessionStore.getState().updateSessionServerInfo(serverId, {
|
||||
serverId,
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import type { CheckoutPrMergeMethod } from "@getpaseo/protocol/messages";
|
||||
import { create } from "zustand";
|
||||
import { queryClient as appQueryClient } from "@/data/query-client";
|
||||
import { getHostClient } from "@/runtime/host-runtime";
|
||||
import { hostSupports } from "@/runtime/session-data";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { invalidateCheckoutGitQueriesForClient } from "@/git/query-keys";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
|
||||
@@ -36,7 +35,8 @@ function checkoutKey(serverId: string, cwd: string): CheckoutKey {
|
||||
}
|
||||
|
||||
function resolveClient(serverId: string) {
|
||||
const client = getHostClient(serverId);
|
||||
const session = useSessionStore.getState().sessions[serverId];
|
||||
const client = session?.client ?? null;
|
||||
if (!client) {
|
||||
throw new Error(i18n.t("common.errors.daemonClientUnavailable"));
|
||||
}
|
||||
@@ -46,13 +46,14 @@ function resolveClient(serverId: string) {
|
||||
type AutoMergeActionsRpc = "forge" | "github";
|
||||
|
||||
function resolveAutoMergeActionsRpc(serverId: string): AutoMergeActionsRpc {
|
||||
if (hostSupports(serverId, "checkoutForgeSetAutoMerge")) {
|
||||
const session = useSessionStore.getState().sessions[serverId];
|
||||
if (session?.serverInfo?.features?.checkoutForgeSetAutoMerge === true) {
|
||||
return "forge";
|
||||
}
|
||||
// COMPAT(githubAutoMergeRpc): use the legacy GitHub RPC with daemons that
|
||||
// predate checkout.forge.set_auto_merge.*. Remove after 2027-01-17 once the
|
||||
// supported daemon floor is >= v0.2.0.
|
||||
if (hostSupports(serverId, "checkoutGithubSetAutoMerge")) {
|
||||
if (session?.serverInfo?.features?.checkoutGithubSetAutoMerge === true) {
|
||||
return "github";
|
||||
}
|
||||
throw new Error("Update the host to use auto-merge actions.");
|
||||
|
||||
@@ -72,8 +72,7 @@ import { parseGitRemoteLocation } from "@getpaseo/protocol/git-remote";
|
||||
import type { ForgeAuthState } from "@getpaseo/protocol/messages";
|
||||
import { useCheckoutGitActionsStore } from "@/git/actions-store";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useHostRuntimeClient } from "@/runtime/host-runtime";
|
||||
import { useServerFeature } from "@/stores/session-store-hooks";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -144,7 +143,9 @@ function useDiscardChangesAction({
|
||||
const toast = useToast();
|
||||
const discardChanges = useCheckoutGitActionsStore((state) => state.discardChanges);
|
||||
// COMPAT(checkoutDiscardChanges): added in v0.3.0, remove gate after 2027-02-08.
|
||||
const discardSupported = useServerFeature(serverId, "checkoutDiscardChanges");
|
||||
const discardSupported = useSessionStore(
|
||||
(s) => s.sessions[serverId]?.serverInfo?.features?.checkoutDiscardChanges === true,
|
||||
);
|
||||
const discardPath = useCallback(
|
||||
async (path: string, oldPath?: string) => {
|
||||
const confirmed = await confirmDialog({
|
||||
@@ -1631,10 +1632,14 @@ export function ChangesSurface({
|
||||
isMobile,
|
||||
pullRequestOpenLocation: appSettings.pullRequestOpenLocation,
|
||||
});
|
||||
const refreshSupported = useServerFeature(serverId, "checkoutRefresh");
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const refreshSupported = useSessionStore(
|
||||
(s) => s.sessions[serverId]?.serverInfo?.features?.checkoutRefresh === true,
|
||||
);
|
||||
const client = useSessionStore((state) => state.sessions[serverId]?.client);
|
||||
// COMPAT(fsEntryDuplicate): added in v0.3.0, remove gate after 2027-02-09.
|
||||
const fsEntryDuplicateEnabled = useServerFeature(serverId, "fsEntryDuplicate");
|
||||
const fsEntryDuplicateEnabled = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.serverInfo?.features?.fsEntryDuplicate === true,
|
||||
);
|
||||
const runRefresh = useCheckoutGitActionsStore((s) => s.refresh);
|
||||
const isRefreshing =
|
||||
useCheckoutGitActionsStore((s) => s.getStatus({ serverId, cwd, actionId: "refresh" })) ===
|
||||
@@ -1691,7 +1696,9 @@ export function ChangesSurface({
|
||||
cwd,
|
||||
enabled: isGit,
|
||||
});
|
||||
const forgeProvidersSupported = useServerFeature(serverId, "forgeProviders");
|
||||
const forgeProvidersSupported = useSessionStore(
|
||||
(s) => s.sessions[serverId]?.serverInfo?.features?.forgeProviders === true,
|
||||
);
|
||||
const forgeSetupAction = computeForgeSetupAction({
|
||||
forge,
|
||||
forgeProvidersSupported,
|
||||
|
||||
@@ -31,7 +31,7 @@ import { MarkdownRenderer } from "@/components/markdown/renderer";
|
||||
import { getDefaultMarkdownClipboardEnvironment } from "@/utils/rich-clipboard-default-environment";
|
||||
import { writeMarkdownToRichClipboard } from "@/utils/rich-clipboard";
|
||||
import { useHostRuntimeClient } from "@/runtime/host-runtime";
|
||||
import { useServerFeature } from "@/stores/session-store-hooks";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useWorkspaceAttachmentsStore } from "@/attachments/workspace-attachments-store";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useCheckoutGitActionsStore } from "@/git/actions-store";
|
||||
@@ -209,9 +209,15 @@ export function PullRequestPane({
|
||||
// COMPAT(githubCheckDetailsRpc): recognize the legacy capability on daemons
|
||||
// predating checkout.forge.get_check_details.*. Remove after 2027-01-17 once
|
||||
// the supported daemon floor is >= v0.2.0.
|
||||
const canFetchGitHubCheckDetails = useServerFeature(serverId, "githubCheckDetails");
|
||||
const canFetchForgeCheckDetails = useServerFeature(serverId, "forgeCheckDetails");
|
||||
const forgeProvidersEnabled = useServerFeature(serverId, "forgeProviders");
|
||||
const canFetchGitHubCheckDetails = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.serverInfo?.features?.githubCheckDetails === true,
|
||||
);
|
||||
const canFetchForgeCheckDetails = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.serverInfo?.features?.forgeCheckDetails === true,
|
||||
);
|
||||
const forgeProvidersEnabled = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.serverInfo?.features?.forgeProviders === true,
|
||||
);
|
||||
const addWorkspaceAttachment = useWorkspaceAttachmentsStore(
|
||||
(state) => state.addWorkspaceAttachment,
|
||||
);
|
||||
@@ -224,7 +230,9 @@ export function PullRequestPane({
|
||||
void openExternalUrl(data.url);
|
||||
}, [data.url]);
|
||||
|
||||
const refreshSupported = useServerFeature(serverId, "checkoutRefresh");
|
||||
const refreshSupported = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.serverInfo?.features?.checkoutRefresh === true,
|
||||
);
|
||||
const runRefresh = useCheckoutGitActionsStore((state) => state.refresh);
|
||||
const isRefreshing =
|
||||
useCheckoutGitActionsStore((state) =>
|
||||
|
||||
@@ -21,12 +21,7 @@ import { deriveMergeCapability } from "@/git/merge-capability";
|
||||
import type { CheckoutPrMergeMethod } from "@getpaseo/protocol/messages";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import {
|
||||
useServerFeature,
|
||||
useWorkspaceByDirectoryFields,
|
||||
useWorkspaceFields,
|
||||
type WorkspaceDescriptor,
|
||||
} from "@/stores/session-store-hooks";
|
||||
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import {
|
||||
useActiveWorkspaceSelection,
|
||||
type ActiveWorkspaceSelection,
|
||||
@@ -34,6 +29,7 @@ import {
|
||||
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
|
||||
import { type WorktreeArchiveWarningLabels } from "@/git/worktree-archive-warning";
|
||||
import { useWorkspaceArchive } from "@/workspace/use-workspace-archive";
|
||||
import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity";
|
||||
import { readValidatedString } from "@/storage/validated-storage";
|
||||
|
||||
export type { GitActionId, GitAction, GitActions } from "@/git/policy";
|
||||
@@ -220,13 +216,33 @@ interface UseWorkspaceScreenArchiveControllerInput {
|
||||
t: (key: string, options?: Record<string, unknown>) => string;
|
||||
}
|
||||
|
||||
type ArchiveWorkspaceProjection = Pick<
|
||||
WorkspaceDescriptor,
|
||||
"id" | "workspaceKind" | "name" | "diffStat" | "archivingAt" | "gitRuntime"
|
||||
>;
|
||||
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;
|
||||
}
|
||||
|
||||
function resolveWorkspaceArchiveRisk(
|
||||
workspace: ArchiveWorkspaceProjection | null,
|
||||
workspace: WorkspaceDescriptor | null,
|
||||
gitStatus: CheckoutStatusPayload | null,
|
||||
): { isDirty: boolean | null | undefined; aheadOfOrigin: number | null | undefined } {
|
||||
return {
|
||||
@@ -236,7 +252,7 @@ function resolveWorkspaceArchiveRisk(
|
||||
}
|
||||
|
||||
function canArchiveWorkspace(
|
||||
workspace: ArchiveWorkspaceProjection | null,
|
||||
workspace: WorkspaceDescriptor | null,
|
||||
risk: ReturnType<typeof resolveWorkspaceArchiveRisk>,
|
||||
): boolean {
|
||||
return (
|
||||
@@ -254,29 +270,17 @@ function useWorkspaceScreenArchiveController({
|
||||
gitStatus,
|
||||
t,
|
||||
}: UseWorkspaceScreenArchiveControllerInput) {
|
||||
const sessionWorkspaces = useSessionStore((state) => state.sessions[serverId]?.workspaces);
|
||||
const [isHidingWorkspace, setIsHidingWorkspace] = useState(false);
|
||||
const projectArchiveFields = useCallback(
|
||||
(workspace: WorkspaceDescriptor) => ({
|
||||
id: workspace.id,
|
||||
workspaceKind: workspace.workspaceKind,
|
||||
name: workspace.name,
|
||||
diffStat: workspace.diffStat,
|
||||
archivingAt: workspace.archivingAt,
|
||||
gitRuntime: workspace.gitRuntime,
|
||||
}),
|
||||
[],
|
||||
const workspaceDescriptor = useMemo(
|
||||
() =>
|
||||
resolveArchiveWorkspaceDescriptor({
|
||||
workspaces: sessionWorkspaces,
|
||||
activeWorkspaceSelection,
|
||||
workspaceDirectory,
|
||||
}),
|
||||
[activeWorkspaceSelection, sessionWorkspaces, workspaceDirectory],
|
||||
);
|
||||
const selectedWorkspace = useWorkspaceFields(
|
||||
serverId,
|
||||
activeWorkspaceSelection?.workspaceId ?? null,
|
||||
projectArchiveFields,
|
||||
);
|
||||
const directoryWorkspace = useWorkspaceByDirectoryFields(
|
||||
serverId,
|
||||
workspaceDirectory,
|
||||
projectArchiveFields,
|
||||
);
|
||||
const workspaceDescriptor = selectedWorkspace ?? directoryWorkspace;
|
||||
const archiveRisk = resolveWorkspaceArchiveRisk(workspaceDescriptor, gitStatus);
|
||||
|
||||
const controller = useWorkspaceArchive({
|
||||
@@ -449,9 +453,11 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
||||
const runDisablePrAutoMerge = useCheckoutGitActionsStore((s) => s.disablePrAutoMerge);
|
||||
const runMergeBranch = useCheckoutGitActionsStore((s) => s.mergeBranch);
|
||||
const runMergeFromBase = useCheckoutGitActionsStore((s) => s.mergeFromBase);
|
||||
const supportsForgeAutoMerge = useServerFeature(serverId, "checkoutForgeSetAutoMerge");
|
||||
const supportsGithubAutoMerge = useServerFeature(serverId, "checkoutGithubSetAutoMerge");
|
||||
const githubAutoMergeActionsEnabled = supportsForgeAutoMerge || supportsGithubAutoMerge;
|
||||
const githubAutoMergeActionsEnabled = useSessionStore(
|
||||
(s) =>
|
||||
s.sessions[serverId]?.serverInfo?.features?.checkoutForgeSetAutoMerge === true ||
|
||||
s.sessions[serverId]?.serverInfo?.features?.checkoutGithubSetAutoMerge === true,
|
||||
);
|
||||
|
||||
const toastActionError = useCallback(
|
||||
(error: unknown, fallback: string) => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { useFetchQuery } from "@/data/query";
|
||||
import { checkoutCommitsQueryKey } from "@/git/query-keys";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { useServerFeature } from "@/stores/session-store-hooks";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
|
||||
// Commits ahead of base change rarely while the section is open; this keeps a
|
||||
// collapse/re-expand cycle warm without leaving the fetch result stale for long.
|
||||
@@ -80,9 +80,11 @@ export function useCheckoutCommitsQuery({
|
||||
// COMPAT(commitsList): added in v0.1.110, remove after 2027-01-16.
|
||||
// COMPAT(commitBaseClassification): added in v0.2.0, remove after 2027-01-23.
|
||||
// Single capability-detection site; downstream reads a clean load-state union.
|
||||
const supportsCommitsList = useServerFeature(serverId, "commitsList");
|
||||
const supportsBaseClassification = useServerFeature(serverId, "commitBaseClassification");
|
||||
const capabilityPresent = supportsCommitsList && supportsBaseClassification;
|
||||
const capabilityPresent = useSessionStore(
|
||||
(state) =>
|
||||
state.sessions[serverId]?.serverInfo?.features?.commitsList === true &&
|
||||
state.sessions[serverId]?.serverInfo?.features?.commitBaseClassification === true,
|
||||
);
|
||||
|
||||
const canFetch = Boolean(cwd) && Boolean(client) && isConnected;
|
||||
const queryEnabled = queryEnabledByCaller && capabilityPresent && canFetch;
|
||||
|
||||
@@ -4,7 +4,7 @@ import type {
|
||||
ProjectAddResponse,
|
||||
WorkspaceProjectDescriptorPayload,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import { normalizeProjectDescriptor, type ProjectDescriptor } from "@/stores/session-store-hooks";
|
||||
import { normalizeProjectDescriptor, type ProjectDescriptor } from "@/stores/session-store";
|
||||
|
||||
type OpenProjectPayload = ProjectAddResponse["payload"];
|
||||
type OpenProjectErrorCode = NonNullable<OpenProjectPayload["errorCode"]>;
|
||||
|
||||
@@ -692,7 +692,6 @@ function agent(input: {
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
status: Agent["status"];
|
||||
turn?: Agent["turn"];
|
||||
updatedAt?: Date;
|
||||
parentAgentId?: string | null;
|
||||
archivedAt?: Date | null;
|
||||
@@ -702,7 +701,10 @@ function agent(input: {
|
||||
id: input.id,
|
||||
provider: "claude" as Agent["provider"],
|
||||
status: input.status,
|
||||
turn: input.turn ?? { phase: "idle", cancellationRequestId: null },
|
||||
turn:
|
||||
input.status === "running"
|
||||
? { phase: "open", turnId: null, startedAt: null, cancellationRequestId: null }
|
||||
: { phase: "idle", cancellationRequestId: null },
|
||||
createdAt: new Date(0),
|
||||
updatedAt: input.updatedAt ?? new Date(1_000),
|
||||
lastUserMessageAt: null,
|
||||
@@ -892,19 +894,7 @@ describe("deriveProjectStatusBucket", () => {
|
||||
sessions: {
|
||||
srv: sessionWith({
|
||||
workspaces: [projectWorkspace("ws-1", "done")],
|
||||
agents: [
|
||||
agent({
|
||||
id: "a1",
|
||||
workspaceId: "ws-1",
|
||||
status: "running",
|
||||
turn: {
|
||||
phase: "open",
|
||||
turnId: "turn-1",
|
||||
startedAt: new Date(1_000),
|
||||
cancellationRequestId: null,
|
||||
},
|
||||
}),
|
||||
],
|
||||
agents: [agent({ id: "a1", workspaceId: "ws-1", status: "running" })],
|
||||
}),
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { PrHint } from "@/git/pr-hint";
|
||||
import { selectPrHintFromStatus } from "@/git/pr-hint";
|
||||
import { type HostProjectListItem } from "@/projects/host-project-model";
|
||||
import type { PendingCreateAttempt } from "@/stores/create-flow-store";
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store-hooks";
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import type {
|
||||
WorkspaceStructureHostPlacement,
|
||||
WorkspaceStructureProject,
|
||||
@@ -71,13 +71,13 @@ export interface SidebarWorkspacePlacementModel {
|
||||
|
||||
export interface SidebarWorkspaceSession {
|
||||
serverId: string;
|
||||
workspaces: ReadonlyMap<string, WorkspaceDescriptor>;
|
||||
workspaceAgentActivity: ReadonlyMap<string, WorkspaceAgentActivity>;
|
||||
workspaces: Map<string, WorkspaceDescriptor>;
|
||||
workspaceAgentActivity: Map<string, WorkspaceAgentActivity>;
|
||||
}
|
||||
|
||||
interface SidebarWorkspaceSessionSource {
|
||||
workspaces: ReadonlyMap<string, WorkspaceDescriptor>;
|
||||
workspaceAgentActivity: ReadonlyMap<string, WorkspaceAgentActivity>;
|
||||
workspaces: Map<string, WorkspaceDescriptor>;
|
||||
workspaceAgentActivity: Map<string, WorkspaceAgentActivity>;
|
||||
}
|
||||
|
||||
export function selectSidebarWorkspaceSessions(
|
||||
@@ -232,8 +232,8 @@ function getPendingInitialAgentCreateStartedAt(input: {
|
||||
}
|
||||
|
||||
export interface ProjectStatusSession {
|
||||
workspaces: ReadonlyMap<string, WorkspaceDescriptor>;
|
||||
workspaceAgentActivity: ReadonlyMap<string, WorkspaceAgentActivity>;
|
||||
workspaces: Map<string, WorkspaceDescriptor>;
|
||||
workspaceAgentActivity: Map<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 { useActiveAgentFields } from "@/stores/session-store-hooks";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { CLIENT_SLASH_COMMANDS, type ClientSlashCommand } from "@/client-slash-commands";
|
||||
import type { PluginClientSlashCommand } from "@/plugins/client-slash-commands";
|
||||
@@ -389,7 +389,9 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
|
||||
const queryDraftConfig = normalizedDraftConfig;
|
||||
const canLoadCommands = resolveCanLoadCommands({ serverId, agentId, isDraftContext });
|
||||
|
||||
const agentCwd = useActiveAgentFields(serverId, agentId, (agent) => agent.cwd, Object.is) ?? "";
|
||||
const agentCwd = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.agents?.get(agentId)?.cwd ?? "",
|
||||
);
|
||||
const autocompleteCwd = useMemo(() => {
|
||||
if (isDraftContext) {
|
||||
return queryDraftConfig?.cwd ?? "";
|
||||
|
||||
@@ -5,17 +5,11 @@ import type {
|
||||
} from "@getpaseo/client/internal/daemon-client";
|
||||
import type { AgentSearchMatch } from "@getpaseo/protocol/messages";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useCallback, useMemo, useSyncExternalStore } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||
import {
|
||||
getHostClient,
|
||||
isHostRuntimeConnected,
|
||||
readHostRuntimeSnapshot,
|
||||
useHostRuntimeVersion,
|
||||
useHosts,
|
||||
} from "@/runtime/host-runtime";
|
||||
import { useConnections } from "@/stores/session-store-hooks";
|
||||
import { getHostRuntimeStore, isHostRuntimeConnected, useHosts } from "@/runtime/host-runtime";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { buildAgentDirectoryState } from "@/utils/agent-directory-sync";
|
||||
import { agentHistoryQueryKey, allAgentHistoryQueryKey } from "./agent-history-query-key";
|
||||
|
||||
@@ -305,7 +299,12 @@ export function useAgentHistory(options: {
|
||||
}): AgentHistoryResult {
|
||||
const { t } = useTranslation();
|
||||
const daemons = useHosts();
|
||||
const runtimeVersion = useHostRuntimeVersion();
|
||||
const runtime = getHostRuntimeStore();
|
||||
const runtimeVersion = useSyncExternalStore(
|
||||
(onStoreChange) => runtime.subscribeAll(onStoreChange),
|
||||
() => runtime.getVersion(),
|
||||
() => runtime.getVersion(),
|
||||
);
|
||||
const serverId = useMemo(() => {
|
||||
const value = options.serverId;
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
@@ -323,8 +322,8 @@ export function useAgentHistory(options: {
|
||||
const unreachable: AgentHistoryHostError[] = [];
|
||||
|
||||
for (const targetServerId of serverIds) {
|
||||
const snapshot = readHostRuntimeSnapshot(targetServerId);
|
||||
const client = getHostClient(targetServerId);
|
||||
const snapshot = runtime.getSnapshot(targetServerId);
|
||||
const client = runtime.getClient(targetServerId);
|
||||
const serverName = serverLabelById.get(targetServerId) ?? targetServerId;
|
||||
if (!client || !isHostRuntimeConnected(snapshot)) {
|
||||
unreachable.push({ serverId: targetServerId, serverName });
|
||||
@@ -334,18 +333,17 @@ export function useAgentHistory(options: {
|
||||
}
|
||||
|
||||
return { targetHosts: hosts, unreachableHosts: unreachable };
|
||||
}, [daemons, runtimeVersion, serverId]);
|
||||
}, [daemons, runtime, runtimeVersion, serverId]);
|
||||
const targetServerIds = useMemo(() => targetHosts.map((host) => host.serverId), [targetHosts]);
|
||||
// One gate, checked before the field is offered: a fleet where any host
|
||||
// predates search has no search, rather than a list that silently omits that
|
||||
// host's sessions.
|
||||
const isSearchSupported = useConnections(
|
||||
targetServerIds,
|
||||
const isSearchSupported = useSessionStore(
|
||||
useCallback(
|
||||
(connections) =>
|
||||
(state) =>
|
||||
targetServerIds.length > 0 &&
|
||||
targetServerIds.every(
|
||||
(id) => connections.get(id)?.serverInfo?.features?.agentHistorySearch === true,
|
||||
(id) => state.sessions[id]?.serverInfo?.features?.agentHistorySearch === true,
|
||||
),
|
||||
[targetServerIds],
|
||||
),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { getAgentTimelineSnapshot, publishAgentInitialization } from "@/runtime/session-data";
|
||||
import { selectAgentTimelineState, useSessionStore } from "@/stores/session-store";
|
||||
import {
|
||||
createInitDeferred,
|
||||
getInitDeferred,
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
rejectInitDeferred,
|
||||
refreshInitTimeout,
|
||||
} from "@/utils/agent-initialization";
|
||||
import { agentTimelineRuntime } from "@/runtime/host-runtime";
|
||||
import { getHostRuntimeStore, type HostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { planTimelineResumeFetch, planTimelineTailFetch } from "@/timeline/timeline-sync-plan";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
|
||||
@@ -38,7 +38,7 @@ export interface EnsureAgentIsInitializedInput {
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
client: Pick<DaemonClient, "fetchAgentTimeline"> | null;
|
||||
runtime: typeof agentTimelineRuntime;
|
||||
runtime: Pick<HostRuntimeStore, "fetchAgentTimeline">;
|
||||
setAgentInitializing: SetAgentInitializing;
|
||||
hostDisconnectedMessage?: string;
|
||||
}
|
||||
@@ -51,7 +51,7 @@ export function ensureAgentIsInitialized(input: EnsureAgentIsInitializedInput):
|
||||
return existing.promise;
|
||||
}
|
||||
|
||||
const timeline = getAgentTimelineSnapshot(serverId, agentId);
|
||||
const timeline = selectAgentTimelineState(useSessionStore.getState().sessions[serverId], agentId);
|
||||
const timelineRequest = planTimelineResumeFetch(
|
||||
timeline.status === "synced" ? timeline.range : null,
|
||||
);
|
||||
@@ -82,7 +82,7 @@ export interface RefreshAgentInput {
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
client: Pick<DaemonClient, "refreshAgent"> | null;
|
||||
runtime: typeof agentTimelineRuntime;
|
||||
runtime: Pick<HostRuntimeStore, "fetchAgentTimeline">;
|
||||
setAgentInitializing: SetAgentInitializing;
|
||||
hostDisconnectedMessage?: string;
|
||||
}
|
||||
@@ -105,7 +105,7 @@ export async function refreshAgent(input: RefreshAgentInput): Promise<void> {
|
||||
|
||||
export function createSetAgentInitializing(
|
||||
serverId: string,
|
||||
setInitializingAgents: typeof publishAgentInitialization,
|
||||
setInitializingAgents: ReturnType<typeof useSessionStore.getState>["setInitializingAgents"],
|
||||
): SetAgentInitializing {
|
||||
return (agentId, initializing) => {
|
||||
setInitializingAgents(serverId, (prev) => {
|
||||
@@ -127,9 +127,10 @@ export function useAgentInitialization({
|
||||
client: DaemonClient | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const setInitializingAgents = useSessionStore((state) => state.setInitializingAgents);
|
||||
const setAgentInitializing = useMemo(
|
||||
() => createSetAgentInitializing(serverId, publishAgentInitialization),
|
||||
[serverId],
|
||||
() => createSetAgentInitializing(serverId, setInitializingAgents),
|
||||
[serverId, setInitializingAgents],
|
||||
);
|
||||
|
||||
const ensureAgentIsInitializedCallback = useCallback(
|
||||
@@ -138,7 +139,7 @@ export function useAgentInitialization({
|
||||
serverId,
|
||||
agentId,
|
||||
client,
|
||||
runtime: agentTimelineRuntime,
|
||||
runtime: getHostRuntimeStore(),
|
||||
setAgentInitializing,
|
||||
hostDisconnectedMessage: t("workspace.terminal.hostDisconnected"),
|
||||
}),
|
||||
@@ -151,7 +152,7 @@ export function useAgentInitialization({
|
||||
serverId,
|
||||
agentId,
|
||||
client,
|
||||
runtime: agentTimelineRuntime,
|
||||
runtime: getHostRuntimeStore(),
|
||||
setAgentInitializing,
|
||||
hostDisconnectedMessage: t("workspace.terminal.hostDisconnected"),
|
||||
}),
|
||||
|
||||
@@ -17,12 +17,7 @@ function createAgent(id: string): Agent {
|
||||
id,
|
||||
provider: "claude",
|
||||
status: "running",
|
||||
turn: {
|
||||
phase: "open",
|
||||
turnId: "turn-1",
|
||||
startedAt: now,
|
||||
cancellationRequestId: null,
|
||||
},
|
||||
turn: { phase: "open", turnId: "turn-1", startedAt: now, cancellationRequestId: null },
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
lastUserMessageAt: now,
|
||||
|
||||
@@ -1,38 +1,16 @@
|
||||
import { useMemo, useCallback, useEffect, useRef } from "react";
|
||||
import { useMemo, useCallback, useEffect, useRef, useSyncExternalStore } from "react";
|
||||
import equal from "fast-deep-equal";
|
||||
import { useAgentDirectories } from "@/stores/session-store-hooks";
|
||||
import type { Agent } from "@/stores/session-store-hooks";
|
||||
import { useShallow } from "zustand/shallow";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import type { AgentDirectoryEntry } from "@/types/agent-directory";
|
||||
import {
|
||||
acquireDirectoryDemand,
|
||||
readHostRuntimeSnapshot,
|
||||
refreshHostDirectories,
|
||||
useHostRuntimeVersion,
|
||||
useHosts,
|
||||
} from "@/runtime/host-runtime";
|
||||
import type { Agent } from "@/stores/session-store";
|
||||
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
|
||||
|
||||
export interface AggregatedAgent extends AgentDirectoryEntry {
|
||||
serverId: string;
|
||||
serverLabel: string;
|
||||
}
|
||||
|
||||
interface AgentSource {
|
||||
serverId: string;
|
||||
agents: ReadonlyMap<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;
|
||||
@@ -46,30 +24,35 @@ export function useAggregatedAgents(options?: {
|
||||
demand?: boolean;
|
||||
}): AggregatedAgentsResult {
|
||||
const daemons = useHosts();
|
||||
const runtime = getHostRuntimeStore();
|
||||
const includeArchived = options?.includeArchived ?? false;
|
||||
const demand = options?.demand ?? true;
|
||||
const serverIds = useMemo(() => daemons.map((daemon) => daemon.serverId), [daemons]);
|
||||
useEffect(() => {
|
||||
if (!demand) return;
|
||||
const releases = serverIds.map(acquireDirectoryDemand);
|
||||
const releases = serverIds.map((serverId) => runtime.acquireDirectoryDemand(serverId));
|
||||
return () => releases.forEach((release) => release());
|
||||
}, [demand, serverIds]);
|
||||
const runtimeVersion = useHostRuntimeVersion();
|
||||
}, [demand, runtime, serverIds]);
|
||||
const runtimeVersion = useSyncExternalStore(
|
||||
(onStoreChange) => runtime.subscribeAll(onStoreChange),
|
||||
() => runtime.getVersion(),
|
||||
() => runtime.getVersion(),
|
||||
);
|
||||
|
||||
const sessionAgents = useAgentDirectories(
|
||||
serverIds,
|
||||
(directories) =>
|
||||
serverIds.map((serverId) => ({
|
||||
serverId,
|
||||
agents: directories.get(serverId)?.agents ?? EMPTY_AGENT_MAP,
|
||||
})),
|
||||
agentSourcesEqual,
|
||||
const sessionAgents = useSessionStore(
|
||||
useShallow((state) => {
|
||||
const result: Record<string, Map<string, Agent> | undefined> = {};
|
||||
for (const [serverId, session] of Object.entries(state.sessions)) {
|
||||
result[serverId] = session.agents;
|
||||
}
|
||||
return result;
|
||||
}),
|
||||
);
|
||||
|
||||
const refreshAll = useCallback(() => {
|
||||
if (!demand) return;
|
||||
for (const serverId of serverIds) void refreshHostDirectories(serverId);
|
||||
}, [demand, serverIds]);
|
||||
for (const serverId of serverIds) void runtime.refreshDirectories(serverId);
|
||||
}, [demand, runtime, serverIds]);
|
||||
|
||||
// Keyed by "serverId:agentId" — reuse the previous AggregatedAgent object when
|
||||
// none of its fields changed, so downstream memo/shallow comparisons can bail early.
|
||||
@@ -87,8 +70,8 @@ export function useAggregatedAgents(options?: {
|
||||
);
|
||||
|
||||
// Derive agent directory from all sessions
|
||||
for (const { serverId, agents } of sessionAgents) {
|
||||
if (agents.size === 0) {
|
||||
for (const [serverId, agents] of Object.entries(sessionAgents)) {
|
||||
if (!agents || agents.size === 0) {
|
||||
continue;
|
||||
}
|
||||
const serverLabel = serverLabelById.get(serverId) ?? serverId;
|
||||
@@ -162,7 +145,7 @@ export function useAggregatedAgents(options?: {
|
||||
// Align list loading with the runtime directory-sync machine.
|
||||
const isLoading = daemons.some((daemon) => {
|
||||
const status =
|
||||
readHostRuntimeSnapshot(daemon.serverId)?.agentDirectoryStatus ?? "initial_loading";
|
||||
runtime.getSnapshot(daemon.serverId)?.agentDirectoryStatus ?? "initial_loading";
|
||||
return status === "initial_loading" || status === "revalidating";
|
||||
});
|
||||
const isInitialLoad = isLoading && !hasAnyData;
|
||||
@@ -174,7 +157,7 @@ export function useAggregatedAgents(options?: {
|
||||
isInitialLoad,
|
||||
isRevalidating,
|
||||
};
|
||||
}, [daemons, includeArchived, runtimeVersion, sessionAgents]);
|
||||
}, [daemons, includeArchived, runtime, runtimeVersion, sessionAgents]);
|
||||
|
||||
return {
|
||||
...result,
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { seedSessionHosts } from "@/test/seed-session";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import type { Agent } from "@/stores/session-store";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { acceptAgentSnapshot } from "@/runtime/session-data";
|
||||
import { installSessionDataTestOwner } from "@/test/seed-session";
|
||||
import { agentHistoryQueryKey, allAgentHistoryQueryKey } from "./agent-history-query-key";
|
||||
import {
|
||||
applyArchivedAgentCloseResults,
|
||||
@@ -48,9 +47,10 @@ function makeAgent(overrides: Partial<Agent> = {}): Agent {
|
||||
}
|
||||
|
||||
describe("useArchiveAgent", () => {
|
||||
afterEach(() => seedSessionHosts([]));
|
||||
beforeEach(() => {
|
||||
useSessionStore.setState((state) => ({ ...state, sessions: {} }));
|
||||
installSessionDataTestOwner(["server-a"]);
|
||||
seedSessionHosts(["server-a"]);
|
||||
});
|
||||
|
||||
it("tracks pending archive state in shared react-query cache", () => {
|
||||
@@ -130,7 +130,7 @@ describe("useArchiveAgent", () => {
|
||||
it("applies archived agent close results to session state and cached lists", async () => {
|
||||
const queryClient = new QueryClient();
|
||||
useSessionStore.getState().initializeSession("server-a", {} as DaemonClient);
|
||||
acceptAgentSnapshot("server-a", makeAgent());
|
||||
useSessionStore.getState().setAgents("server-a", new Map([["agent-1", makeAgent()]]));
|
||||
queryClient.setQueryData(["sidebarAgentsList", "server-a"], {
|
||||
entries: [{ agent: { id: "agent-1" } }, { agent: { id: "agent-2" } }],
|
||||
});
|
||||
@@ -212,7 +212,7 @@ describe("useArchiveAgent", () => {
|
||||
it("can apply archived agent close results without invalidating cached lists", () => {
|
||||
const queryClient = new QueryClient();
|
||||
useSessionStore.getState().initializeSession("server-a", {} as DaemonClient);
|
||||
acceptAgentSnapshot("server-a", makeAgent());
|
||||
useSessionStore.getState().setAgents("server-a", new Map([["agent-1", makeAgent()]]));
|
||||
queryClient.setQueryData(["sidebarAgentsList", "server-a"], {
|
||||
entries: [{ agent: { id: "agent-1" } }, { agent: { id: "agent-2" } }],
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import {
|
||||
useMutation,
|
||||
@@ -7,12 +8,7 @@ import {
|
||||
type QueryKey,
|
||||
} from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getActiveAgentSnapshot } from "@/runtime/session-data";
|
||||
import {
|
||||
archiveAgentSnapshot,
|
||||
restoreAgentSnapshot as restoreStoredAgent,
|
||||
} from "@/runtime/session-data";
|
||||
import { getHostClient } from "@/runtime/host-runtime";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { agentHistoryQueryKey, allAgentHistoryQueryRootKey } from "./agent-history-query-key";
|
||||
|
||||
export const ARCHIVE_AGENT_PENDING_QUERY_KEY = ["archive-agent-pending"] as const;
|
||||
@@ -246,13 +242,13 @@ interface ArchiveAgentMutationContext {
|
||||
}
|
||||
|
||||
function getStoredAgentSnapshot(input: ArchiveAgentInput) {
|
||||
return getActiveAgentSnapshot(input.serverId, input.agentId) ?? undefined;
|
||||
return useSessionStore.getState().sessions[input.serverId]?.agents.get(input.agentId);
|
||||
}
|
||||
|
||||
function restoreAgentSnapshot(
|
||||
input: ArchiveAgentInput & { agent: ReturnType<typeof getStoredAgentSnapshot> },
|
||||
): void {
|
||||
restoreStoredAgent(input.serverId, input.agentId, input.agent);
|
||||
getHostRuntimeStore().restoreAgentSnapshot(input.serverId, input.agentId, input.agent);
|
||||
}
|
||||
|
||||
function getArchivedAgentListCacheSnapshot(
|
||||
@@ -309,7 +305,11 @@ function markAgentArchivedInStore(input: ArchiveAgentInput & { archivedAt: strin
|
||||
return;
|
||||
}
|
||||
|
||||
archiveAgentSnapshot(input.serverId, input.agentId, archivedAt.toISOString());
|
||||
getHostRuntimeStore().archiveAgentSnapshot(
|
||||
input.serverId,
|
||||
input.agentId,
|
||||
archivedAt.toISOString(),
|
||||
);
|
||||
}
|
||||
|
||||
interface ApplyArchivedAgentCloseResultsInput {
|
||||
@@ -383,7 +383,7 @@ export function useArchiveAgent() {
|
||||
|
||||
const archiveMutation = useMutation({
|
||||
mutationFn: async (input: ArchiveAgentInput): Promise<{ archivedAt: string }> => {
|
||||
const client = getHostClient(input.serverId);
|
||||
const client = useSessionStore.getState().sessions[input.serverId]?.client ?? null;
|
||||
if (!client) {
|
||||
throw new Error(t("common.errors.daemonClientUnavailable"));
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
import { getHostClient } from "@/runtime/host-runtime";
|
||||
import { useWorkspaceFields } from "@/stores/session-store-hooks";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
|
||||
export interface ClearWorkspaceAttentionController {
|
||||
hasClearableAttention: boolean;
|
||||
@@ -15,18 +15,16 @@ export function useClearWorkspaceAttention({
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
}): ClearWorkspaceAttentionController {
|
||||
const hasClearableAttention =
|
||||
useWorkspaceFields(
|
||||
serverId,
|
||||
workspaceId,
|
||||
(workspace) => workspace.status === "attention" || workspace.status === "failed",
|
||||
) ?? false;
|
||||
const hasClearableAttention = useSessionStore((state) => {
|
||||
const workspace = state.sessions[serverId]?.workspaces.get(workspaceId);
|
||||
return workspace?.status === "attention" || workspace?.status === "failed";
|
||||
});
|
||||
|
||||
const clearAttention = useCallback(async () => {
|
||||
if (!hasClearableAttention) {
|
||||
return;
|
||||
}
|
||||
const client = getHostClient(serverId);
|
||||
const client = getHostRuntimeStore().getClient(serverId);
|
||||
if (!client) {
|
||||
throw new Error(i18n.t("workspace.terminal.hostDisconnected"));
|
||||
}
|
||||
|
||||
@@ -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.turn.phase === "open");
|
||||
const hasRunning = agents.some((agent) => agent.status === "running");
|
||||
if (hasRunning) {
|
||||
return "running";
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type AgentFileExplorerState, type ExplorerDirectory } from "@/stores/session-store-hooks";
|
||||
import { publishFileExplorer as setFileExplorer } from "@/runtime/session-data";
|
||||
import { useHostRuntimeClient } from "@/runtime/host-runtime";
|
||||
import {
|
||||
useSessionStore,
|
||||
type AgentFileExplorerState,
|
||||
type ExplorerDirectory,
|
||||
} from "@/stores/session-store";
|
||||
import { explorerFileFromReadResult } from "@/file-explorer/read-result";
|
||||
import { parentExplorerPath } from "@/utils/explorer-paths";
|
||||
|
||||
@@ -57,7 +59,8 @@ export function buildWorkspaceExplorerStateKey(scope: FileExplorerWorkspaceScope
|
||||
export function useFileExplorerActions(params: { serverId: string } & FileExplorerWorkspaceScope) {
|
||||
const { t } = useTranslation();
|
||||
const { serverId, workspaceId, workspaceRoot } = params;
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
|
||||
const setFileExplorer = useSessionStore((state) => state.setFileExplorer);
|
||||
const normalizedWorkspaceRoot = useMemo(
|
||||
() => normalizeWorkspaceValue(workspaceRoot),
|
||||
[workspaceRoot],
|
||||
@@ -83,7 +86,7 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[serverId, workspaceStateKey],
|
||||
[serverId, setFileExplorer, workspaceStateKey],
|
||||
);
|
||||
|
||||
const requestDirectoryListing = useCallback(
|
||||
|
||||
@@ -12,7 +12,7 @@ import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { useHostFeature } from "@/runtime/host-features";
|
||||
import { generateDraftId } from "@/stores/draft-keys";
|
||||
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
|
||||
import { useHostRuntimeClient } from "@/runtime/host-runtime";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import {
|
||||
buildDraftWorkspaceAttachmentScopeKey,
|
||||
useWorkspaceAttachmentsStore,
|
||||
@@ -129,7 +129,7 @@ export function useForkAgent(
|
||||
const { serverId, toast, readOnly = false } = input;
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
|
||||
const supportsAgentForkContext = useHostFeature(serverId, "agentForkContext") && !readOnly;
|
||||
|
||||
return useStableEvent(async ({ agentId, agent, workspaceId, target, boundary }) => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback } from "react";
|
||||
|
||||
import type { HostRuntimeAgentDirectoryStatus } from "@/runtime/host-runtime";
|
||||
import { useConnection } from "@/stores/session-store-hooks";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { getVoiceReadinessState } from "@/utils/server-info-capabilities";
|
||||
|
||||
function isLegacyDictationReady(agentDirectoryStatus: HostRuntimeAgentDirectoryStatus): boolean {
|
||||
@@ -21,16 +21,19 @@ export function useIsDictationReady({
|
||||
isConnected: boolean;
|
||||
agentDirectoryStatus: HostRuntimeAgentDirectoryStatus;
|
||||
}): boolean {
|
||||
const dictationCapabilityEnabled = useConnection(
|
||||
serverId,
|
||||
useCallback(({ serverInfo }) => {
|
||||
return (
|
||||
getVoiceReadinessState({
|
||||
serverInfo,
|
||||
mode: "dictation",
|
||||
})?.enabled ?? null
|
||||
);
|
||||
}, []),
|
||||
const dictationCapabilityEnabled = useSessionStore(
|
||||
useCallback(
|
||||
(state) => {
|
||||
const serverInfo = state.sessions[serverId]?.serverInfo ?? null;
|
||||
return (
|
||||
getVoiceReadinessState({
|
||||
serverInfo,
|
||||
mode: "dictation",
|
||||
})?.enabled ?? null
|
||||
);
|
||||
},
|
||||
[serverId],
|
||||
),
|
||||
);
|
||||
|
||||
if (!isConnected) {
|
||||
|
||||
@@ -2,13 +2,13 @@ import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ToastApi } from "@/components/toast-host";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
import { useAgentTimeline, type AgentTimelineCursorState } from "@/stores/session-store-hooks";
|
||||
import {
|
||||
getAgentTimelineLoadSnapshot,
|
||||
publishOlderTimelineFetch as setOlderFetchInFlight,
|
||||
} from "@/runtime/session-data";
|
||||
selectAgentTimelineState,
|
||||
useSessionStore,
|
||||
type AgentTimelineCursorState,
|
||||
} from "@/stores/session-store";
|
||||
import { planTimelineOlderFetch } from "@/timeline/timeline-sync-plan";
|
||||
import { fetchAgentTimeline, getHostClient } from "@/runtime/host-runtime";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
|
||||
export interface LoadOlderAgentHistoryClient {
|
||||
fetchAgentTimeline: (
|
||||
@@ -78,15 +78,22 @@ export function useLoadOlderAgentHistory({
|
||||
toast?: ToastApi | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const timelineState = useAgentTimeline(serverId, agentId, (timeline, meta) => {
|
||||
const cursor = timeline.status === "synced" ? timeline.range : null;
|
||||
return {
|
||||
hasOlder: timeline.status === "synced" && timeline.older === "available",
|
||||
isLoadingOlder: meta.isLoadingOlder,
|
||||
progressKey: cursor ? `${cursor.epoch}:${cursor.startSeq}` : null,
|
||||
};
|
||||
const hasOlder = useSessionStore((state) => {
|
||||
const timeline = selectAgentTimelineState(state.sessions[serverId], agentId);
|
||||
return timeline.status === "synced" && timeline.older === "available";
|
||||
});
|
||||
const { hasOlder, isLoadingOlder, progressKey } = timelineState;
|
||||
const isLoadingOlder =
|
||||
useSessionStore((state) =>
|
||||
state.sessions[serverId]?.agentTimelineOlderFetchInFlight.get(agentId),
|
||||
) === true;
|
||||
const progressKey = useSessionStore((state) => {
|
||||
const timeline = selectAgentTimelineState(state.sessions[serverId], agentId);
|
||||
const cursor = timeline.status === "synced" ? timeline.range : null;
|
||||
return cursor ? `${cursor.epoch}:${cursor.startSeq}` : null;
|
||||
});
|
||||
const setOlderFetchInFlight = useSessionStore(
|
||||
(state) => state.setAgentTimelineOlderFetchInFlight,
|
||||
);
|
||||
|
||||
const setInFlight = useCallback(
|
||||
(value: boolean) => {
|
||||
@@ -99,24 +106,22 @@ export function useLoadOlderAgentHistory({
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[agentId, serverId],
|
||||
[agentId, serverId, setOlderFetchInFlight],
|
||||
);
|
||||
|
||||
const loadOlder = useCallback(async (): Promise<boolean> => {
|
||||
const { timeline, isLoadingOlder: currentIsLoadingOlder } = getAgentTimelineLoadSnapshot(
|
||||
serverId,
|
||||
agentId,
|
||||
);
|
||||
const session = useSessionStore.getState().sessions[serverId];
|
||||
const timeline = selectAgentTimelineState(session, agentId);
|
||||
return await loadOlderAgentHistory(agentId, {
|
||||
client: getHostClient(serverId)
|
||||
client: session?.client
|
||||
? {
|
||||
fetchAgentTimeline: (timelineAgentId, request) =>
|
||||
fetchAgentTimeline(serverId, timelineAgentId, request),
|
||||
getHostRuntimeStore().fetchAgentTimeline(serverId, timelineAgentId, request),
|
||||
}
|
||||
: null,
|
||||
cursor: timeline.status === "synced" ? (timeline.range ?? undefined) : undefined,
|
||||
hasOlder: timeline.status === "synced" && timeline.older === "available",
|
||||
isLoadingOlder: currentIsLoadingOlder,
|
||||
isLoadingOlder: session?.agentTimelineOlderFetchInFlight.get(agentId) === true,
|
||||
setInFlight,
|
||||
toast,
|
||||
failedMessage: t("loadOlderHistory.failed"),
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { useCallback } from "react";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { useServerFeature } from "@/stores/session-store-hooks";
|
||||
import {
|
||||
acceptProjectSnapshot as upsertProject,
|
||||
publishWorkspaceHydration as setHasHydratedWorkspaces,
|
||||
} from "@/runtime/session-data";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import {
|
||||
cloneGithubProjectDirectly,
|
||||
openProjectDirectly,
|
||||
@@ -18,12 +15,22 @@ export function useOpenProject(
|
||||
const normalizedServerId = serverId?.trim() ?? "";
|
||||
const client = useHostRuntimeClient(normalizedServerId);
|
||||
const isConnected = useHostRuntimeIsConnected(normalizedServerId);
|
||||
const supportsProjectAdd = useServerFeature(normalizedServerId, "projectAdd");
|
||||
const supportsStableProjectIdentity = useServerFeature(
|
||||
normalizedServerId,
|
||||
"stableProjectIdentity",
|
||||
const canAddProject = useSessionStore((state) =>
|
||||
normalizedServerId
|
||||
? state.sessions[normalizedServerId]?.serverInfo?.features?.projectAdd === true &&
|
||||
state.sessions[normalizedServerId]?.serverInfo?.features?.stableProjectIdentity === true
|
||||
: false,
|
||||
);
|
||||
const canAddProject = supportsProjectAdd && supportsStableProjectIdentity;
|
||||
const upsertProject = useCallback(
|
||||
(
|
||||
targetServerId: string,
|
||||
project: Parameters<ReturnType<typeof getHostRuntimeStore>["acceptProjectSnapshot"]>[1],
|
||||
) => {
|
||||
getHostRuntimeStore().acceptProjectSnapshot(targetServerId, project);
|
||||
},
|
||||
[],
|
||||
);
|
||||
const setHasHydratedWorkspaces = useSessionStore((state) => state.setHasHydratedWorkspaces);
|
||||
|
||||
return useCallback(
|
||||
async (path: string) => {
|
||||
@@ -38,7 +45,14 @@ export function useOpenProject(
|
||||
});
|
||||
return result;
|
||||
},
|
||||
[canAddProject, client, isConnected, normalizedServerId],
|
||||
[
|
||||
upsertProject,
|
||||
canAddProject,
|
||||
client,
|
||||
isConnected,
|
||||
normalizedServerId,
|
||||
setHasHydratedWorkspaces,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -52,6 +66,16 @@ export function useCloneGithubProject(
|
||||
const normalizedServerId = serverId?.trim() ?? "";
|
||||
const client = useHostRuntimeClient(normalizedServerId);
|
||||
const isConnected = useHostRuntimeIsConnected(normalizedServerId);
|
||||
const upsertProject = useCallback(
|
||||
(
|
||||
targetServerId: string,
|
||||
project: Parameters<ReturnType<typeof getHostRuntimeStore>["acceptProjectSnapshot"]>[1],
|
||||
) => {
|
||||
getHostRuntimeStore().acceptProjectSnapshot(targetServerId, project);
|
||||
},
|
||||
[],
|
||||
);
|
||||
const setHasHydratedWorkspaces = useSessionStore((state) => state.setHasHydratedWorkspaces);
|
||||
|
||||
return useCallback(
|
||||
async (repo: string, targetDirectory: string, cloneProtocol?: ProjectGithubCloneProtocol) => {
|
||||
@@ -66,6 +90,6 @@ export function useCloneGithubProject(
|
||||
setHasHydratedWorkspaces,
|
||||
});
|
||||
},
|
||||
[client, isConnected, normalizedServerId],
|
||||
[client, isConnected, normalizedServerId, setHasHydratedWorkspaces, upsertProject],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,17 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from "react";
|
||||
import equal from "fast-deep-equal";
|
||||
import { useStoreWithEqualityFn } from "zustand/traditional";
|
||||
import {
|
||||
acquireDirectoryDemand,
|
||||
getHostRuntimeStore,
|
||||
isHostRuntimeDirectoryLoading,
|
||||
readHostRuntimeSnapshot,
|
||||
refreshHostDirectories,
|
||||
useHostRuntimeVersion,
|
||||
useHosts,
|
||||
type HostRuntimeSnapshot,
|
||||
} from "@/runtime/host-runtime";
|
||||
import {
|
||||
useWorkspaceDirectories,
|
||||
type WorkspaceDirectoryView,
|
||||
useSessionStore,
|
||||
type ProjectDescriptor,
|
||||
type WorkspaceDescriptor,
|
||||
} from "@/stores/session-store-hooks";
|
||||
} from "@/stores/session-store";
|
||||
import { buildProjects, type ProjectHost, type ProjectSummary } from "@/utils/projects";
|
||||
|
||||
export interface ProjectHostError {
|
||||
@@ -76,41 +73,23 @@ function toProjectHostRuntimeState(
|
||||
};
|
||||
}
|
||||
|
||||
function createProjectHostReplicasSelector(
|
||||
function selectProjectHostReplicas(
|
||||
hosts: readonly { serverId: string; label: string }[],
|
||||
enabled: boolean,
|
||||
): (directories: ReadonlyMap<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 };
|
||||
): (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() ?? []),
|
||||
};
|
||||
});
|
||||
const priorInputs = previousInputs;
|
||||
if (
|
||||
priorInputs &&
|
||||
inputs.every(
|
||||
(input, index) =>
|
||||
input.workspaces === priorInputs[index]?.workspaces &&
|
||||
input.projects === priorInputs[index]?.projects,
|
||||
)
|
||||
) {
|
||||
return previousResult;
|
||||
}
|
||||
previousInputs = inputs;
|
||||
previousResult = hosts.map((host, index) => ({
|
||||
serverId: host.serverId,
|
||||
serverName: host.label,
|
||||
workspaces: Array.from(inputs[index]?.workspaces?.values() ?? []),
|
||||
projects: Array.from(inputs[index]?.projects?.values() ?? []),
|
||||
}));
|
||||
return previousResult;
|
||||
};
|
||||
}
|
||||
|
||||
export function deriveProjectsFromReplica(input: {
|
||||
@@ -155,8 +134,15 @@ function useProjectHostRuntimeStates(
|
||||
serverIds: readonly string[],
|
||||
enabled: boolean,
|
||||
): ProjectHostRuntimeState[] {
|
||||
const runtime = getHostRuntimeStore();
|
||||
const previousStatesRef = useRef<ProjectHostRuntimeState[]>([]);
|
||||
const runtimeSnapshotTick = useHostRuntimeVersion();
|
||||
const subscribe = useCallback(
|
||||
(onStoreChange: () => void) =>
|
||||
enabled ? runtime.subscribeAll(onStoreChange) : () => undefined,
|
||||
[enabled, runtime],
|
||||
);
|
||||
const getSnapshot = useCallback(() => (enabled ? runtime.getVersion() : 0), [enabled, runtime]);
|
||||
const runtimeSnapshotTick = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||
return useMemo(() => {
|
||||
if (!enabled) {
|
||||
previousStatesRef.current = EMPTY_PROJECT_HOST_RUNTIME_STATES;
|
||||
@@ -164,32 +150,33 @@ function useProjectHostRuntimeStates(
|
||||
}
|
||||
void runtimeSnapshotTick;
|
||||
const nextStates = serverIds.map((serverId) =>
|
||||
toProjectHostRuntimeState(serverId, readHostRuntimeSnapshot(serverId)),
|
||||
toProjectHostRuntimeState(serverId, runtime.getSnapshot(serverId)),
|
||||
);
|
||||
if (equal(previousStatesRef.current, nextStates)) {
|
||||
return previousStatesRef.current;
|
||||
}
|
||||
previousStatesRef.current = nextStates;
|
||||
return nextStates;
|
||||
}, [enabled, runtimeSnapshotTick, serverIds]);
|
||||
}, [enabled, runtime, runtimeSnapshotTick, serverIds]);
|
||||
}
|
||||
|
||||
export function useProjects(options: UseProjectsOptions = {}): UseProjectsResult {
|
||||
const enabled = options.enabled ?? true;
|
||||
const hosts = useHosts();
|
||||
const runtime = getHostRuntimeStore();
|
||||
const serverIds = useMemo(
|
||||
() => (enabled ? hosts.map((host) => host.serverId) : []),
|
||||
[enabled, hosts],
|
||||
);
|
||||
useEffect(() => {
|
||||
const releases = serverIds.map(acquireDirectoryDemand);
|
||||
const releases = serverIds.map((serverId) => runtime.acquireDirectoryDemand(serverId));
|
||||
return () => releases.forEach((release) => release());
|
||||
}, [serverIds]);
|
||||
const selectReplicas = useMemo(
|
||||
() => createProjectHostReplicasSelector(hosts, enabled),
|
||||
}, [runtime, serverIds]);
|
||||
const replicaSelector = useMemo(
|
||||
() => selectProjectHostReplicas(hosts, enabled),
|
||||
[enabled, hosts],
|
||||
);
|
||||
const replicas = useWorkspaceDirectories(serverIds, selectReplicas);
|
||||
const replicas = useStoreWithEqualityFn(useSessionStore, replicaSelector, equal);
|
||||
const runtimeStates = useProjectHostRuntimeStates(serverIds, enabled);
|
||||
const derived = useMemo(
|
||||
() => deriveProjectsFromReplica({ replicas, runtimeStates }),
|
||||
@@ -197,8 +184,8 @@ export function useProjects(options: UseProjectsOptions = {}): UseProjectsResult
|
||||
);
|
||||
const refetch = useCallback(() => {
|
||||
if (!enabled) return;
|
||||
for (const serverId of serverIds) void refreshHostDirectories(serverId);
|
||||
}, [enabled, serverIds]);
|
||||
for (const serverId of serverIds) void runtime.refreshDirectories(serverId);
|
||||
}, [enabled, runtime, serverIds]);
|
||||
|
||||
return {
|
||||
...derived,
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { AgentProvider, ProviderSnapshotEntry } from "@getpaseo/protocol/ag
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { useServerFeature } from "@/stores/session-store-hooks";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useReplicaQuery } from "@/data/query";
|
||||
import { queryClient as singletonQueryClient } from "@/data/query-client";
|
||||
import {
|
||||
@@ -138,7 +138,9 @@ export function useProvidersSnapshot(
|
||||
const enabled = (options.enabled ?? true) && retainedPanelActive;
|
||||
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
|
||||
const cwd = normalizeProvidersSnapshotCwd(options.cwd);
|
||||
const supportsSnapshot = useServerFeature(serverId, "providersSnapshot");
|
||||
const supportsSnapshot = useSessionStore(
|
||||
(state) => state.sessions[serverId ?? ""]?.serverInfo?.features?.providersSnapshot === true,
|
||||
);
|
||||
|
||||
const queryKey = useMemo(() => providersSnapshotQueryKey(serverId, cwd), [cwd, serverId]);
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ import type {
|
||||
FetchAggregatedSchedulesState,
|
||||
} from "@/schedules/aggregated-schedules";
|
||||
import { schedulesQueryBaseKey } from "@/schedules/aggregated-schedules";
|
||||
import { getHostClient } from "@/runtime/host-runtime";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
|
||||
export type CreateScheduleInput = Omit<CreateScheduleOptions, "requestId">;
|
||||
export type UpdateScheduleInput = Omit<UpdateScheduleOptions, "requestId">;
|
||||
@@ -52,7 +52,7 @@ export function updateAggregatedSchedulesData(
|
||||
}
|
||||
|
||||
function requireClient(serverId: string, unavailableMessage: string): DaemonClient {
|
||||
const client = getHostClient(serverId);
|
||||
const client = useSessionStore.getState().sessions[serverId]?.client ?? null;
|
||||
if (!client) {
|
||||
throw new Error(unavailableMessage);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useMemo } from "react";
|
||||
import { useFetchQuery } from "@/data/query";
|
||||
import {
|
||||
scheduleRuntime,
|
||||
getHostRuntimeStore,
|
||||
useHostRuntimeConnectionStatuses,
|
||||
useHosts,
|
||||
} from "@/runtime/host-runtime";
|
||||
@@ -35,6 +35,7 @@ export interface UseSchedulesResult {
|
||||
|
||||
export function useSchedules(): UseSchedulesResult {
|
||||
const hosts = useHosts();
|
||||
const runtime = getHostRuntimeStore();
|
||||
const hostInputs = useMemo<ScheduleHostInput[]>(
|
||||
() => hosts.map((host) => ({ serverId: host.serverId, serverName: host.label })),
|
||||
[hosts],
|
||||
@@ -48,7 +49,7 @@ export function useSchedules(): UseSchedulesResult {
|
||||
|
||||
const query = useFetchQuery({
|
||||
queryKey: [...schedulesQueryKey(serverIds), connectionStatusKey],
|
||||
queryFn: () => fetchAggregatedSchedules({ hosts: hostInputs, runtime: scheduleRuntime }),
|
||||
queryFn: () => fetchAggregatedSchedules({ hosts: hostInputs, runtime }),
|
||||
dataShape: "list",
|
||||
staleTimeMs: 5_000,
|
||||
});
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useMemo, useRef } from "react";
|
||||
import { shallow } from "zustand/shallow";
|
||||
import { useStoreWithEqualityFn } from "zustand/traditional";
|
||||
import type {
|
||||
SidebarProjectEntry,
|
||||
SidebarWorkspacePlacement,
|
||||
} from "@/hooks/use-sidebar-workspaces-list";
|
||||
import { applyStoredOrdering } from "@/hooks/sidebar-workspaces-view-model";
|
||||
import { useWorkspaceDirectories } from "@/stores/session-store-hooks";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
|
||||
export interface PinnedSidebarKeys {
|
||||
pinnedWorkspaceKeys: string[];
|
||||
@@ -81,25 +83,30 @@ export function usePinnedSidebarKeys(projects: SidebarProjectEntry[]): PinnedSid
|
||||
),
|
||||
[projects],
|
||||
);
|
||||
return useWorkspaceDirectories(
|
||||
serverIds,
|
||||
(directories) => {
|
||||
const workspaceMapByServerId = new Map<
|
||||
string,
|
||||
ReadonlyMap<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;
|
||||
},
|
||||
arePinnedSidebarKeysEqual,
|
||||
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);
|
||||
}
|
||||
}
|
||||
const nextKeys = buildPinnedSidebarKeys(projects, workspaceMapByServerId);
|
||||
if (arePinnedSidebarKeysEqual(previousKeysRef.current, nextKeys)) {
|
||||
return previousKeysRef.current;
|
||||
}
|
||||
previousKeysRef.current = nextKeys;
|
||||
return nextKeys;
|
||||
}, [projects, serverIds, workspaceMaps]);
|
||||
}
|
||||
|
||||
// Splits the sidebar into a dedicated Pinned section (chats) and the regular list below.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useMemo, useRef } from "react";
|
||||
import { useStoreWithEqualityFn } from "zustand/traditional";
|
||||
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
||||
import { useWorkspaceDirectories } from "@/stores/session-store-hooks";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import {
|
||||
areSidebarWorkspaceSessionsEqual,
|
||||
buildSidebarWorkspaceEntries,
|
||||
@@ -22,26 +23,10 @@ export function useSidebarWorkspaceEntries(
|
||||
() => Array.from(new Set(placements.map((placement) => placement.serverId))),
|
||||
[placements],
|
||||
);
|
||||
const sessions = useWorkspaceDirectories(
|
||||
serverIds,
|
||||
(directories) =>
|
||||
enabled
|
||||
? selectSidebarWorkspaceSessions(
|
||||
Object.fromEntries(
|
||||
serverIds.map((serverId) => {
|
||||
const directory = directories.get(serverId);
|
||||
return [
|
||||
serverId,
|
||||
{
|
||||
workspaces: directory?.workspaces ?? new Map(),
|
||||
workspaceAgentActivity: directory?.workspaceAgentActivity ?? new Map(),
|
||||
},
|
||||
];
|
||||
}),
|
||||
),
|
||||
serverIds,
|
||||
)
|
||||
: EMPTY_SESSIONS,
|
||||
const sessions = useStoreWithEqualityFn(
|
||||
useSessionStore,
|
||||
(state) =>
|
||||
enabled ? selectSidebarWorkspaceSessions(state.sessions, serverIds) : EMPTY_SESSIONS,
|
||||
areSidebarWorkspaceSessionsEqual,
|
||||
);
|
||||
const pendingCreateAttempts = useCreateFlowStore((state) =>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
|
||||
import { getHostClient } from "@/runtime/host-runtime";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
|
||||
// Everything the pin toggle actually needs. Kept narrower than SidebarWorkspaceEntry so the
|
||||
// global keyboard handler can build one from the active route selection without a sidebar row.
|
||||
@@ -30,7 +30,7 @@ export function useSidebarWorkspacePinController(): ToggleSidebarWorkspacePin {
|
||||
workspace: PinnableWorkspace;
|
||||
pinned: boolean;
|
||||
}) => {
|
||||
const client = getHostClient(workspace.serverId);
|
||||
const client = getHostRuntimeStore().getClient(workspace.serverId);
|
||||
if (!client) {
|
||||
throw new Error(t("sidebar.workspace.toasts.hostDisconnected"));
|
||||
}
|
||||
|
||||
@@ -1,18 +1,11 @@
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { useStoreWithEqualityFn } from "zustand/traditional";
|
||||
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
||||
import {
|
||||
useWorkspaceDirectories,
|
||||
useWorkspaceDirectoryServerIds,
|
||||
} from "@/stores/session-store-hooks";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useWorkspaceDirectoryServerIds } from "@/stores/session-store-hooks";
|
||||
import { workspaceEqualityFns } from "@/stores/session-store-hooks/selectors";
|
||||
import { useHostProjects } from "@/projects/host-projects";
|
||||
import {
|
||||
acquireDirectoryDemand,
|
||||
refreshHostDirectories,
|
||||
useHostRegistryLoaded,
|
||||
useHosts,
|
||||
} from "@/runtime/host-runtime";
|
||||
import { getHostRuntimeStore, useHostRegistryLoaded, useHosts } from "@/runtime/host-runtime";
|
||||
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
|
||||
import { useSidebarViewStore } from "@/stores/sidebar-view-store";
|
||||
import {
|
||||
@@ -71,30 +64,19 @@ export function useSidebarProjectStatusBucket(input: {
|
||||
workspaceEqualityFns.deep,
|
||||
);
|
||||
|
||||
const serverIds = useMemo(
|
||||
() => Array.from(new Set(workspaces.map((workspace) => workspace.serverId))),
|
||||
[workspaces],
|
||||
);
|
||||
return useWorkspaceDirectories(
|
||||
serverIds,
|
||||
(directories) => {
|
||||
const selector = useCallback(
|
||||
(state: { sessions: Record<string, ProjectStatusSession | undefined> }) => {
|
||||
if (!enabled) return null;
|
||||
const sessions = Object.fromEntries(
|
||||
serverIds.map((serverId) => {
|
||||
const directory = directories.get(serverId);
|
||||
return [
|
||||
serverId,
|
||||
{
|
||||
workspaces: directory?.workspaces ?? new Map(),
|
||||
workspaceAgentActivity: directory?.workspaceAgentActivity ?? new Map(),
|
||||
} satisfies ProjectStatusSession,
|
||||
];
|
||||
}),
|
||||
);
|
||||
return deriveProjectStatusBucket({ workspaces, sessions, pendingCreateAttempts });
|
||||
return deriveProjectStatusBucket({
|
||||
workspaces,
|
||||
sessions: state.sessions,
|
||||
pendingCreateAttempts,
|
||||
});
|
||||
},
|
||||
Object.is,
|
||||
[enabled, pendingCreateAttempts, workspaces],
|
||||
);
|
||||
|
||||
return useStoreWithEqualityFn(useSessionStore, selector, Object.is);
|
||||
}
|
||||
|
||||
const EMPTY_ORDER: string[] = [];
|
||||
@@ -116,6 +98,7 @@ export function useSidebarWorkspacesList(options?: {
|
||||
hostFilters?: readonly string[];
|
||||
enabled?: boolean;
|
||||
}): SidebarWorkspacesListResult {
|
||||
const runtime = getHostRuntimeStore();
|
||||
const allHosts = useHosts();
|
||||
const hostRegistryLoaded = useHostRegistryLoaded();
|
||||
const allServerIds = useMemo(() => allHosts.map((h) => h.serverId), [allHosts]);
|
||||
@@ -140,9 +123,9 @@ export function useSidebarWorkspacesList(options?: {
|
||||
}, [allServerIds, hostFilters, hostRegistryLoaded]);
|
||||
useEffect(() => {
|
||||
if (!isActive) return;
|
||||
const releases = serverIds.map(acquireDirectoryDemand);
|
||||
const releases = serverIds.map((serverId) => runtime.acquireDirectoryDemand(serverId));
|
||||
return () => releases.forEach((release) => release());
|
||||
}, [isActive, serverIds]);
|
||||
}, [isActive, runtime, serverIds]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hostRegistryLoaded) {
|
||||
@@ -193,14 +176,14 @@ export function useSidebarWorkspacesList(options?: {
|
||||
const refreshAll = useCallback(() => {
|
||||
if (!isActive) return;
|
||||
for (const serverId of serverIds) {
|
||||
void refreshHostDirectories(serverId).catch((error) => {
|
||||
void runtime.refreshDirectories(serverId).catch((error) => {
|
||||
console.error("[WorkspaceFetch][sidebar-refresh] failed", {
|
||||
serverId,
|
||||
error,
|
||||
});
|
||||
});
|
||||
}
|
||||
}, [isActive, serverIds]);
|
||||
}, [isActive, runtime, serverIds]);
|
||||
|
||||
const loadingState = deriveSidebarLoadingState({
|
||||
isActive,
|
||||
|
||||
@@ -10,8 +10,6 @@ import {
|
||||
buildHostWorkspaceRoute,
|
||||
buildOpenProjectRoute,
|
||||
} from "@/utils/host-routes";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { getManagedDaemonStartService } from "@/runtime/daemon-start-service";
|
||||
|
||||
export interface HostRuntimeBootstrapStore {
|
||||
boot: () => Promise<void>;
|
||||
@@ -39,14 +37,6 @@ export function startHostRuntimeBootstrap(input: StartHostRuntimeBootstrapInput)
|
||||
});
|
||||
}
|
||||
|
||||
export function startDefaultHostRuntimeBootstrap(shouldStartDaemon: DaemonStartCondition): void {
|
||||
startHostRuntimeBootstrap({
|
||||
store: getHostRuntimeStore(),
|
||||
daemonStartService: getManagedDaemonStartService(),
|
||||
shouldStartDaemon,
|
||||
});
|
||||
}
|
||||
|
||||
const WELCOME_ROUTE: Href = "/welcome";
|
||||
|
||||
export type StartupBlocker =
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
@@ -18,7 +19,8 @@ import { StyleSheet as RNStyleSheet, Text, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import invariant from "tiny-invariant";
|
||||
import { useShallow } from "zustand/shallow";
|
||||
import { shallow, useShallow } from "zustand/shallow";
|
||||
import { useStoreWithEqualityFn } from "zustand/traditional";
|
||||
import { AgentStreamView, type AgentStreamViewHandle } from "@/agent-stream/view";
|
||||
import { ArchivedAgentCallout } from "@/components/archived-agent-callout";
|
||||
import { KeyboardDock } from "@/components/keyboard-dock";
|
||||
@@ -77,12 +79,6 @@ import {
|
||||
useHostRuntimeLastError,
|
||||
useHosts,
|
||||
} from "@/runtime/host-runtime";
|
||||
import {
|
||||
acceptAgentSnapshot,
|
||||
getAgentSnapshot,
|
||||
publishAgentDetails,
|
||||
replaceAgentPendingPermissions,
|
||||
} from "@/runtime/session-data";
|
||||
import {
|
||||
deriveRouteBottomAnchorIntent,
|
||||
deriveRouteBottomAnchorRequest,
|
||||
@@ -92,19 +88,18 @@ import { AgentTracks, hasAgentTracks } from "@/panels/agent-tracks";
|
||||
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
||||
import { buildDraftStoreKey, generateDraftId } from "@/stores/draft-keys";
|
||||
import {
|
||||
useAgentFields,
|
||||
useAgentConversation,
|
||||
useAgentTurn,
|
||||
useConnection,
|
||||
selectAgentTimelineState,
|
||||
selectAgentTurnPresentation,
|
||||
type Agent,
|
||||
} from "@/stores/session-store-hooks";
|
||||
useSessionStore,
|
||||
} from "@/stores/session-store";
|
||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
||||
import { openWorkspaceChanges } from "@/workspace-tabs/open-supporting-view";
|
||||
import { useSettings } from "@/hooks/use-settings";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import type { PendingPermission } from "@/types/shared";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import type { StreamItem, TodoEntry } from "@/types/stream";
|
||||
import type { ViewedTimelineStatus, ViewedTimelineUiBridge } from "@/timeline/viewed-timeline-sync";
|
||||
import { useArchiveFinishedSubagents, useSubagentsForParent } from "@/subagents";
|
||||
import { getInitDeferred, getInitKey } from "@/utils/agent-initialization";
|
||||
@@ -140,6 +135,16 @@ interface ChatAgentSelectedState extends ChatAgentStateShape {
|
||||
attentionReason: Agent["attentionReason"] | null;
|
||||
}
|
||||
|
||||
function resolveChatAgentFromSession(
|
||||
state: ReturnType<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;
|
||||
@@ -160,7 +165,12 @@ const EMPTY_CHAT_AGENT_STATE: ChatAgentSelectedState = {
|
||||
attentionReason: null,
|
||||
};
|
||||
|
||||
function selectChatAgentState(agent: Agent | null): ChatAgentSelectedState {
|
||||
function selectChatAgentState(
|
||||
state: ReturnType<typeof useSessionStore.getState>,
|
||||
serverId: string,
|
||||
agentId: string | undefined,
|
||||
): ChatAgentSelectedState {
|
||||
const agent = resolveChatAgentFromSession(state, serverId, agentId);
|
||||
if (!agent) return EMPTY_CHAT_AGENT_STATE;
|
||||
return {
|
||||
serverId: agent.serverId,
|
||||
@@ -182,26 +192,6 @@ function selectChatAgentState(agent: Agent | null): ChatAgentSelectedState {
|
||||
};
|
||||
}
|
||||
|
||||
function selectAgentProjectPlacement(agent: Agent | null) {
|
||||
return agent ? (agent.projectPlacement ?? null) : null;
|
||||
}
|
||||
|
||||
function selectReadyAgent(agent: Agent) {
|
||||
return {
|
||||
agentState: selectChatAgentState(agent),
|
||||
projectPlacement: selectAgentProjectPlacement(agent),
|
||||
};
|
||||
}
|
||||
|
||||
const EMPTY_READY_AGENT = {
|
||||
agentState: EMPTY_CHAT_AGENT_STATE,
|
||||
projectPlacement: null,
|
||||
};
|
||||
|
||||
function resolveReadyAgent(selected: ReturnType<typeof selectReadyAgent> | null) {
|
||||
return selected ?? EMPTY_READY_AGENT;
|
||||
}
|
||||
|
||||
function buildChatAgentFromState(
|
||||
state: ChatAgentStateShape,
|
||||
projectPlacement: Agent["projectPlacement"] | null,
|
||||
@@ -318,24 +308,31 @@ function storeFetchedAgentDetail(input: {
|
||||
projectPlacement: input.result.project,
|
||||
},
|
||||
});
|
||||
const store = useSessionStore.getState();
|
||||
|
||||
if (shouldStoreFetchedAgentInActiveDirectory(hydrated)) {
|
||||
acceptAgentSnapshot(input.serverId, hydrated);
|
||||
getHostRuntimeStore().acceptAgentSnapshot(input.serverId, hydrated);
|
||||
} else {
|
||||
publishAgentDetails(input.serverId, (previous) => {
|
||||
store.setAgentDetails(input.serverId, (previous) => {
|
||||
const next = new Map(previous);
|
||||
next.set(hydrated.id, hydrated);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
replaceAgentPendingPermissions(
|
||||
input.serverId,
|
||||
hydrated.id,
|
||||
hydrated.pendingPermissions.map((request) => {
|
||||
store.setPendingPermissions(input.serverId, (previous) => {
|
||||
const next = new Map(previous);
|
||||
for (const [key, pending] of next.entries()) {
|
||||
if (pending.agentId === hydrated.id) {
|
||||
next.delete(key);
|
||||
}
|
||||
}
|
||||
for (const request of hydrated.pendingPermissions) {
|
||||
const key = derivePendingPermissionKey(hydrated.id, request);
|
||||
return { key, agentId: hydrated.id, request };
|
||||
}),
|
||||
);
|
||||
next.set(key, { key, agentId: hydrated.id, request });
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
return hydrated;
|
||||
}
|
||||
@@ -344,17 +341,24 @@ function useAgentPanelDescriptor(
|
||||
target: { kind: "agent"; agentId: string },
|
||||
context: { serverId: string },
|
||||
): PanelDescriptor {
|
||||
const descriptorState = useAgentFields(context.serverId, target.agentId, (agent) => ({
|
||||
provider: agent.provider,
|
||||
title: agent.title,
|
||||
status: agent.status,
|
||||
pendingPermissionCount: agent.pendingPermissions.length,
|
||||
requiresAttention: agent.requiresAttention ?? false,
|
||||
attentionReason: agent.attentionReason ?? null,
|
||||
}));
|
||||
const isTurnActive = useAgentTurn(context.serverId, target.agentId, (turn) => turn.isActive);
|
||||
const provider = descriptorState?.provider ?? "codex";
|
||||
const label = resolveWorkspaceAgentTabLabel(descriptorState?.title ?? null);
|
||||
const descriptorState = useSessionStore(
|
||||
useShallow((state) => {
|
||||
const session = state.sessions[context.serverId];
|
||||
const agent =
|
||||
session?.agents?.get(target.agentId) ?? session?.agentDetails?.get(target.agentId) ?? null;
|
||||
return {
|
||||
provider: agent?.provider ?? "codex",
|
||||
title: agent?.title ?? null,
|
||||
status: agent?.status ?? null,
|
||||
pendingPermissionCount: agent?.pendingPermissions.length ?? 0,
|
||||
requiresAttention: agent?.requiresAttention ?? false,
|
||||
attentionReason: agent?.attentionReason ?? null,
|
||||
isTurnActive: selectAgentTurnPresentation(session, target.agentId).isActive,
|
||||
};
|
||||
}),
|
||||
);
|
||||
const provider = descriptorState.provider;
|
||||
const label = resolveWorkspaceAgentTabLabel(descriptorState.title);
|
||||
const icon = getProviderIcon(provider, context.serverId);
|
||||
|
||||
return {
|
||||
@@ -363,9 +367,9 @@ function useAgentPanelDescriptor(
|
||||
tooltip: label ?? `${formatProviderLabel(provider)} agent`,
|
||||
titleState: label ? "ready" : "loading",
|
||||
icon,
|
||||
statusBucket: descriptorState?.status
|
||||
statusBucket: descriptorState.status
|
||||
? deriveSidebarStateBucket({
|
||||
status: isTurnActive ? "running" : descriptorState.status,
|
||||
status: descriptorState.isTurnActive ? "running" : descriptorState.status,
|
||||
pendingPermissionCount: descriptorState.pendingPermissionCount,
|
||||
requiresAttention: descriptorState.requiresAttention,
|
||||
attentionReason: descriptorState.attentionReason,
|
||||
@@ -407,7 +411,7 @@ function DraftPanel() {
|
||||
(agentSnapshot: Parameters<typeof normalizeAgentSnapshot>[0]) => {
|
||||
const normalized = normalizeAgentSnapshot(agentSnapshot, serverId);
|
||||
const agent = applyLegacyDaemonWorkspaceOwnership({ serverId, agent: normalized });
|
||||
acceptAgentSnapshot(serverId, agent);
|
||||
getHostRuntimeStore().acceptAgentSnapshot(serverId, agent);
|
||||
retargetCurrentTab({ kind: "agent", agentId: agentSnapshot.id });
|
||||
},
|
||||
[retargetCurrentTab, serverId],
|
||||
@@ -532,14 +536,13 @@ function AgentPanelContent({
|
||||
const runtimeIsConnected = useHostRuntimeIsConnected(runtimeServerId);
|
||||
const runtimeConnectionStatus = useHostRuntimeConnectionStatus(runtimeServerId);
|
||||
const runtimeLastError = useHostRuntimeLastError(runtimeServerId);
|
||||
const hasCachedAgent = Boolean(
|
||||
useAgentFields(
|
||||
resolvedServerId ?? null,
|
||||
resolvedAgentId ?? null,
|
||||
(agent) => agent.id,
|
||||
Object.is,
|
||||
),
|
||||
);
|
||||
const hasCachedAgent = useSessionStore((state) => {
|
||||
if (!resolvedServerId || !resolvedAgentId) return false;
|
||||
const session = state.sessions[resolvedServerId];
|
||||
return Boolean(
|
||||
session?.agents.has(resolvedAgentId) || session?.agentDetails.has(resolvedAgentId),
|
||||
);
|
||||
});
|
||||
|
||||
const connectionServerId = resolvedServerId ?? null;
|
||||
const daemon = connectionServerId
|
||||
@@ -601,13 +604,25 @@ function AgentPanelBody({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { isArchivingAgent: _isArchivingAgent } = useArchiveAgent();
|
||||
const hasSession = useConnection(serverId, (connection) => connection.exists);
|
||||
const selected = useAgentFields(serverId, agentId, (agent) => ({
|
||||
projectPlacement: selectAgentProjectPlacement(agent),
|
||||
agentState: selectChatAgentState(agent),
|
||||
}));
|
||||
const projectPlacement = selected?.projectPlacement ?? null;
|
||||
const agentState = selected?.agentState ?? EMPTY_CHAT_AGENT_STATE;
|
||||
const hasSession = useSessionStore((state) => Boolean(state.sessions[serverId]));
|
||||
const projectPlacement = useStoreWithEqualityFn(
|
||||
useSessionStore,
|
||||
(state) => {
|
||||
if (!agentId) {
|
||||
return null;
|
||||
}
|
||||
const session = state.sessions[serverId];
|
||||
return (
|
||||
session?.agents?.get(agentId)?.projectPlacement ??
|
||||
session?.agentDetails?.get(agentId)?.projectPlacement ??
|
||||
null
|
||||
);
|
||||
},
|
||||
(a, b) => a === b || JSON.stringify(a) === JSON.stringify(b),
|
||||
);
|
||||
const agentState = useSessionStore(
|
||||
useShallow((state) => selectChatAgentState(state, serverId, agentId)),
|
||||
);
|
||||
const [lookupState, setLookupState] = useState<AgentLookupState>({ tag: "idle" });
|
||||
const lookupAttemptTokenRef = useRef(0);
|
||||
const retryAgentLookup = useCallback(() => setLookupState({ tag: "idle" }), []);
|
||||
@@ -782,22 +797,42 @@ function ChatAgentContent({
|
||||
routeKey: string;
|
||||
reason: "initial-entry" | "resume";
|
||||
} | null>(null);
|
||||
const { agentState, projectPlacement } = resolveReadyAgent(
|
||||
useAgentFields(serverId, agentId, selectReadyAgent),
|
||||
const agentState = useSessionStore(
|
||||
useShallow((state) => selectChatAgentState(state, serverId, agentId)),
|
||||
);
|
||||
const projectPlacement = useStoreWithEqualityFn(
|
||||
useSessionStore,
|
||||
(state) => {
|
||||
if (!agentId) {
|
||||
return null;
|
||||
}
|
||||
const session = state.sessions[serverId];
|
||||
return (
|
||||
session?.agents?.get(agentId)?.projectPlacement ??
|
||||
session?.agentDetails?.get(agentId)?.projectPlacement ??
|
||||
null
|
||||
);
|
||||
},
|
||||
(a, b) => a === b || JSON.stringify(a) === JSON.stringify(b),
|
||||
);
|
||||
const isInitializingFromMap = useSessionStore((state) =>
|
||||
agentId ? (state.sessions[serverId]?.initializingAgents?.get(agentId) ?? false) : false,
|
||||
);
|
||||
const historySyncGeneration = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.historySyncGeneration ?? 0,
|
||||
);
|
||||
const replicaTimelineStatus = useSessionStore((state) =>
|
||||
agentId
|
||||
? selectAgentTimelineState(state.sessions[serverId], agentId).status
|
||||
: ("cold" as const),
|
||||
);
|
||||
const conversation = useAgentConversation(serverId, agentId, (value) => ({
|
||||
isInitializing: value.isInitializing,
|
||||
historySyncGeneration: value.historySyncGeneration,
|
||||
agentHistorySyncGeneration: value.agentHistorySyncGeneration,
|
||||
timelineStatus: value.timeline.status,
|
||||
viewedTimelineSync: value.viewedTimelineSync,
|
||||
}));
|
||||
const isInitializingFromMap = conversation.isInitializing;
|
||||
const historySyncGeneration = conversation.historySyncGeneration;
|
||||
const replicaTimelineStatus = conversation.timelineStatus;
|
||||
const hasAppliedAuthoritativeHistory = replicaTimelineStatus === "synced";
|
||||
const agentHistorySyncGeneration = conversation.agentHistorySyncGeneration;
|
||||
const viewedTimelineSync = conversation.viewedTimelineSync;
|
||||
const agentHistorySyncGeneration = useSessionStore((state) =>
|
||||
agentId ? (state.sessions[serverId]?.agentHistorySyncGeneration?.get(agentId) ?? -1) : -1,
|
||||
);
|
||||
const viewedTimelineSync = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.viewedTimelineSync ?? null,
|
||||
);
|
||||
const subscribeToVisibilityCatchUp = useCallback(
|
||||
(listener: () => void) => viewedTimelineSync?.subscribe(listener) ?? (() => {}),
|
||||
[viewedTimelineSync],
|
||||
@@ -823,7 +858,7 @@ function ChatAgentContent({
|
||||
const hasActiveCreateHandoff = useCreateFlowStore((state) =>
|
||||
findActiveCreateHandoff({ pendingByDraftId: state.pendingByDraftId, serverId, agentId }),
|
||||
);
|
||||
const hasSession = useConnection(serverId, (connection) => connection.exists);
|
||||
const hasSession = useSessionStore((state) => Boolean(state.sessions[serverId]));
|
||||
const [missingAgentState, setMissingAgentState] = useState<AgentScreenMissingState>({
|
||||
kind: "idle",
|
||||
});
|
||||
@@ -1075,7 +1110,9 @@ function ChatAgentContent({
|
||||
if (attemptToken !== initAttemptTokenRef.current) {
|
||||
return;
|
||||
}
|
||||
const currentAgent = getAgentSnapshot(serverId, agentId);
|
||||
const currentSession = useSessionStore.getState().sessions[serverId];
|
||||
const currentAgent =
|
||||
currentSession?.agents.get(agentId) ?? currentSession?.agentDetails.get(agentId);
|
||||
if (!currentAgent) {
|
||||
const result = await client.fetchAgent({ agentId });
|
||||
if (attemptToken !== initAttemptTokenRef.current) {
|
||||
@@ -1226,7 +1263,9 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const subagentRows = useSubagentsForParent({ serverId, parentAgentId: agentId });
|
||||
const tasks = useAgentConversation(serverId, agentId, (value) => value.tasks, Object.is);
|
||||
const tasks = useSessionStore((state): TodoEntry[] | undefined =>
|
||||
state.sessions[serverId]?.agentTasks.get(agentId),
|
||||
);
|
||||
const archiveFinishedSubagents = useArchiveFinishedSubagents({
|
||||
serverId,
|
||||
parentAgentId: agentId,
|
||||
@@ -1439,21 +1478,44 @@ const AgentStreamSection = memo(function AgentStreamSection({
|
||||
const bottomOverlayControlClearance = hasVisibleComposerTracks
|
||||
? resolveComposerTrackControlClearance(isCompactFormFactor)
|
||||
: 0;
|
||||
const conversation = useAgentConversation(serverId, agentId, (value) => ({
|
||||
streamItems: value.streamItems,
|
||||
messageSubmissions: value.messageSubmissions,
|
||||
pendingPermissions: value.pendingPermissions,
|
||||
turn: value.turn,
|
||||
}));
|
||||
const streamItemsRaw = conversation.streamItems;
|
||||
const pendingMessageSubmissions = agentId
|
||||
? getActiveMessageSubmissions(conversation.messageSubmissions)
|
||||
: EMPTY_MESSAGE_SUBMISSIONS;
|
||||
const turnPresentation = conversation.turn;
|
||||
const streamItemsRaw = useSessionStore((state) =>
|
||||
agentId ? state.sessions[serverId]?.agentStreamTail?.get(agentId) : undefined,
|
||||
);
|
||||
const pendingMessageSubmissions = useSessionStore(
|
||||
useShallow((state) =>
|
||||
agentId
|
||||
? getActiveMessageSubmissions(state.sessions[serverId]?.messageSubmissions.get(agentId))
|
||||
: EMPTY_MESSAGE_SUBMISSIONS,
|
||||
),
|
||||
);
|
||||
const turnPresentation = useSessionStore(
|
||||
useShallow((state) =>
|
||||
agentId
|
||||
? selectAgentTurnPresentation(state.sessions[serverId], agentId)
|
||||
: { isActive: false, isCancelling: false, startedAt: null, turnId: null },
|
||||
),
|
||||
);
|
||||
const streamItems = streamItemsRaw ?? EMPTY_STREAM_ITEMS;
|
||||
const pendingPermissionList = agentId
|
||||
? conversation.pendingPermissions
|
||||
: EMPTY_PENDING_PERMISSION_LIST;
|
||||
const pendingPermissionList = useStoreWithEqualityFn(
|
||||
useSessionStore,
|
||||
(state) => {
|
||||
if (!agentId) {
|
||||
return EMPTY_PENDING_PERMISSION_LIST;
|
||||
}
|
||||
const allPendingPermissions = state.sessions[serverId]?.pendingPermissions;
|
||||
if (!allPendingPermissions) {
|
||||
return EMPTY_PENDING_PERMISSION_LIST;
|
||||
}
|
||||
const filtered: PendingPermission[] = [];
|
||||
for (const permission of allPendingPermissions.values()) {
|
||||
if (permission.agentId === agentId) {
|
||||
filtered.push(permission);
|
||||
}
|
||||
}
|
||||
return filtered.length > 0 ? filtered : EMPTY_PENDING_PERMISSION_LIST;
|
||||
},
|
||||
shallow,
|
||||
);
|
||||
const pendingPermissions = useMemo(() => {
|
||||
if (pendingPermissionList.length === 0) {
|
||||
return EMPTY_PENDING_PERMISSIONS;
|
||||
@@ -1596,7 +1658,7 @@ function ActiveAgentComposer({
|
||||
|
||||
const handleClientSlashCommand = useCallback(
|
||||
async (command: ClientSlashCommand) => {
|
||||
const agent = getAgentSnapshot(serverId, agentId);
|
||||
const agent = resolveChatAgentFromSession(useSessionStore.getState(), serverId, agentId);
|
||||
if (!agent) {
|
||||
throw new Error("Agent not found");
|
||||
}
|
||||
|
||||
@@ -7,8 +7,7 @@ import { supportsDesktopPaneSplits, useIsCompactFormFactor } from "@/constants/l
|
||||
import { usePaneContext } from "@/panels/pane-context";
|
||||
import { useSettings } from "@/hooks/use-settings";
|
||||
import { PluginComposerPills } from "@/plugins";
|
||||
import { useServerFeature } from "@/stores/session-store-hooks";
|
||||
import { getAgentSnapshot } from "@/runtime/session-data";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import {
|
||||
type ArchiveFinishedStatus,
|
||||
useArchiveSubagent,
|
||||
@@ -56,12 +55,15 @@ export const AgentTracks = memo(function AgentTracks({
|
||||
const canSplit = supportsDesktopPaneSplits() && !isCompact;
|
||||
const openInSidePane = useSettings((settings) => settings.openInSidePane);
|
||||
const workspaceKey = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
|
||||
const canDetachSubagents = useServerFeature(serverId, "agentDetach");
|
||||
const canDetachSubagents = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.serverInfo?.features?.agentDetach === true,
|
||||
);
|
||||
const archiveSubagent = useArchiveSubagent({ serverId });
|
||||
const detachSubagent = useDetachSubagent({ serverId });
|
||||
const handleOpenSubagent = useCallback(
|
||||
(subagentId: string) => {
|
||||
const agent = getAgentSnapshot(serverId, subagentId);
|
||||
const session = useSessionStore.getState().sessions[serverId];
|
||||
const agent = session?.agents.get(subagentId) ?? session?.agentDetails.get(subagentId);
|
||||
if (agent?.workspaceId && agent.workspaceId !== workspaceId) {
|
||||
navigateToAgent({ serverId, agentId: subagentId });
|
||||
return;
|
||||
|
||||
@@ -14,8 +14,7 @@ import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import type { AgentScreenAgent } from "@/hooks/use-agent-screen-state-machine";
|
||||
import { usePaneContext } from "@/panels/pane-context";
|
||||
import { definePanel, type PanelDescriptor } from "@/panels/panel-registry";
|
||||
import { useActiveAgentFields, useAgentFields, useConnection } from "@/stores/session-store-hooks";
|
||||
import { useHostRuntimeClient } from "@/runtime/host-runtime";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useSubagentsForParent } from "@/subagents/select";
|
||||
import { SubagentsTrack } from "@/subagents/track";
|
||||
import {
|
||||
@@ -83,11 +82,8 @@ function useProviderSubagentDescriptor(
|
||||
providerSubagentKey(context.serverId, target.parentAgentId, target.subagentId),
|
||||
),
|
||||
);
|
||||
const parentProvider = useActiveAgentFields(
|
||||
context.serverId,
|
||||
target.parentAgentId,
|
||||
(agent) => agent.provider,
|
||||
Object.is,
|
||||
const parentProvider = useSessionStore(
|
||||
(state) => state.sessions[context.serverId]?.agents.get(target.parentAgentId)?.provider,
|
||||
);
|
||||
const provider = descriptor?.provider ?? parentProvider ?? "agent";
|
||||
// The task names the tab; the subagent type is supporting detail beside the provider.
|
||||
@@ -122,14 +118,14 @@ function ProviderSubagentPanel() {
|
||||
timeline: state.timelines.get(key) ?? null,
|
||||
})),
|
||||
);
|
||||
const parent = useAgentFields(serverId, target.parentAgentId, (agent) => ({
|
||||
provider: agent.provider,
|
||||
cwd: agent.cwd,
|
||||
workspaceId: agent.workspaceId,
|
||||
projectPlacement: agent.projectPlacement,
|
||||
}));
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const serverInfo = useConnection(serverId, (connection) => connection.serverInfo);
|
||||
const parent = useSessionStore(
|
||||
(state) =>
|
||||
state.sessions[serverId]?.agents.get(target.parentAgentId) ??
|
||||
state.sessions[serverId]?.agentDetails.get(target.parentAgentId) ??
|
||||
null,
|
||||
);
|
||||
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
|
||||
const serverInfo = useSessionStore((state) => state.sessions[serverId]?.serverInfo ?? null);
|
||||
// COMPAT(providerSubagents): added in v0.2.11, remove after 2027-01-12.
|
||||
const supported = serverInfo?.features?.providerSubagents === true;
|
||||
const [isLoadingOlder, setIsLoadingOlder] = useState(false);
|
||||
|
||||
@@ -12,8 +12,8 @@ import { definePanel, type PanelDescriptor } from "@/panels/panel-registry";
|
||||
import { queryClient } from "@/data/query-client";
|
||||
import { buildTerminalsQueryKey } from "@/screens/workspace/terminals/state";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useWorkspaceDirectory, useWorkspaceFields } from "@/stores/session-store-hooks";
|
||||
import { useHostRuntimeClient } from "@/runtime/host-runtime";
|
||||
|
||||
type ListTerminalsPayload = ListTerminalsResponse["payload"];
|
||||
|
||||
@@ -37,7 +37,7 @@ function useTerminalPanelDescriptor(
|
||||
context: { serverId: string; workspaceId: string },
|
||||
): PanelDescriptor {
|
||||
const { t } = useTranslation();
|
||||
const client = useHostRuntimeClient(context.serverId);
|
||||
const client = useSessionStore((state) => state.sessions[context.serverId]?.client ?? null);
|
||||
const workspaceDirectory = useWorkspaceDirectory(context.serverId, context.workspaceId);
|
||||
const terminalsQuery = useQuery(
|
||||
{
|
||||
@@ -78,9 +78,9 @@ function useTerminalPanelDescriptor(
|
||||
function TerminalPanel() {
|
||||
const { serverId, workspaceId, target, openFileInWorkspace } = usePaneContext();
|
||||
const { isWorkspaceFocused, isPaneFocused } = usePaneFocus();
|
||||
const workspaceFields = useWorkspaceFields(serverId, workspaceId, (workspace) => ({
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
isGitCheckout: workspace.projectKind === "git",
|
||||
const workspaceFields = useWorkspaceFields(serverId, workspaceId, (w) => ({
|
||||
workspaceDirectory: w.workspaceDirectory,
|
||||
isGitCheckout: w.projectKind === "git",
|
||||
}));
|
||||
const workspaceDirectory = workspaceFields?.workspaceDirectory || null;
|
||||
const isGitCheckout = workspaceFields?.isGitCheckout ?? false;
|
||||
|
||||
@@ -11,7 +11,6 @@ export function PluginCatalogSync({
|
||||
serverId: string;
|
||||
client: DaemonClient;
|
||||
}) {
|
||||
const viewedTimeline = useViewedTimelineSync(serverId);
|
||||
const connected = useHostRuntimeIsConnected(serverId);
|
||||
const supported = useHostFeature(serverId, "plugins");
|
||||
|
||||
@@ -59,7 +58,7 @@ export function PluginCatalogSync({
|
||||
cancelled = true;
|
||||
unsubscribe();
|
||||
};
|
||||
}, [client, connected, serverId, supported, viewedTimeline]);
|
||||
}, [client, connected, serverId, supported]);
|
||||
|
||||
useEffect(() => () => pluginRegistry.removeHost(serverId), [serverId]);
|
||||
return null;
|
||||
|
||||
@@ -4,8 +4,10 @@ import { useCommandCenterActions } from "@/command-center/provider";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useHostRuntimeClient } from "@/runtime/host-runtime";
|
||||
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
import { useAgentBelongsToWorkspace, useWorkspaceExists } from "@/stores/session-store-hooks";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useWorkspaceExists } from "@/stores/session-store-hooks";
|
||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||
import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity";
|
||||
import { createPluginClientStateSource } from "../client-state/source";
|
||||
import { hostIdFromPathname } from "../routes";
|
||||
import { useInstalledPlugins } from "../registry";
|
||||
@@ -25,7 +27,17 @@ export function PluginCommandCenterActions() {
|
||||
workspaceKey ? (state.layoutByWorkspace[workspaceKey] ?? null) : null,
|
||||
);
|
||||
const focusedAgentId = getFocusedAgentId(layout);
|
||||
const agentExists = useAgentBelongsToWorkspace(serverId, focusedAgentId, workspaceId);
|
||||
const agentExists = useSessionStore((state) => {
|
||||
if (!serverId || !focusedAgentId) return null;
|
||||
const session = state.sessions[serverId];
|
||||
const agent =
|
||||
session?.agents.get(focusedAgentId) ?? session?.agentDetails.get(focusedAgentId) ?? null;
|
||||
return (
|
||||
Boolean(agent) &&
|
||||
Boolean(workspaceId) &&
|
||||
normalizeWorkspaceOpaqueId(agent?.workspaceId) === normalizeWorkspaceOpaqueId(workspaceId)
|
||||
);
|
||||
});
|
||||
const client = useHostRuntimeClient(serverId ?? "");
|
||||
const installed = useInstalledPlugins();
|
||||
const plugins = useMemo(
|
||||
|
||||
@@ -14,8 +14,10 @@ import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { usePaneContext } from "@/panels/pane-context";
|
||||
import { definePanel, type PanelDescriptor } from "@/panels/panel-registry";
|
||||
import { useHostRuntimeClient, useHosts } from "@/runtime/host-runtime";
|
||||
import { useAgentBelongsToWorkspace, useWorkspaceExists } from "@/stores/session-store-hooks";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useWorkspaceExists } from "@/stores/session-store-hooks";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity";
|
||||
import { usePluginHostNavigation } from "../host-navigation";
|
||||
import { createPluginClientStateSource } from "../client-state/source";
|
||||
import { toPluginTheme } from "../theme";
|
||||
@@ -42,11 +44,15 @@ function PluginPanelBody({ theme }: { theme: PluginTheme }) {
|
||||
const plugin = useInstalledPlugin(serverId, target.pluginId);
|
||||
const contribution = resolvePluginWorkspacePanel(plugin, target);
|
||||
const workspaceExists = useWorkspaceExists(serverId, workspaceId);
|
||||
const agentExists = useAgentBelongsToWorkspace(
|
||||
serverId,
|
||||
target.context === "agent" ? target.agentId : null,
|
||||
workspaceId,
|
||||
);
|
||||
const agentExists = useSessionStore((state) => {
|
||||
if (target.context !== "agent") return null;
|
||||
const session = state.sessions[serverId];
|
||||
const agent = session?.agents.get(target.agentId) ?? session?.agentDetails.get(target.agentId);
|
||||
return (
|
||||
Boolean(agent) &&
|
||||
normalizeWorkspaceOpaqueId(agent?.workspaceId) === normalizeWorkspaceOpaqueId(workspaceId)
|
||||
);
|
||||
});
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const runtime = useMemo(
|
||||
() => createPluginSurfaceRuntime(client, target.pluginId),
|
||||
|
||||
@@ -6,8 +6,8 @@ import { useHostFeatureAvailabilityMap } from "@/runtime/host-features";
|
||||
import { projectIconCache } from "@/projects/icon-cache";
|
||||
import type { ProjectIconTarget } from "@/projects/icon-target";
|
||||
import {
|
||||
getHostClient,
|
||||
useHostRuntimeConnectionStatuses,
|
||||
getHostRuntimeStore,
|
||||
isHostRuntimeConnected,
|
||||
useHostRuntimeClient,
|
||||
useHostRuntimeIsConnected,
|
||||
} from "@/runtime/host-runtime";
|
||||
@@ -80,7 +80,6 @@ export function useProjectIcons(input: {
|
||||
[input.projects],
|
||||
);
|
||||
const supportsCustomIcons = useHostFeatureAvailabilityMap(serverIds, "projectCustomIcon");
|
||||
const connectionStatuses = useHostRuntimeConnectionStatuses(serverIds);
|
||||
const requests = useMemo(() => {
|
||||
const unique = new Map<string, ProjectIconTarget>();
|
||||
for (const project of input.projects) {
|
||||
@@ -96,8 +95,8 @@ export function useProjectIcons(input: {
|
||||
...projectIconCache.query(
|
||||
request,
|
||||
supportsCustomIcons.get(request.serverId) ?? null,
|
||||
() => getHostClient(request.serverId),
|
||||
connectionStatuses.get(request.serverId) === "online",
|
||||
() => getHostRuntimeStore().getClient(request.serverId),
|
||||
isHostRuntimeConnected(getHostRuntimeStore().getSnapshot(request.serverId)),
|
||||
),
|
||||
select: iconDataUri,
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { hostSupports } from "@/runtime/session-data";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { selectHostFeature } from "@/runtime/host-features";
|
||||
|
||||
interface ProjectRemoveHost {
|
||||
serverId: string;
|
||||
@@ -54,9 +55,10 @@ export function getProjectRemoveReadiness(input: {
|
||||
export function getCurrentProjectRemoveReadiness(
|
||||
project: ProjectRemoveProject,
|
||||
): ProjectRemoveReadiness {
|
||||
const sessionState = useSessionStore.getState();
|
||||
return getProjectRemoveReadiness({
|
||||
project,
|
||||
supportsProjectRemove: (serverId) => hostSupports(serverId, "projectRemove"),
|
||||
supportsProjectRemove: (serverId) => selectHostFeature(sessionState, serverId, "projectRemove"),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useCallback, useMemo } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { useServerFeature } from "@/stores/session-store-hooks";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { providerUsageCopy } from "./copy";
|
||||
import type { ProviderUsageListPayload, ProviderUsageView } from "./types";
|
||||
|
||||
@@ -33,7 +33,9 @@ export function useProviderUsage(
|
||||
const queryClient = useQueryClient();
|
||||
const client = useHostRuntimeClient(serverId ?? "");
|
||||
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
|
||||
const supportsProviderUsage = useServerFeature(serverId, "providerUsageList");
|
||||
const supportsProviderUsage = useSessionStore(
|
||||
(state) => state.sessions[serverId ?? ""]?.serverInfo?.features?.providerUsageList === true,
|
||||
);
|
||||
const queryKey = useMemo(() => providerUsageQueryKey(serverId), [serverId]);
|
||||
const canFetch = Boolean(serverId && client && isConnected && supportsProviderUsage);
|
||||
const enabled = Boolean((options.enabled ?? true) && canFetch);
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
import type {
|
||||
Agent,
|
||||
AgentTimelineCursorState,
|
||||
ProjectDescriptor,
|
||||
WorkspaceDescriptor,
|
||||
} from "@/stores/session-store";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import { ReplicaCache } from "./internal/cache";
|
||||
import { createReplicaRowStore } from "./internal/cache/row-store-factory";
|
||||
|
||||
export type DirectoryReplicaMutation =
|
||||
| { kind: "agent"; type: "upsert"; id: string; value: Agent }
|
||||
| { kind: "agent"; type: "delete"; id: string }
|
||||
| { kind: "workspace"; type: "upsert"; id: string; value: WorkspaceDescriptor }
|
||||
| { kind: "workspace"; type: "delete"; id: string }
|
||||
| { kind: "project"; type: "upsert"; id: string; value: ProjectDescriptor }
|
||||
| { kind: "project"; type: "delete"; id: string };
|
||||
|
||||
export interface CachedDirectory {
|
||||
agents: Map<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,
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { startDesktopDaemon, type DesktopDaemonStatus } from "@/desktop/daemon/desktop-daemon";
|
||||
import { connectionFromListen } from "@/types/host-connection";
|
||||
import { getHostRuntimeStore, type HostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import type { HostRuntimeStore } from "@/runtime/host-runtime";
|
||||
|
||||
export type DaemonStartResult = { ok: true } | { ok: false; error: string };
|
||||
export type DaemonStartCondition = boolean | (() => boolean | Promise<boolean>);
|
||||
@@ -46,12 +46,6 @@ export async function upsertDesktopDaemonConnection(
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export function registerDesktopDaemonConnection(
|
||||
daemon: DesktopDaemonStatus,
|
||||
): Promise<DaemonStartResult> {
|
||||
return upsertDesktopDaemonConnection(getHostRuntimeStore(), daemon);
|
||||
}
|
||||
|
||||
export class DaemonStartService {
|
||||
private readonly store: DaemonConnectionStore;
|
||||
private readonly invokeStartDesktopDaemon: () => Promise<DesktopDaemonStatus>;
|
||||
@@ -172,7 +166,3 @@ export function getDaemonStartService(deps: DaemonStartServiceDeps): DaemonStart
|
||||
runtimeGlobal[DAEMON_START_SERVICE_GLOBAL_KEY] = singletonDaemonStartService;
|
||||
return singletonDaemonStartService;
|
||||
}
|
||||
|
||||
export function getManagedDaemonStartService(): DaemonStartService {
|
||||
return getDaemonStartService({ store: getHostRuntimeStore() });
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { DaemonClient, FetchAgentsEntry } from "@getpaseo/client/internal/d
|
||||
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
|
||||
import { selectAgentTurnPresentation, useSessionStore } from "@/stores/session-store";
|
||||
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
||||
import type { DirectoryReplicaMutation } from "@/runtime/client-replica";
|
||||
import type { DirectoryReplicaMutation } from "@/runtime/replica-cache";
|
||||
import { AgentDirectoryReplica } from "./agent-replica";
|
||||
|
||||
function payload(title: string): AgentSnapshotPayload {
|
||||
|
||||
@@ -7,7 +7,7 @@ import { normalizeAgentSnapshot, projectAgentSnapshot } from "@/utils/agent-snap
|
||||
import { type AgentDirectoryDelta } from "@/utils/agent-directory-sync";
|
||||
import { reconcileAgentDirectory } from "@/utils/agent-directory-reconciliation";
|
||||
import { applyLegacyDaemonWorkspaceOwnership } from "@/workspace/legacy-daemon-workspaces";
|
||||
import type { DirectoryReplicaMutation } from "@/runtime/client-replica";
|
||||
import type { DirectoryReplicaMutation } from "@/runtime/replica-cache";
|
||||
import type { TurnLivenessTransition } from "@/timeline/turn-liveness";
|
||||
import { AgentStoreProjection } from "./internal/agent-store";
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from "@/stores/session-store";
|
||||
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
||||
import { selectWorkspaceDirectoryServerIds } from "@/stores/session-store-hooks/selectors";
|
||||
import type { DirectoryReplicaMutation } from "@/runtime/client-replica";
|
||||
import type { DirectoryReplicaMutation } from "@/runtime/replica-cache";
|
||||
import {
|
||||
DirectoryRefreshSupersededError,
|
||||
DirectorySync,
|
||||
|
||||
@@ -37,7 +37,7 @@ import type {
|
||||
DirectoryCheckpoint,
|
||||
DirectoryCursor,
|
||||
DirectoryReplicaMutation,
|
||||
} from "@/runtime/client-replica";
|
||||
} from "@/runtime/replica-cache";
|
||||
import type { TurnLivenessTransition } from "@/timeline/turn-liveness";
|
||||
|
||||
const PAGE_LIMIT = 200;
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
shouldSuppressWorkspaceForLocalArchive,
|
||||
} from "@/contexts/session-workspace-upserts";
|
||||
import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity";
|
||||
import type { DirectoryReplicaMutation } from "@/runtime/client-replica";
|
||||
import type { DirectoryReplicaMutation } from "@/runtime/replica-cache";
|
||||
|
||||
export type WorkspaceDirectoryDelta =
|
||||
| Extract<SessionOutboundMessage, { type: "workspace_update" | "project.update" }>["payload"]
|
||||
|
||||
@@ -21,8 +21,7 @@ import {
|
||||
type HostRuntimeControllerDeps,
|
||||
type HostRuntimeStorage,
|
||||
} from "./host-runtime";
|
||||
import type { ClientReplica } from "./client-replica";
|
||||
import { drainQueuedAgentMessage } from "./session-data";
|
||||
import type { ReplicaRow, ReplicaRowStore } from "./replica-cache/row-store";
|
||||
|
||||
class FakeDaemonClient {
|
||||
private state: ConnectionState = { status: "idle" };
|
||||
@@ -461,6 +460,50 @@ function createMemoryHostRuntimeStorage(entries: Record<string, string> = {}): H
|
||||
};
|
||||
}
|
||||
|
||||
function createMemoryReplicaRowStore(): ReplicaRowStore {
|
||||
const rows = new Map<string, ReplicaRow>();
|
||||
const keyOf = (row: Pick<ReplicaRow, "serverId" | "kind" | "id">) =>
|
||||
`${row.serverId}:${row.kind}:${row.id}`;
|
||||
return {
|
||||
open: async () => undefined,
|
||||
read: async (serverId, kinds, ids) => {
|
||||
const acceptedKinds = new Set(kinds);
|
||||
const acceptedIds = ids ? new Set(ids) : null;
|
||||
return [...rows.values()].filter(
|
||||
(row) =>
|
||||
row.serverId === serverId &&
|
||||
acceptedKinds.has(row.kind) &&
|
||||
(!acceptedIds || acceptedIds.has(row.id)),
|
||||
);
|
||||
},
|
||||
readAll: async () => {
|
||||
const hosts = new Map<string, ReplicaRow[]>();
|
||||
for (const row of rows.values()) {
|
||||
const hostRows = hosts.get(row.serverId) ?? [];
|
||||
hostRows.push(row);
|
||||
hosts.set(row.serverId, hostRows);
|
||||
}
|
||||
return [...hosts].map(([serverId, hostRows]) => ({ serverId, rows: hostRows }));
|
||||
},
|
||||
apply: async (changes) => {
|
||||
for (const key of changes.deletes) rows.delete(keyOf(key));
|
||||
for (const row of changes.upserts) rows.set(keyOf(row), row);
|
||||
},
|
||||
deleteHost: async (serverId) => {
|
||||
for (const [key, row] of rows) if (row.serverId === serverId) rows.delete(key);
|
||||
},
|
||||
renameHost: async (oldServerId, newServerId) => {
|
||||
for (const [key, row] of rows) {
|
||||
if (row.serverId !== oldServerId) continue;
|
||||
rows.delete(key);
|
||||
const renamed = { ...row, serverId: newServerId };
|
||||
rows.set(keyOf(renamed), renamed);
|
||||
}
|
||||
},
|
||||
clear: async () => rows.clear(),
|
||||
};
|
||||
}
|
||||
|
||||
function createAppearanceStore(storage: HostRuntimeStorage): HostRuntimeStore {
|
||||
return new HostRuntimeStore({
|
||||
storage,
|
||||
@@ -1493,13 +1536,13 @@ describe("HostRuntimeStore", () => {
|
||||
});
|
||||
await waitForHostOnline(store, hostA.serverId);
|
||||
await waitForHostOnline(store, hostB.serverId);
|
||||
store.setAppVisible(false, null);
|
||||
store.setAppVisible(false);
|
||||
expect(clientA.reconnectEnabledChanges.at(-1)).toBe(false);
|
||||
expect(clientB.reconnectEnabledChanges.at(-1)).toBe(false);
|
||||
clientA.setConnectionState({ status: "disconnected", reason: "backgrounded" });
|
||||
clientB.setConnectionState({ status: "disconnected", reason: "backgrounded" });
|
||||
|
||||
store.setAppVisible(true, hostA.serverId);
|
||||
store.setAppVisible(true);
|
||||
expect(clientA.reconnectEnabledChanges.at(-1)).toBe(true);
|
||||
expect(clientB.reconnectEnabledChanges.at(-1)).toBe(true);
|
||||
expect(clientA.ensureConnectedCalls).toBe(1);
|
||||
@@ -1560,25 +1603,12 @@ describe("HostRuntimeStore", () => {
|
||||
const host = makeHost();
|
||||
const storage = createMemoryHostRuntimeStorage();
|
||||
let fullScans = 0;
|
||||
const clientReplica: ClientReplica = {
|
||||
hostLifecycle: {
|
||||
setHosts: () => undefined,
|
||||
reconcileServerId: () => undefined,
|
||||
flush: async () => undefined,
|
||||
},
|
||||
directory: {
|
||||
readAgent: async () => undefined,
|
||||
readWorkspace: async () => undefined,
|
||||
readDirectory: async () => {
|
||||
fullScans += 1;
|
||||
return { agents: new Map(), workspaces: new Map(), projects: new Map() };
|
||||
},
|
||||
commitDirectoryMutations: () => undefined,
|
||||
replaceDirectoryBaseline: () => undefined,
|
||||
},
|
||||
timeline: {
|
||||
readTimeline: async () => undefined,
|
||||
commitTimeline: () => undefined,
|
||||
const backingStore = createMemoryReplicaRowStore();
|
||||
const replicaRowStore: ReplicaRowStore = {
|
||||
...backingStore,
|
||||
read: async (...args) => {
|
||||
fullScans += 1;
|
||||
return backingStore.read(...args);
|
||||
},
|
||||
};
|
||||
await storage.setItem("@paseo:daemon-registry", JSON.stringify([host]));
|
||||
@@ -1587,7 +1617,7 @@ describe("HostRuntimeStore", () => {
|
||||
|
||||
const store = new HostRuntimeStore({
|
||||
storage,
|
||||
clientReplica,
|
||||
replicaRowStore,
|
||||
deps: {
|
||||
createClient: () => {
|
||||
throw new Error("createClient should not be called");
|
||||
@@ -1602,7 +1632,7 @@ describe("HostRuntimeStore", () => {
|
||||
store.boot();
|
||||
await registryLoaded;
|
||||
|
||||
expect(fullScans).toBe(1);
|
||||
await vi.waitFor(() => expect(fullScans).toBe(1));
|
||||
expect(useSessionStore.getState().sessions[host.serverId]).toMatchObject({
|
||||
client: null,
|
||||
hasHydratedAgents: false,
|
||||
@@ -2714,7 +2744,7 @@ describe("HostRuntimeStore", () => {
|
||||
const fakeClient = new FakeDaemonClient();
|
||||
const send = new Deferred<void>();
|
||||
fakeClient.sendAgentMessageResponses.push(send.promise);
|
||||
const _store = new HostRuntimeStore({
|
||||
const store = new HostRuntimeStore({
|
||||
deps: {
|
||||
createClient: () => fakeClient as unknown as DaemonClient,
|
||||
connectToDaemon: async () => ({
|
||||
@@ -2755,7 +2785,7 @@ describe("HostRuntimeStore", () => {
|
||||
]),
|
||||
);
|
||||
|
||||
drainQueuedAgentMessage(host.serverId, "agent");
|
||||
store.drainQueuedAgentMessage(host.serverId, "agent");
|
||||
await fakeClient.waitForSentMessages(1);
|
||||
|
||||
// The row and the pending submission must exist while the RPC is still in flight —
|
||||
@@ -2779,7 +2809,7 @@ describe("HostRuntimeStore", () => {
|
||||
const host = makeHost({ serverId: "srv_failed_queue_drain" });
|
||||
const fakeClient = new FakeDaemonClient();
|
||||
fakeClient.sendAgentMessageFailures.push(new Error("connection lost"));
|
||||
const _store = new HostRuntimeStore({
|
||||
const store = new HostRuntimeStore({
|
||||
deps: {
|
||||
createClient: () => fakeClient as unknown as DaemonClient,
|
||||
connectToDaemon: async () => ({
|
||||
@@ -2805,7 +2835,7 @@ describe("HostRuntimeStore", () => {
|
||||
]),
|
||||
);
|
||||
|
||||
drainQueuedAgentMessage(host.serverId, "agent");
|
||||
store.drainQueuedAgentMessage(host.serverId, "agent");
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(fakeClient.sentAgentMessages).toHaveLength(1);
|
||||
@@ -2825,7 +2855,7 @@ describe("HostRuntimeStore", () => {
|
||||
const fakeClient = new FakeDaemonClient();
|
||||
const send = new Deferred<void>();
|
||||
fakeClient.sendAgentMessageResponses.push(send.promise);
|
||||
const _store = new HostRuntimeStore({
|
||||
const store = new HostRuntimeStore({
|
||||
deps: {
|
||||
createClient: () => fakeClient as unknown as DaemonClient,
|
||||
connectToDaemon: async () => ({
|
||||
@@ -2843,8 +2873,8 @@ describe("HostRuntimeStore", () => {
|
||||
new Map([["agent", [{ id: "first", text: "send once", attachments: [] }]]]),
|
||||
);
|
||||
|
||||
drainQueuedAgentMessage(host.serverId, "agent");
|
||||
drainQueuedAgentMessage(host.serverId, "agent");
|
||||
store.drainQueuedAgentMessage(host.serverId, "agent");
|
||||
store.drainQueuedAgentMessage(host.serverId, "agent");
|
||||
await fakeClient.waitForSentMessages(1);
|
||||
expect(fakeClient.sentAgentMessages).toHaveLength(1);
|
||||
|
||||
@@ -2860,7 +2890,7 @@ describe("HostRuntimeStore", () => {
|
||||
it("uses legacy GitHub attachments when draining a queue for an old daemon", async () => {
|
||||
const host = makeHost({ serverId: "srv_legacy_queue_attachment" });
|
||||
const fakeClient = new FakeDaemonClient();
|
||||
const _store = new HostRuntimeStore({
|
||||
const store = new HostRuntimeStore({
|
||||
deps: {
|
||||
createClient: () => fakeClient as unknown as DaemonClient,
|
||||
connectToDaemon: async () => ({
|
||||
@@ -2910,7 +2940,7 @@ describe("HostRuntimeStore", () => {
|
||||
]),
|
||||
);
|
||||
|
||||
drainQueuedAgentMessage(host.serverId, "agent");
|
||||
store.drainQueuedAgentMessage(host.serverId, "agent");
|
||||
await fakeClient.waitForSentMessages(1);
|
||||
|
||||
expect(fakeClient.sentAgentMessages[0]?.[2]?.attachments).toEqual([
|
||||
|
||||
@@ -45,7 +45,13 @@ import {
|
||||
import { getDesktopHost } from "@/desktop/host";
|
||||
import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities";
|
||||
import { BROWSER_AUTOMATION_COMMAND_NAMES } from "@getpaseo/protocol/browser-automation/rpc-schemas";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import {
|
||||
useSessionStore,
|
||||
type Agent,
|
||||
type WorkspaceDescriptor,
|
||||
type ProjectDescriptor,
|
||||
} from "@/stores/session-store";
|
||||
import type { TurnLivenessTransition } from "@/timeline/turn-liveness";
|
||||
import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
|
||||
import { invalidateCheckoutGitQueriesForServer } from "@/git/query-keys";
|
||||
import { queryClient } from "@/data/query-client";
|
||||
@@ -55,9 +61,14 @@ import {
|
||||
} from "@/data/push-router";
|
||||
import { mountBrowserAutomationDaemonClientHandler } from "@/desktop/browser/automation/handler";
|
||||
import { schedulesQueryBaseKey } from "@/schedules/aggregated-schedules";
|
||||
import { dispatchComposerAgentMessage, sendQueuedComposerMessageNow } from "@/composer/actions";
|
||||
import { createMessageSubmissionWriter } from "@/composer/submission/writer";
|
||||
import { resolveComposerAttachmentSubmitFormat } from "@/composer/attachments/submit";
|
||||
import { encodeImages } from "@/utils/encode-images";
|
||||
import { DirectorySync, type RefreshAgentDirectoryResult } from "@/runtime/directory-sync";
|
||||
import { createClientReplica, type ClientReplica } from "@/runtime/client-replica";
|
||||
import { registerDefaultSessionDataOwner, SessionDataOwner } from "@/runtime/session-data";
|
||||
import { ReplicaCache } from "@/runtime/replica-cache";
|
||||
import type { ReplicaRowStore } from "@/runtime/replica-cache/row-store";
|
||||
import { createReplicaRowStore } from "@/runtime/replica-cache/row-store-factory";
|
||||
import {
|
||||
createTimelineReplica,
|
||||
createViewedTimelineOwner,
|
||||
@@ -1388,27 +1399,26 @@ export class HostRuntimeStore {
|
||||
private deps: HostRuntimeControllerDeps;
|
||||
private lastConnectionStatusByServer = new Map<string, HostRuntimeConnectionStatus>();
|
||||
private connectionStatusStartedAtByServer = new Map<string, number>();
|
||||
private readonly sessionData: SessionDataOwner;
|
||||
private queuedAgentDrainInFlight = new Set<string>();
|
||||
private directorySyncByServer = new Map<string, DirectorySync>();
|
||||
private nextCancellationRequestId = 0;
|
||||
private timelineReplicaByServer = new Map<string, TimelineReplica>();
|
||||
private configuredOverrideBootstrapInFlight: Promise<void> | null = null;
|
||||
private bootPromise: Promise<void> | null = null;
|
||||
private appVisible = true;
|
||||
private storage: HostRuntimeStorage;
|
||||
private clientReplica: ClientReplica;
|
||||
private replicaCache: ReplicaCache;
|
||||
private readonly revokePushNotifications: typeof revokePushNotifications;
|
||||
|
||||
constructor(input?: {
|
||||
deps?: HostRuntimeControllerDeps;
|
||||
storage?: HostRuntimeStorage;
|
||||
clientReplica?: ClientReplica;
|
||||
sessionData?: SessionDataOwner;
|
||||
replicaRowStore?: ReplicaRowStore;
|
||||
revokePushNotifications?: typeof revokePushNotifications;
|
||||
}) {
|
||||
this.deps = input?.deps ?? createDefaultDeps();
|
||||
this.storage = input?.storage ?? AsyncStorage;
|
||||
this.clientReplica = input?.clientReplica ?? createClientReplica();
|
||||
this.sessionData = input?.sessionData ?? new SessionDataOwner();
|
||||
registerDefaultSessionDataOwner(this.sessionData);
|
||||
this.replicaCache = new ReplicaCache(input?.replicaRowStore ?? createReplicaRowStore());
|
||||
this.revokePushNotifications = input?.revokePushNotifications ?? revokePushNotifications;
|
||||
}
|
||||
|
||||
@@ -1506,13 +1516,13 @@ export class HostRuntimeStore {
|
||||
}
|
||||
}
|
||||
this.hosts = profiles;
|
||||
this.clientReplica.hostLifecycle.setHosts(profiles.map((profile) => profile.serverId));
|
||||
this.replicaCache.setHosts(profiles.map((profile) => profile.serverId));
|
||||
projectIconCache.setHosts(profiles.map((profile) => profile.serverId));
|
||||
await projectIconCache.restore();
|
||||
this.syncHosts(profiles);
|
||||
for (const profile of profiles) {
|
||||
void this.sessionData
|
||||
.directory(profile.serverId)
|
||||
void this.directorySyncByServer
|
||||
.get(profile.serverId)
|
||||
?.restoreCachedDirectory()
|
||||
.catch(() => undefined);
|
||||
}
|
||||
@@ -1648,27 +1658,27 @@ export class HostRuntimeStore {
|
||||
rekeyMap(this.controllers, oldServerId, newServerId);
|
||||
rekeyMap(this.lastConnectionStatusByServer, oldServerId, newServerId);
|
||||
rekeyMap(this.connectionStatusStartedAtByServer, oldServerId, newServerId);
|
||||
this.clientReplica.hostLifecycle.reconcileServerId(oldServerId, newServerId);
|
||||
this.replicaCache.reconcileServerId(oldServerId, newServerId);
|
||||
projectIconCache.reconcileServerId(oldServerId, newServerId);
|
||||
this.sessionData.removeDirectory(oldServerId);
|
||||
this.directorySyncByServer.get(oldServerId)?.dispose();
|
||||
this.directorySyncByServer.delete(oldServerId);
|
||||
this.timelineReplicaByServer.delete(oldServerId);
|
||||
const directory = new DirectorySync(
|
||||
newServerId,
|
||||
{
|
||||
onAgentStoppedRunning: (agentId) =>
|
||||
this.sessionData.drainQueuedAgentMessage(newServerId, agentId),
|
||||
onAgentStoppedRunning: (agentId) => this.drainQueuedAgentMessage(newServerId, agentId),
|
||||
markAgentLoading: () => controller.markAgentDirectorySyncLoading(),
|
||||
markAgentReady: () => controller.markAgentDirectorySyncReady(),
|
||||
markAgentError: (error) => controller.markAgentDirectorySyncError(error),
|
||||
},
|
||||
this.clientReplica.directory,
|
||||
this.replicaCache,
|
||||
);
|
||||
this.sessionData.registerDirectory(newServerId, directory);
|
||||
this.directorySyncByServer.set(newServerId, directory);
|
||||
this.timelineReplicaByServer.set(
|
||||
newServerId,
|
||||
createTimelineReplica({
|
||||
serverId: newServerId,
|
||||
storage: this.clientReplica.timeline,
|
||||
storage: this.replicaCache,
|
||||
prepareAgent: (agentId) => directory.prepareAgentRoute(agentId),
|
||||
}),
|
||||
);
|
||||
@@ -2034,7 +2044,7 @@ export class HostRuntimeStore {
|
||||
>;
|
||||
},
|
||||
): void {
|
||||
this.clientReplica.hostLifecycle.setHosts(hosts.map((host) => host.serverId));
|
||||
this.replicaCache.setHosts(hosts.map((host) => host.serverId));
|
||||
projectIconCache.setHosts(hosts.map((host) => host.serverId));
|
||||
const nextIds = new Set(hosts.map((host) => host.serverId));
|
||||
for (const [serverId, controller] of this.controllers) {
|
||||
@@ -2044,7 +2054,8 @@ export class HostRuntimeStore {
|
||||
this.controllers.delete(serverId);
|
||||
this.lastConnectionStatusByServer.delete(serverId);
|
||||
this.connectionStatusStartedAtByServer.delete(serverId);
|
||||
this.sessionData.removeDirectory(serverId);
|
||||
this.directorySyncByServer.get(serverId)?.dispose();
|
||||
this.directorySyncByServer.delete(serverId);
|
||||
this.timelineReplicaByServer.delete(serverId);
|
||||
this.clearHostReplica(serverId);
|
||||
void controller.stop();
|
||||
@@ -2074,20 +2085,19 @@ export class HostRuntimeStore {
|
||||
const directory = new DirectorySync(
|
||||
host.serverId,
|
||||
{
|
||||
onAgentStoppedRunning: (agentId) =>
|
||||
this.sessionData.drainQueuedAgentMessage(host.serverId, agentId),
|
||||
onAgentStoppedRunning: (agentId) => this.drainQueuedAgentMessage(host.serverId, agentId),
|
||||
markAgentLoading: () => controller.markAgentDirectorySyncLoading(),
|
||||
markAgentReady: () => controller.markAgentDirectorySyncReady(),
|
||||
markAgentError: (error) => controller.markAgentDirectorySyncError(error),
|
||||
},
|
||||
this.clientReplica.directory,
|
||||
this.replicaCache,
|
||||
);
|
||||
this.sessionData.registerDirectory(host.serverId, directory);
|
||||
this.directorySyncByServer.set(host.serverId, directory);
|
||||
this.timelineReplicaByServer.set(
|
||||
host.serverId,
|
||||
createTimelineReplica({
|
||||
serverId: host.serverId,
|
||||
storage: this.clientReplica.timeline,
|
||||
storage: this.replicaCache,
|
||||
prepareAgent: (agentId) => directory.prepareAgentRoute(agentId),
|
||||
}),
|
||||
);
|
||||
@@ -2138,7 +2148,7 @@ export class HostRuntimeStore {
|
||||
return;
|
||||
}
|
||||
const snapshot = controller.getSnapshot();
|
||||
const directory = this.sessionData.directory(serverId);
|
||||
const directory = this.directorySyncByServer.get(serverId);
|
||||
directory?.connectionChanged({
|
||||
client: snapshot.client,
|
||||
status: snapshot.connectionStatus === "online" ? "online" : "offline",
|
||||
@@ -2170,6 +2180,107 @@ export class HostRuntimeStore {
|
||||
}
|
||||
}
|
||||
|
||||
drainQueuedAgentMessage(serverId: string, agentId: string): void {
|
||||
const drainKey = `${serverId}:${agentId}`;
|
||||
if (this.queuedAgentDrainInFlight.has(drainKey)) return;
|
||||
const store = useSessionStore.getState();
|
||||
const session = store.sessions[serverId];
|
||||
const queue = session?.queuedMessages.get(agentId);
|
||||
const client = session?.client;
|
||||
if (!client || !queue?.length || session.initializingAgents.get(agentId) === true) {
|
||||
return;
|
||||
}
|
||||
this.queuedAgentDrainInFlight.add(drainKey);
|
||||
const next = queue[0];
|
||||
void sendQueuedComposerMessageNow({
|
||||
agentId,
|
||||
messageId: next.id,
|
||||
queue: {
|
||||
read: (queuedAgentId) =>
|
||||
useSessionStore.getState().sessions[serverId]?.queuedMessages.get(queuedAgentId) ?? [],
|
||||
write: (update) => useSessionStore.getState().setQueuedMessages(serverId, update),
|
||||
},
|
||||
submitMessage: async ({ text, attachments }) => {
|
||||
const supportsForgeAttachments =
|
||||
useSessionStore.getState().sessions[serverId]?.serverInfo?.features?.forgeSearch === true;
|
||||
await dispatchComposerAgentMessage({
|
||||
client,
|
||||
agentId,
|
||||
text,
|
||||
attachments,
|
||||
attachmentSubmitFormat: resolveComposerAttachmentSubmitFormat({
|
||||
supportsForgeAttachments,
|
||||
}),
|
||||
encodeImages,
|
||||
submission: createMessageSubmissionWriter(serverId),
|
||||
});
|
||||
},
|
||||
})
|
||||
.then((result) => {
|
||||
if (result.status === "failed") {
|
||||
console.error("[HostRuntime] failed to drain queued agent message", {
|
||||
serverId,
|
||||
agentId,
|
||||
error: result.errorMessage,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
})
|
||||
.finally(() => {
|
||||
this.queuedAgentDrainInFlight.delete(drainKey);
|
||||
});
|
||||
}
|
||||
|
||||
applyAgentTurnLiveness(
|
||||
serverId: string,
|
||||
agentId: string,
|
||||
transition: TurnLivenessTransition | readonly TurnLivenessTransition[],
|
||||
): void {
|
||||
this.directorySyncByServer.get(serverId)?.applyAgentTurnLiveness(agentId, transition);
|
||||
}
|
||||
|
||||
beginAgentCancellation(serverId: string, agentId: string): number {
|
||||
const requestId = ++this.nextCancellationRequestId;
|
||||
this.applyAgentTurnLiveness(serverId, agentId, { type: "cancellation_started", requestId });
|
||||
return requestId;
|
||||
}
|
||||
|
||||
settleAgentCancellation(serverId: string, agentId: string, requestId: number): void {
|
||||
this.applyAgentTurnLiveness(serverId, agentId, { type: "cancellation_settled", requestId });
|
||||
}
|
||||
|
||||
acceptAgentSnapshot(serverId: string, agent: Agent): Agent {
|
||||
return this.requireDirectory(serverId).acceptAgent(agent);
|
||||
}
|
||||
|
||||
archiveAgentSnapshot(serverId: string, agentId: string, archivedAt: string): void {
|
||||
this.requireDirectory(serverId).archiveAgent(agentId, archivedAt);
|
||||
}
|
||||
|
||||
restoreAgentSnapshot(serverId: string, agentId: string, agent: Agent | undefined): void {
|
||||
const directory = this.requireDirectory(serverId);
|
||||
if (agent) directory.acceptAgent(agent);
|
||||
else directory.removeAgent(agentId);
|
||||
}
|
||||
|
||||
acceptWorkspaceSnapshots(serverId: string, workspaces: readonly WorkspaceDescriptor[]): void {
|
||||
this.requireDirectory(serverId).acceptWorkspaces(workspaces);
|
||||
}
|
||||
|
||||
acceptProjectSnapshot(serverId: string, project: ProjectDescriptor): void {
|
||||
this.requireDirectory(serverId).acceptProject(project);
|
||||
}
|
||||
|
||||
removeWorkspaceSnapshot(serverId: string, workspaceId: string): void {
|
||||
this.requireDirectory(serverId).removeWorkspace(workspaceId);
|
||||
}
|
||||
|
||||
private requireDirectory(serverId: string): DirectorySync {
|
||||
const directory = this.directorySyncByServer.get(serverId);
|
||||
if (!directory) throw new Error(`Unknown host runtime for serverId ${serverId}`);
|
||||
return directory;
|
||||
}
|
||||
|
||||
getSnapshot(serverId: string): HostRuntimeSnapshot | null {
|
||||
return this.controllers.get(serverId)?.getSnapshot() ?? null;
|
||||
}
|
||||
@@ -2229,14 +2340,13 @@ export class HostRuntimeStore {
|
||||
}
|
||||
}
|
||||
|
||||
setAppVisible(visible: boolean, preferredServerId: string | null): void {
|
||||
void preferredServerId;
|
||||
setAppVisible(visible: boolean): void {
|
||||
this.appVisible = visible;
|
||||
for (const controller of this.controllers.values()) {
|
||||
controller.setReconnectEnabled(visible);
|
||||
}
|
||||
if (!visible) {
|
||||
void this.clientReplica.hostLifecycle.flush();
|
||||
void this.replicaCache.flush();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2255,25 +2365,25 @@ export class HostRuntimeStore {
|
||||
async refreshAgentDirectory(
|
||||
input: AgentDirectoryRefreshInput,
|
||||
): Promise<RefreshAgentDirectoryResult> {
|
||||
const directory = this.sessionData.directory(input.serverId);
|
||||
const directory = this.directorySyncByServer.get(input.serverId);
|
||||
if (!directory) throw new Error(`Unknown host runtime for serverId ${input.serverId}`);
|
||||
return directory.refreshAgents(input);
|
||||
}
|
||||
|
||||
async refreshWorkspaceDirectory(input: { serverId: string; subscribe?: boolean }): Promise<void> {
|
||||
const directory = this.sessionData.directory(input.serverId);
|
||||
const directory = this.directorySyncByServer.get(input.serverId);
|
||||
if (!directory) throw new Error(`Unknown host runtime for serverId ${input.serverId}`);
|
||||
await directory.refreshWorkspaces({ subscribe: input.subscribe });
|
||||
}
|
||||
|
||||
async refreshDirectories(serverId: string): Promise<void> {
|
||||
const directory = this.sessionData.directory(serverId);
|
||||
const directory = this.directorySyncByServer.get(serverId);
|
||||
if (!directory) throw new Error(`Unknown host runtime for serverId ${serverId}`);
|
||||
await directory.refreshDemand();
|
||||
}
|
||||
|
||||
acquireDirectoryDemand(serverId: string): () => void {
|
||||
const directory = this.sessionData.directory(serverId);
|
||||
const directory = this.directorySyncByServer.get(serverId);
|
||||
if (!directory) return () => undefined;
|
||||
const source = {};
|
||||
directory.setDemand(source, true);
|
||||
@@ -2281,13 +2391,13 @@ export class HostRuntimeStore {
|
||||
}
|
||||
|
||||
async prepareAgentRoute(serverId: string, agentId: string): Promise<void> {
|
||||
const directory = this.sessionData.directory(serverId);
|
||||
const directory = this.directorySyncByServer.get(serverId);
|
||||
if (!directory) throw new Error(`Unknown host runtime for serverId ${serverId}`);
|
||||
await directory.prepareAgentRoute(agentId);
|
||||
}
|
||||
|
||||
async prepareWorkspaceRoute(serverId: string, workspaceId: string): Promise<void> {
|
||||
const directory = this.sessionData.directory(serverId);
|
||||
const directory = this.directorySyncByServer.get(serverId);
|
||||
if (!directory) throw new Error(`Unknown host runtime for serverId ${serverId}`);
|
||||
await directory.prepareWorkspaceRoute(workspaceId);
|
||||
}
|
||||
@@ -2303,7 +2413,7 @@ export class HostRuntimeStore {
|
||||
agentId: string,
|
||||
request: Parameters<DaemonClient["fetchAgentTimeline"]>[1],
|
||||
): Promise<Awaited<ReturnType<DaemonClient["fetchAgentTimeline"]>>> {
|
||||
const directory = this.sessionData.directory(serverId);
|
||||
const directory = this.directorySyncByServer.get(serverId);
|
||||
if (!directory) throw new Error(`Unknown host runtime for serverId ${serverId}`);
|
||||
return directory.fetchTimeline(agentId, request);
|
||||
}
|
||||
@@ -2312,7 +2422,7 @@ export class HostRuntimeStore {
|
||||
serverId: string,
|
||||
ports: ViewedTimelineOwnerPorts,
|
||||
): ViewedTimelineOwner {
|
||||
const directory = this.sessionData.directory(serverId);
|
||||
const directory = this.directorySyncByServer.get(serverId);
|
||||
if (!directory) throw new Error(`Unknown host runtime for serverId ${serverId}`);
|
||||
const replica = this.timelineReplicaByServer.get(serverId);
|
||||
if (!replica) throw new Error(`Unknown host runtime for serverId ${serverId}`);
|
||||
@@ -2320,8 +2430,7 @@ export class HostRuntimeStore {
|
||||
serverId,
|
||||
replica,
|
||||
replaceDemandedAgentIds: (agentIds) => directory.setAgentRouteDemand(agentIds),
|
||||
drainQueuedAgentMessage: (agentId) =>
|
||||
this.sessionData.drainQueuedAgentMessage(serverId, agentId),
|
||||
drainQueuedAgentMessage: (agentId) => this.drainQueuedAgentMessage(serverId, agentId),
|
||||
ports,
|
||||
});
|
||||
}
|
||||
@@ -2346,7 +2455,6 @@ export class HostRuntimeStore {
|
||||
|
||||
let singletonHostRuntimeStore: HostRuntimeStore | null = null;
|
||||
const HOST_RUNTIME_STORE_GLOBAL_KEY = "__paseoHostRuntimeStore";
|
||||
const SESSION_DATA_OWNER_GLOBAL_KEY = "__paseoSessionDataOwner";
|
||||
|
||||
export function getHostRuntimeStore(): HostRuntimeStore {
|
||||
if (singletonHostRuntimeStore) {
|
||||
@@ -2355,19 +2463,12 @@ export function getHostRuntimeStore(): HostRuntimeStore {
|
||||
|
||||
const existing = Reflect.get(globalThis, HOST_RUNTIME_STORE_GLOBAL_KEY);
|
||||
if (existing instanceof HostRuntimeStore) {
|
||||
const existingSessionData = Reflect.get(globalThis, SESSION_DATA_OWNER_GLOBAL_KEY);
|
||||
if (existingSessionData instanceof SessionDataOwner) {
|
||||
registerDefaultSessionDataOwner(existingSessionData);
|
||||
}
|
||||
singletonHostRuntimeStore = existing;
|
||||
return existing;
|
||||
}
|
||||
|
||||
const sessionData = new SessionDataOwner();
|
||||
registerDefaultSessionDataOwner(sessionData);
|
||||
singletonHostRuntimeStore = new HostRuntimeStore({ sessionData });
|
||||
singletonHostRuntimeStore = new HostRuntimeStore();
|
||||
Reflect.set(globalThis, HOST_RUNTIME_STORE_GLOBAL_KEY, singletonHostRuntimeStore);
|
||||
Reflect.set(globalThis, SESSION_DATA_OWNER_GLOBAL_KEY, sessionData);
|
||||
return singletonHostRuntimeStore;
|
||||
}
|
||||
|
||||
@@ -2488,82 +2589,6 @@ export function useHostRegistryLoaded(): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
export function getHostClient(serverId: string): DaemonClient | null {
|
||||
return getHostRuntimeStore().getClient(serverId);
|
||||
}
|
||||
|
||||
export function probeHostConnections(serverId?: string): Promise<void> {
|
||||
return getHostRuntimeStore().runProbeCycleNow(serverId);
|
||||
}
|
||||
|
||||
export function prepareAgentRoute(serverId: string, agentId: string): Promise<void> {
|
||||
return getHostRuntimeStore().prepareAgentRoute(serverId, agentId);
|
||||
}
|
||||
|
||||
export function prepareWorkspaceRoute(serverId: string, workspaceId: string): Promise<void> {
|
||||
return getHostRuntimeStore().prepareWorkspaceRoute(serverId, workspaceId);
|
||||
}
|
||||
|
||||
export function prepareAgentTimeline(serverId: string, agentId: string): Promise<void> {
|
||||
return getHostRuntimeStore().prepareAgentTimeline(serverId, agentId);
|
||||
}
|
||||
|
||||
export function createViewedTimelineOwnerForHost(
|
||||
serverId: string,
|
||||
ports: ViewedTimelineOwnerPorts,
|
||||
): ViewedTimelineOwner {
|
||||
return getHostRuntimeStore().createViewedTimelineOwner(serverId, ports);
|
||||
}
|
||||
|
||||
export function fetchAgentTimeline(
|
||||
serverId: string,
|
||||
agentId: string,
|
||||
request: Parameters<DaemonClient["fetchAgentTimeline"]>[1],
|
||||
): Promise<Awaited<ReturnType<DaemonClient["fetchAgentTimeline"]>>> {
|
||||
return getHostRuntimeStore().fetchAgentTimeline(serverId, agentId, request);
|
||||
}
|
||||
|
||||
export const agentTimelineRuntime = { fetchAgentTimeline };
|
||||
|
||||
export function setAppVisible(visible: boolean, preferredServerId: string | null): void {
|
||||
getHostRuntimeStore().setAppVisible(visible, preferredServerId);
|
||||
}
|
||||
|
||||
export function useHostRuntimeVersion(): number {
|
||||
const store = getHostRuntimeStore();
|
||||
return useSyncExternalStore(
|
||||
(listener) => store.subscribeAll(listener),
|
||||
() => store.getVersion(),
|
||||
() => store.getVersion(),
|
||||
);
|
||||
}
|
||||
|
||||
export function readHostRuntimeSnapshot(serverId: string): HostRuntimeSnapshot | null {
|
||||
return getHostRuntimeStore().getSnapshot(serverId);
|
||||
}
|
||||
|
||||
export function acquireDirectoryDemand(serverId: string): () => void {
|
||||
return getHostRuntimeStore().acquireDirectoryDemand(serverId);
|
||||
}
|
||||
|
||||
export function refreshHostDirectories(serverId: string): Promise<void> {
|
||||
return getHostRuntimeStore().refreshDirectories(serverId);
|
||||
}
|
||||
|
||||
export const scheduleRuntime = {
|
||||
getClient: getHostClient,
|
||||
getSnapshot: readHostRuntimeSnapshot,
|
||||
};
|
||||
|
||||
export function useEarliestOnlineHostServerId(): string | null {
|
||||
const store = getHostRuntimeStore();
|
||||
return useSyncExternalStore(
|
||||
(listener) => store.subscribeAll(listener),
|
||||
() => store.getEarliestOnlineHostServerId(),
|
||||
() => store.getEarliestOnlineHostServerId(),
|
||||
);
|
||||
}
|
||||
|
||||
export interface HostMutations {
|
||||
upsertDirectConnection: (input: {
|
||||
serverId: string;
|
||||
|
||||
+32
-1
@@ -8,7 +8,7 @@ import {
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
||||
import { ReplicaCache } from ".";
|
||||
import type { DirectoryCheckpoint } from "@/runtime/client-replica";
|
||||
import type { DirectoryCheckpoint } from "@/runtime/replica-cache";
|
||||
import type { ReplicaHostRows, ReplicaRow, ReplicaRowChanges, ReplicaRowStore } from "./row-store";
|
||||
|
||||
const SERVER_ID = "cached-host";
|
||||
@@ -269,6 +269,37 @@ describe("ReplicaCache", () => {
|
||||
expect(restoredTimeline).toEqual(timeline());
|
||||
});
|
||||
|
||||
it("preserves pending timeline updates across directory baseline replacement", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const writer = createCache(storage);
|
||||
writer.commitTimeline(SERVER_ID, "agent-1", timeline("Latest visible reply"));
|
||||
writer.replaceDirectoryBaseline(SERVER_ID, directory());
|
||||
await writer.flush();
|
||||
|
||||
const reader = createCache(storage);
|
||||
expect(await reader.readTimeline(SERVER_ID, "agent-1")).toEqual(
|
||||
timeline("Latest visible reply"),
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves clearing a timeline across directory baseline replacement", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const writer = createCache(storage);
|
||||
writer.commitTimeline(SERVER_ID, "agent-1", timeline());
|
||||
await writer.flush();
|
||||
writer.commitTimeline(SERVER_ID, "agent-1", { ...timeline(), items: [], range: null });
|
||||
writer.replaceDirectoryBaseline(SERVER_ID, directory());
|
||||
await writer.flush();
|
||||
|
||||
const reader = createCache(storage);
|
||||
expect(await reader.readTimeline(SERVER_ID, "agent-1")).toEqual({
|
||||
...timeline(),
|
||||
items: [],
|
||||
range: null,
|
||||
hasOlder: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("serializes and writes only keyed directory mutations", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const cache = createCache(storage);
|
||||
+46
-8
@@ -10,6 +10,7 @@ import {
|
||||
normalizeProjectDescriptor,
|
||||
normalizeWorkspaceDescriptor,
|
||||
type Agent,
|
||||
type AgentTimelineCursorState,
|
||||
type ProjectDescriptor,
|
||||
type WorkspaceDescriptor,
|
||||
} from "@/stores/session-store";
|
||||
@@ -18,13 +19,6 @@ import {
|
||||
type AgentToolCallData,
|
||||
type StreamItem,
|
||||
} from "@/types/stream";
|
||||
import type {
|
||||
CachedDirectory,
|
||||
CachedTimeline,
|
||||
CachedWorkspace,
|
||||
DirectoryCheckpoint,
|
||||
DirectoryReplicaMutation,
|
||||
} from "@/runtime/client-replica";
|
||||
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
||||
import { clearLegacyReplicaCache } from "./legacy-cleanup";
|
||||
import {
|
||||
@@ -36,6 +30,44 @@ import {
|
||||
type ReplicaRowStore,
|
||||
} from "./row-store";
|
||||
|
||||
export type DirectoryReplicaMutation =
|
||||
| { kind: "agent"; type: "upsert"; id: string; value: Agent }
|
||||
| { kind: "agent"; type: "delete"; id: string }
|
||||
| { kind: "workspace"; type: "upsert"; id: string; value: WorkspaceDescriptor }
|
||||
| { kind: "workspace"; type: "delete"; id: string }
|
||||
| { kind: "project"; type: "upsert"; id: string; value: ProjectDescriptor }
|
||||
| { kind: "project"; type: "delete"; id: string };
|
||||
|
||||
export interface CachedDirectory {
|
||||
agents: Map<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;
|
||||
}
|
||||
|
||||
const PERSIST_DELAY_MS = 1_000;
|
||||
const MAX_TIMELINE_ITEMS = 50;
|
||||
const MAX_CACHE_BYTES = 32 * 1024 * 1024;
|
||||
@@ -1071,7 +1103,13 @@ export class ReplicaCache {
|
||||
replaceDirectoryBaseline(serverId: string, directory: CachedDirectory): void {
|
||||
if (!this.activeServerIds.has(serverId)) return;
|
||||
this.advanceHostRevision(serverId);
|
||||
this.dropPendingHostChanges(serverId);
|
||||
// Replacing the directory must not discard independently accepted timeline changes.
|
||||
for (const [key, row] of this.pendingUpserts) {
|
||||
if (row.serverId === serverId && row.kind !== "timeline") this.pendingUpserts.delete(key);
|
||||
}
|
||||
for (const [key, row] of this.pendingDeletes) {
|
||||
if (row.serverId === serverId && row.kind !== "timeline") this.pendingDeletes.delete(key);
|
||||
}
|
||||
this.pendingBaselines.add(serverId);
|
||||
for (const [id, value] of directory.agents) {
|
||||
this.queueUpsert({ serverId, kind: "agent", id, value });
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user