Scheduled runs each get their own workspace in the sidebar (#1934)
* feat(schedules): per-run workspaces, isolation and archive controls Scheduled agents never appeared in the sidebar: each schedule reused one stamped workspace and archived the run's agent on completion. Now every run mints its own workspace — the entity the sidebar shows — with per-schedule controls for isolation (local or worktree) and whether to archive that workspace when the run finishes. The schedule form is rebuilt on a non-React form model (open/commands/close) to end the flicker, edit-into-create contamination, and multi-host hydration bugs that came from effect-choreographed shared state. That lands the reusable foundations it needed: a form kit over a single control-geometry owner, a data-access taxonomy (replica/snapshot/fetch) with real load-state semantics, and lint/boundary guardrails so new screens inherit the paved road. Also fixes desktop combobox popovers truncating options when the trigger is narrower than the content. New schedule protocol fields are optional on both create and update, so old and new clients/daemons stay compatible. * test(app): complete mock themes for control-geometry tokens switch and terminal-profile-edit-modal now style through createControlGeometry, which reads theme.borderWidth[1], theme.opacity[50], and theme.colors.borderAccent. The hand-rolled mock themes in these two suites lacked those tokens, so the eager StyleSheet.create mock threw at import (collection error), not on any assertion. Add the missing tokens. * fix(schedules): address review findings on run cleanup, edit isolation, load error, sheet dismiss, and preference hydration - Server: archive the run's workspace even when agent creation fails, so a misconfigured provider no longer orphans a sidebar workspace/worktree. - Edit form: keep a stored worktree isolation while worktree eligibility is still resolving, instead of silently downgrading a saved schedule to local. - Schedules screen: show the load error/retry UI when every host fails, rather than spinning forever behind the loading branch. - Form sheet: fire the parent onClose on native gesture/backdrop dismiss so the sheet closes instead of re-opening. - Form model: apply late-loading saved preferences to untouched create-mode fields (never to edited schedules or user-modified fields). * fix(schedules): review batch — reconnect resubscribe, worktree prune, Android dismiss, cadence and provider edit safety - Push-router: re-send terminal and checkout-diff subscriptions after a reconnect; the old per-hook effect resubscribed on isConnected and the extracted router did not, so daemon restarts silently stopped terminals_changed pushes (root cause of the terminal-activity e2e failures). - Server: pass the run's source repo root when archiving worktree runs so the worktree is unregistered (git worktree remove/prune), not just deleted; also archive the workspace when agent creation fails even with archive-off (an agentless workspace has nothing to inspect). - Sheet: RN Modal onDismiss is iOS-only — notify dismiss once-per-close on the Android native path too; replace the JSDOM component test with a real Playwright dismiss spec and inline the trivial dismiss decision. - Form model: editing an interval schedule no longer rewrites its cadence to cron unless the cadence UI is touched (90-minute intervals have no cron equivalent); switching projects clears stale provider/model state and gates submit until the new host's snapshot resolves. * chore(lint): drop custom lint wrapper and boundary script, plain oxlint The import bans stay in .oxlintrc.json (oxlint-native no-restricted-imports). The receiver-sensitive checks the custom script enforced are not worth a parallel lint pipeline; if they come back it will be as oxlint rules. * fix(schedules): crash-safe run cleanup, capability-gated run options, resilient mutations - Persist the run's workspace/agent ids on the running-run record so a daemon crash mid-run can be recovered: restart archives the interrupted run's workspace under the same policy as normal cleanup (agentless always archives, otherwise archiveOnFinish is respected). - Hide Archive on finish (and omit archiveOnFinish/isolation from payloads) on hosts without workspaceMultiplicity — an old daemon ignores the fields, so offering the switch there would lie. - Optimistic pause/resume/delete no longer throw when the schedules cache holds a still-connecting entry alongside loaded data. - Mode field is gated on provider mode options, not a selected model, so model-less providers can pick their advertised modes.
This commit is contained in:
@@ -1,51 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
isProvidersSnapshotHomeScope,
|
||||
normalizeProvidersSnapshotCwd,
|
||||
providersSnapshotQueryKey,
|
||||
providersSnapshotQueryRoot,
|
||||
providersSnapshotRequestOptions,
|
||||
} from "./providers-snapshot-query";
|
||||
|
||||
describe("providers snapshot query scope", () => {
|
||||
it("normalizes blank cwd values to the home scope", () => {
|
||||
expect(normalizeProvidersSnapshotCwd(undefined)).toBeNull();
|
||||
expect(normalizeProvidersSnapshotCwd(null)).toBeNull();
|
||||
expect(normalizeProvidersSnapshotCwd(" ")).toBeNull();
|
||||
expect(isProvidersSnapshotHomeScope("")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps home and workspace query keys separate under one server root", () => {
|
||||
expect(providersSnapshotQueryRoot("server-1")).toEqual(["providersSnapshot", "server-1"]);
|
||||
expect(providersSnapshotQueryKey("server-1")).toEqual([
|
||||
"providersSnapshot",
|
||||
"server-1",
|
||||
"home",
|
||||
]);
|
||||
expect(providersSnapshotQueryKey("server-1", "/repo-a")).toEqual([
|
||||
"providersSnapshot",
|
||||
"server-1",
|
||||
"cwd",
|
||||
"/repo-a",
|
||||
]);
|
||||
});
|
||||
|
||||
it("builds request options with cwd only for workspace scopes", () => {
|
||||
expect(providersSnapshotRequestOptions({ cwd: null, providers: ["codex"] })).toEqual({
|
||||
providers: ["codex"],
|
||||
});
|
||||
expect(providersSnapshotRequestOptions({ cwd: "/repo-a", providers: ["codex"] })).toEqual({
|
||||
cwd: "/repo-a",
|
||||
providers: ["codex"],
|
||||
});
|
||||
});
|
||||
|
||||
it("uses one query scope for Windows cwd values with either separator", () => {
|
||||
expect(normalizeProvidersSnapshotCwd("C:\\Users\\Ezekiel Bulver\\project")).toBe(
|
||||
"C:/Users/Ezekiel Bulver/project",
|
||||
);
|
||||
expect(providersSnapshotQueryKey("server-1", "C:\\Users\\Ezekiel Bulver\\project")).toEqual(
|
||||
providersSnapshotQueryKey("server-1", "C:/Users/Ezekiel Bulver/project"),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,34 +0,0 @@
|
||||
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
|
||||
import { normalizeWorkspacePath } from "@/utils/workspace-identity";
|
||||
|
||||
export const PROVIDERS_SNAPSHOT_QUERY_ROOT = "providersSnapshot";
|
||||
|
||||
export function normalizeProvidersSnapshotCwd(cwd?: string | null): string | null {
|
||||
return normalizeWorkspacePath(cwd);
|
||||
}
|
||||
|
||||
export function providersSnapshotQueryRoot(serverId: string | null) {
|
||||
return [PROVIDERS_SNAPSHOT_QUERY_ROOT, serverId] as const;
|
||||
}
|
||||
|
||||
export function providersSnapshotQueryKey(serverId: string | null, cwd?: string | null) {
|
||||
const normalizedCwd = normalizeProvidersSnapshotCwd(cwd);
|
||||
return normalizedCwd
|
||||
? ([PROVIDERS_SNAPSHOT_QUERY_ROOT, serverId, "cwd", normalizedCwd] as const)
|
||||
: ([PROVIDERS_SNAPSHOT_QUERY_ROOT, serverId, "home"] as const);
|
||||
}
|
||||
|
||||
export function providersSnapshotRequestOptions(input: {
|
||||
cwd?: string | null;
|
||||
providers?: AgentProvider[];
|
||||
}) {
|
||||
const normalizedCwd = normalizeProvidersSnapshotCwd(input.cwd);
|
||||
return {
|
||||
...(normalizedCwd ? { cwd: normalizedCwd } : {}),
|
||||
...(input.providers ? { providers: input.providers } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function isProvidersSnapshotHomeScope(cwd?: string | null): boolean {
|
||||
return normalizeProvidersSnapshotCwd(cwd) === null;
|
||||
}
|
||||
@@ -27,11 +27,13 @@ import {
|
||||
combineInitialValues,
|
||||
buildProviderDefinitionMap,
|
||||
buildProviderDefinitionMapForStatuses,
|
||||
INITIAL_AGENT_FORM_RESOLUTION,
|
||||
INITIAL_USER_MODIFIED,
|
||||
RESOLVABLE_PROVIDER_STATUSES,
|
||||
SELECTABLE_PROVIDER_STATUSES,
|
||||
type FormInitialValues,
|
||||
type FormState,
|
||||
type ProviderModelsByProvider,
|
||||
} from "@/provider-selection/resolve-agent-form";
|
||||
|
||||
export type { FormInitialValues } from "@/provider-selection/resolve-agent-form";
|
||||
@@ -85,7 +87,6 @@ function shouldAutoSelectServerId(input: {
|
||||
isVisible: boolean;
|
||||
isCreateFlow: boolean;
|
||||
isPreferencesLoading: boolean;
|
||||
hasResolved: boolean;
|
||||
userModifiedServerId: boolean;
|
||||
initialServerId: string | null | undefined;
|
||||
currentServerId: string | null;
|
||||
@@ -94,20 +95,47 @@ function shouldAutoSelectServerId(input: {
|
||||
isVisible,
|
||||
isCreateFlow,
|
||||
isPreferencesLoading,
|
||||
hasResolved,
|
||||
userModifiedServerId,
|
||||
initialServerId,
|
||||
currentServerId,
|
||||
} = input;
|
||||
if (!isVisible || !isCreateFlow) return false;
|
||||
if (isPreferencesLoading) return false;
|
||||
if (!hasResolved) return false;
|
||||
if (userModifiedServerId) return false;
|
||||
if (initialServerId !== undefined) return false;
|
||||
if (currentServerId) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function resolutionIntentKeyPart(value: string | null | undefined): string {
|
||||
if (value === undefined) return "undefined";
|
||||
if (value === null) return "null";
|
||||
return value;
|
||||
}
|
||||
|
||||
function buildResolutionIntentKey(initialValues: FormInitialValues | undefined): string {
|
||||
if (!initialValues) return "none";
|
||||
// workingDir seeds the open request, but locked cwd updates must not re-run
|
||||
// provider/model/mode resolution after the form has settled.
|
||||
return [
|
||||
resolutionIntentKeyPart(initialValues.serverId),
|
||||
resolutionIntentKeyPart(initialValues.provider),
|
||||
resolutionIntentKeyPart(initialValues.modeId),
|
||||
resolutionIntentKeyPart(initialValues.model),
|
||||
resolutionIntentKeyPart(initialValues.thinkingOptionId),
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function hasSnapshotDataForResolution(input: {
|
||||
serverId: string | null;
|
||||
snapshotEntries: ProviderSnapshotEntry[] | undefined;
|
||||
}): boolean {
|
||||
if (!input.serverId) {
|
||||
return false;
|
||||
}
|
||||
return input.snapshotEntries !== undefined;
|
||||
}
|
||||
|
||||
function resolveSelectedProviderModes(input: {
|
||||
selectedEntry: ProviderSnapshotEntry | null;
|
||||
provider: AgentProvider | null;
|
||||
@@ -133,6 +161,16 @@ function buildAllProviderModels(
|
||||
return map;
|
||||
}
|
||||
|
||||
function buildProviderModelsByProvider(
|
||||
snapshotEntries: ProviderSnapshotEntry[] | undefined,
|
||||
): ProviderModelsByProvider {
|
||||
const map: ProviderModelsByProvider = new Map();
|
||||
for (const entry of snapshotEntries ?? []) {
|
||||
map.set(entry.provider, entry.models ?? null);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
async function persistProviderPreferences(input: {
|
||||
provider: AgentProvider;
|
||||
formState: FormState;
|
||||
@@ -175,7 +213,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
|
||||
const validServerIds = useMemo(() => new Set(daemons.map((d) => d.serverId)), [daemons]);
|
||||
|
||||
const [{ form: formState, userModified }, dispatch] = useReducer(
|
||||
const [{ form: formState, userModified, resolution }, dispatch] = useReducer(
|
||||
resolveAgentForm,
|
||||
initialServerId,
|
||||
(serverId) => ({
|
||||
@@ -188,6 +226,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
workingDir: "",
|
||||
},
|
||||
userModified: INITIAL_USER_MODIFIED,
|
||||
resolution: INITIAL_AGENT_FORM_RESOLUTION,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -196,14 +235,9 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
reducerStateRef.current = { form: formState, userModified };
|
||||
}, [formState, userModified]);
|
||||
|
||||
const hasResolvedRef = useRef(false);
|
||||
const hydrationPreferencesRef = useRef<FormPreferences | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) {
|
||||
dispatch({ type: "RESET" });
|
||||
hasResolvedRef.current = false;
|
||||
hydrationPreferencesRef.current = null;
|
||||
}
|
||||
}, [isVisible]);
|
||||
|
||||
@@ -245,6 +279,10 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
() => buildAllProviderModels(snapshotEntries),
|
||||
[snapshotEntries],
|
||||
);
|
||||
const snapshotProviderModelsByProvider = useMemo(
|
||||
() => buildProviderModelsByProvider(snapshotEntries),
|
||||
[snapshotEntries],
|
||||
);
|
||||
const snapshotModelSelectorProviders = useMemo(
|
||||
() => buildSelectableProviderSelectorProviders(snapshotEntries),
|
||||
[snapshotEntries],
|
||||
@@ -276,37 +314,52 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
() => combineInitialValues(initialValues, initialServerId),
|
||||
[initialValues, initialServerId],
|
||||
);
|
||||
const resolutionIntentKey = useMemo(
|
||||
() => buildResolutionIntentKey(combinedInitialValues),
|
||||
[combinedInitialValues],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible || !isCreateFlow) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isPreferencesLoading && !hasResolvedRef.current) {
|
||||
dispatch({ type: "REQUEST_RESOLUTION" });
|
||||
}, [isVisible, isCreateFlow, resolutionIntentKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible || !isCreateFlow || resolution.status !== "pending") {
|
||||
return;
|
||||
}
|
||||
if (isPreferencesLoading) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!hasSnapshotDataForResolution({
|
||||
serverId: formState.serverId,
|
||||
snapshotEntries,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasResolvedRef.current) {
|
||||
hydrationPreferencesRef.current = preferences;
|
||||
}
|
||||
const hydrationPreferences = hydrationPreferencesRef.current ?? preferences;
|
||||
|
||||
dispatch({
|
||||
type: "RESOLVE",
|
||||
type: "COMPLETE_RESOLUTION",
|
||||
initialValues: combinedInitialValues,
|
||||
preferences: hydrationPreferences,
|
||||
availableModels,
|
||||
preferences,
|
||||
providerModelsByProvider: snapshotProviderModelsByProvider,
|
||||
allowedProviderMap: snapshotResolvableProviderDefinitionMap,
|
||||
});
|
||||
|
||||
hasResolvedRef.current = true;
|
||||
}, [
|
||||
isVisible,
|
||||
combinedInitialValues,
|
||||
formState.serverId,
|
||||
isCreateFlow,
|
||||
isPreferencesLoading,
|
||||
combinedInitialValues,
|
||||
isVisible,
|
||||
preferences,
|
||||
availableModels,
|
||||
resolution.status,
|
||||
snapshotEntries,
|
||||
snapshotProviderModelsByProvider,
|
||||
snapshotResolvableProviderDefinitionMap,
|
||||
]);
|
||||
|
||||
@@ -316,10 +369,9 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
isVisible,
|
||||
isCreateFlow,
|
||||
isPreferencesLoading,
|
||||
hasResolved: hasResolvedRef.current,
|
||||
userModifiedServerId: userModified.serverId,
|
||||
initialServerId: combinedInitialValues?.serverId,
|
||||
currentServerId: reducerStateRef.current.form.serverId,
|
||||
currentServerId: formState.serverId,
|
||||
});
|
||||
if (!canAutoSelectServerId) return;
|
||||
|
||||
@@ -334,6 +386,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
isVisible,
|
||||
onlineServerIds,
|
||||
onlineServerIdsKey,
|
||||
formState.serverId,
|
||||
userModified.serverId,
|
||||
validServerIds,
|
||||
]);
|
||||
@@ -509,7 +562,6 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
? providerDefinitionMap.get(formState.provider)
|
||||
: undefined;
|
||||
const effectiveModel = resolveEffectiveModel(availableModels, formState.model);
|
||||
const resolvedModelId = effectiveModel?.id ?? formState.model;
|
||||
const availableThinkingOptionsRaw = effectiveModel?.thinkingOptions;
|
||||
const availableThinkingOptions = useMemo(
|
||||
() => availableThinkingOptionsRaw ?? [],
|
||||
@@ -529,7 +581,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
setProviderFromUser,
|
||||
selectedMode: formState.modeId,
|
||||
setModeFromUser,
|
||||
selectedModel: resolvedModelId,
|
||||
selectedModel: formState.model,
|
||||
setModelFromUser,
|
||||
selectedThinkingOptionId: formState.thinkingOptionId,
|
||||
setThinkingOptionFromUser,
|
||||
@@ -560,7 +612,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
formState.serverId,
|
||||
formState.provider,
|
||||
formState.modeId,
|
||||
resolvedModelId,
|
||||
formState.model,
|
||||
formState.thinkingOptionId,
|
||||
formState.workingDir,
|
||||
setSelectedServerId,
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { MutableDaemonConfig, MutableDaemonConfigPatch } from "@getpaseo/protocol/messages";
|
||||
import { useReplicaQuery } from "@/data/query";
|
||||
import { daemonConfigQueryKey } from "@/data/daemon-config";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
|
||||
export function daemonConfigQueryKey(serverId: string | null) {
|
||||
return ["daemon-config", serverId] as const;
|
||||
}
|
||||
|
||||
interface UseDaemonConfigResult {
|
||||
config: MutableDaemonConfig | null;
|
||||
isLoading: boolean;
|
||||
@@ -21,10 +19,10 @@ export function useDaemonConfig(serverId: string | null): UseDaemonConfigResult
|
||||
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
|
||||
const queryKey = useMemo(() => daemonConfigQueryKey(serverId), [serverId]);
|
||||
|
||||
const configQuery = useQuery({
|
||||
const configQuery = useReplicaQuery({
|
||||
queryKey,
|
||||
enabled: Boolean(serverId && client && isConnected),
|
||||
staleTime: Infinity,
|
||||
pushEvent: "status:daemon_config_changed",
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
throw new Error(t("workspace.terminal.hostDisconnected"));
|
||||
@@ -34,22 +32,6 @@ export function useDaemonConfig(serverId: string | null): UseDaemonConfigResult
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!client || !isConnected || !serverId) {
|
||||
return;
|
||||
}
|
||||
|
||||
return client.on("status", (message) => {
|
||||
if (message.type !== "status") {
|
||||
return;
|
||||
}
|
||||
if (message.payload.status !== "daemon_config_changed") {
|
||||
return;
|
||||
}
|
||||
queryClient.setQueryData(queryKey, message.payload.config as MutableDaemonConfig);
|
||||
});
|
||||
}, [client, isConnected, queryClient, queryKey, serverId]);
|
||||
|
||||
const patchConfig = useCallback(
|
||||
async (patch: MutableDaemonConfigPatch) => {
|
||||
if (!client) {
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { useCallback } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { projectsQueryKey } from "@/hooks/use-projects";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { openProjectDirectly, type OpenProjectResult } from "@/hooks/open-project";
|
||||
|
||||
@@ -11,7 +9,6 @@ export function useOpenProject(
|
||||
const normalizedServerId = serverId?.trim() ?? "";
|
||||
const client = useHostRuntimeClient(normalizedServerId);
|
||||
const isConnected = useHostRuntimeIsConnected(normalizedServerId);
|
||||
const queryClient = useQueryClient();
|
||||
const canAddProject = useSessionStore((state) =>
|
||||
normalizedServerId
|
||||
? state.sessions[normalizedServerId]?.serverInfo?.features?.projectAdd === true
|
||||
@@ -31,13 +28,6 @@ export function useOpenProject(
|
||||
addEmptyProject,
|
||||
setHasHydratedWorkspaces,
|
||||
});
|
||||
// The aggregated projects query derives the project list from a fetch
|
||||
// that now includes empty projects; refetch so a freshly-added project
|
||||
// (no workspace yet) is immediately editable instead of only after a
|
||||
// restart.
|
||||
if (result.ok) {
|
||||
void queryClient.invalidateQueries({ queryKey: projectsQueryKey });
|
||||
}
|
||||
return result;
|
||||
},
|
||||
[
|
||||
@@ -46,7 +36,6 @@ export function useOpenProject(
|
||||
client,
|
||||
isConnected,
|
||||
normalizedServerId,
|
||||
queryClient,
|
||||
setHasHydratedWorkspaces,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1,92 +1,48 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { WorkspaceDescriptorPayload } from "@getpaseo/protocol/messages";
|
||||
import type { EmptyProjectDescriptor, WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import {
|
||||
fetchAggregatedProjects,
|
||||
type ProjectsHostInput,
|
||||
type ProjectsRuntime,
|
||||
type ProjectsRuntimeSnapshot,
|
||||
} from "@/projects/aggregated-projects";
|
||||
deriveProjectsFromReplica,
|
||||
type ProjectHostReplica,
|
||||
type ProjectHostRuntimeState,
|
||||
} from "@/hooks/use-projects";
|
||||
|
||||
type FetchWorkspaces = DaemonClient["fetchWorkspaces"];
|
||||
type FetchWorkspacesResult = Awaited<ReturnType<FetchWorkspaces>>;
|
||||
|
||||
interface HostFixture {
|
||||
function runtimeState(input: {
|
||||
serverId: string;
|
||||
serverName: string;
|
||||
status: "online" | "offline" | "missing-snapshot";
|
||||
workspaces?: WorkspaceDescriptorPayload[] | Error;
|
||||
}
|
||||
|
||||
interface RuntimeAdapter extends ProjectsRuntime {
|
||||
fetchCalls: Map<string, number>;
|
||||
}
|
||||
|
||||
function createRuntime(hosts: HostFixture[]): RuntimeAdapter {
|
||||
const snapshots = new Map<string, ProjectsRuntimeSnapshot | null>();
|
||||
const clients = new Map<string, Pick<DaemonClient, "fetchWorkspaces"> | null>();
|
||||
const fetchCalls = new Map<string, number>();
|
||||
|
||||
for (const host of hosts) {
|
||||
if (host.status === "missing-snapshot") {
|
||||
snapshots.set(host.serverId, null);
|
||||
} else {
|
||||
snapshots.set(host.serverId, { connectionStatus: host.status });
|
||||
}
|
||||
|
||||
if (host.workspaces === undefined) {
|
||||
clients.set(host.serverId, null);
|
||||
continue;
|
||||
}
|
||||
|
||||
const workspaces = host.workspaces;
|
||||
const fetchWorkspaces: FetchWorkspaces = async () => {
|
||||
fetchCalls.set(host.serverId, (fetchCalls.get(host.serverId) ?? 0) + 1);
|
||||
if (workspaces instanceof Error) {
|
||||
throw workspaces;
|
||||
}
|
||||
return {
|
||||
requestId: `req-${host.serverId}`,
|
||||
entries: workspaces,
|
||||
emptyProjects: [],
|
||||
pageInfo: { nextCursor: null, prevCursor: null, hasMore: false },
|
||||
} satisfies FetchWorkspacesResult;
|
||||
};
|
||||
|
||||
clients.set(host.serverId, { fetchWorkspaces });
|
||||
}
|
||||
|
||||
isOnline: boolean;
|
||||
isLoading?: boolean;
|
||||
isFetching?: boolean;
|
||||
error?: string | null;
|
||||
}): ProjectHostRuntimeState {
|
||||
return {
|
||||
getClient: (serverId) => clients.get(serverId) ?? null,
|
||||
getSnapshot: (serverId) => snapshots.get(serverId),
|
||||
fetchCalls,
|
||||
serverId: input.serverId,
|
||||
isOnline: input.isOnline,
|
||||
isLoading: input.isLoading ?? false,
|
||||
isFetching: input.isFetching ?? false,
|
||||
error: input.error ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function hostInputs(hosts: HostFixture[]): ProjectsHostInput[] {
|
||||
return hosts.map((host) => ({ serverId: host.serverId, serverName: host.serverName }));
|
||||
}
|
||||
|
||||
function workspace(input: {
|
||||
id: string;
|
||||
projectKey: string;
|
||||
projectName: string;
|
||||
cwd: string;
|
||||
remoteUrl: string | null;
|
||||
}): WorkspaceDescriptorPayload {
|
||||
}): WorkspaceDescriptor {
|
||||
return {
|
||||
id: input.id,
|
||||
projectId: input.projectKey,
|
||||
projectDisplayName: input.projectName,
|
||||
projectCustomName: null,
|
||||
projectRootPath: input.cwd,
|
||||
workspaceDirectory: input.cwd,
|
||||
projectKind: "git",
|
||||
workspaceKind: "local_checkout",
|
||||
name: input.id,
|
||||
title: null,
|
||||
archivingAt: null,
|
||||
status: "done",
|
||||
statusEnteredAt: null,
|
||||
activityAt: null,
|
||||
diffStat: null,
|
||||
scripts: [],
|
||||
gitRuntime: {
|
||||
@@ -115,13 +71,26 @@ function workspace(input: {
|
||||
};
|
||||
}
|
||||
|
||||
describe("fetchAggregatedProjects", () => {
|
||||
it("calls every online host's client and aggregates projects sorted by display name", async () => {
|
||||
const hosts: HostFixture[] = [
|
||||
function emptyProject(input: {
|
||||
projectKey: string;
|
||||
projectName: string;
|
||||
cwd: string;
|
||||
}): EmptyProjectDescriptor {
|
||||
return {
|
||||
projectId: input.projectKey,
|
||||
projectDisplayName: input.projectName,
|
||||
projectCustomName: null,
|
||||
projectRootPath: input.cwd,
|
||||
projectKind: "git",
|
||||
};
|
||||
}
|
||||
|
||||
describe("deriveProjectsFromReplica", () => {
|
||||
it("aggregates store workspaces and empty projects sorted by display name", () => {
|
||||
const replicas: ProjectHostReplica[] = [
|
||||
{
|
||||
serverId: "local",
|
||||
serverName: "Local",
|
||||
status: "online",
|
||||
workspaces: [
|
||||
workspace({
|
||||
id: "z-main",
|
||||
@@ -131,41 +100,50 @@ describe("fetchAggregatedProjects", () => {
|
||||
remoteUrl: "https://github.com/acme/zeta.git",
|
||||
}),
|
||||
],
|
||||
emptyProjects: [],
|
||||
},
|
||||
{
|
||||
serverId: "laptop",
|
||||
serverName: "Laptop",
|
||||
status: "online",
|
||||
workspaces: [
|
||||
workspace({
|
||||
id: "a-main",
|
||||
workspaces: [],
|
||||
emptyProjects: [
|
||||
emptyProject({
|
||||
projectKey: "remote:github.com/acme/alpha",
|
||||
projectName: "acme/alpha",
|
||||
cwd: "/repo/alpha",
|
||||
remoteUrl: "https://github.com/acme/alpha.git",
|
||||
}),
|
||||
],
|
||||
},
|
||||
];
|
||||
const runtime = createRuntime(hosts);
|
||||
|
||||
const result = await fetchAggregatedProjects({ hosts: hostInputs(hosts), runtime });
|
||||
const result = deriveProjectsFromReplica({
|
||||
replicas,
|
||||
runtimeStates: [
|
||||
runtimeState({ serverId: "local", isOnline: true }),
|
||||
runtimeState({ serverId: "laptop", isOnline: false }),
|
||||
],
|
||||
});
|
||||
|
||||
expect(runtime.fetchCalls.get("local")).toBe(1);
|
||||
expect(runtime.fetchCalls.get("laptop")).toBe(1);
|
||||
expect(result.projects.map((project) => project.projectName)).toEqual([
|
||||
"acme/alpha",
|
||||
"acme/zeta",
|
||||
]);
|
||||
expect(result.projects[0]?.hosts).toEqual([
|
||||
expect.objectContaining({
|
||||
serverId: "laptop",
|
||||
isOnline: false,
|
||||
repoRoot: "/repo/alpha",
|
||||
workspaceCount: 0,
|
||||
}),
|
||||
]);
|
||||
expect(result.hostErrors).toEqual([]);
|
||||
});
|
||||
|
||||
it("surfaces per-host fetch failures without dropping successful hosts", async () => {
|
||||
const hosts: HostFixture[] = [
|
||||
it("maps runtime directory status into loading and host errors", () => {
|
||||
const replicas: ProjectHostReplica[] = [
|
||||
{
|
||||
serverId: "local",
|
||||
serverName: "Local",
|
||||
status: "online",
|
||||
workspaces: [
|
||||
workspace({
|
||||
id: "main",
|
||||
@@ -175,17 +153,28 @@ describe("fetchAggregatedProjects", () => {
|
||||
remoteUrl: "https://github.com/acme/app.git",
|
||||
}),
|
||||
],
|
||||
emptyProjects: [],
|
||||
},
|
||||
{
|
||||
serverId: "laptop",
|
||||
serverName: "Laptop",
|
||||
status: "online",
|
||||
workspaces: new Error("laptop unavailable"),
|
||||
workspaces: [],
|
||||
emptyProjects: [],
|
||||
},
|
||||
];
|
||||
const runtime = createRuntime(hosts);
|
||||
|
||||
const result = await fetchAggregatedProjects({ hosts: hostInputs(hosts), runtime });
|
||||
const result = deriveProjectsFromReplica({
|
||||
replicas,
|
||||
runtimeStates: [
|
||||
runtimeState({ serverId: "local", isOnline: true, isFetching: true }),
|
||||
runtimeState({
|
||||
serverId: "laptop",
|
||||
isOnline: true,
|
||||
isLoading: true,
|
||||
error: "laptop unavailable",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.projects).toEqual([
|
||||
expect.objectContaining({ projectKey: "remote:github.com/acme/app" }),
|
||||
@@ -197,61 +186,30 @@ describe("fetchAggregatedProjects", () => {
|
||||
message: "laptop unavailable",
|
||||
},
|
||||
]);
|
||||
expect(result.isLoading).toBe(true);
|
||||
expect(result.isFetching).toBe(true);
|
||||
});
|
||||
|
||||
it("skips disconnected hosts silently without surfacing them as failures", async () => {
|
||||
const hosts: HostFixture[] = [
|
||||
{
|
||||
serverId: "local",
|
||||
serverName: "Local",
|
||||
status: "online",
|
||||
workspaces: [
|
||||
workspace({
|
||||
id: "main",
|
||||
projectKey: "remote:github.com/acme/app",
|
||||
projectName: "acme/app",
|
||||
cwd: "/repo/app",
|
||||
remoteUrl: "https://github.com/acme/app.git",
|
||||
}),
|
||||
],
|
||||
},
|
||||
{
|
||||
serverId: "laptop",
|
||||
serverName: "Laptop",
|
||||
status: "offline",
|
||||
},
|
||||
];
|
||||
const runtime = createRuntime(hosts);
|
||||
|
||||
const result = await fetchAggregatedProjects({ hosts: hostInputs(hosts), runtime });
|
||||
|
||||
expect(result.hostErrors).toEqual([]);
|
||||
expect(result.projects).toEqual([
|
||||
expect.objectContaining({ projectKey: "remote:github.com/acme/app" }),
|
||||
]);
|
||||
expect(runtime.fetchCalls.get("laptop")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns only the stable public project and host entry shapes", async () => {
|
||||
const hosts: HostFixture[] = [
|
||||
{
|
||||
serverId: "local",
|
||||
serverName: "Local",
|
||||
status: "online",
|
||||
workspaces: [
|
||||
workspace({
|
||||
id: "main",
|
||||
projectKey: "remote:github.com/acme/app",
|
||||
projectName: "acme/app",
|
||||
cwd: "/repo/app",
|
||||
remoteUrl: "https://github.com/acme/app.git",
|
||||
}),
|
||||
],
|
||||
},
|
||||
];
|
||||
const runtime = createRuntime(hosts);
|
||||
|
||||
const result = await fetchAggregatedProjects({ hosts: hostInputs(hosts), runtime });
|
||||
it("returns only the stable public project and host entry shapes", () => {
|
||||
const result = deriveProjectsFromReplica({
|
||||
replicas: [
|
||||
{
|
||||
serverId: "local",
|
||||
serverName: "Local",
|
||||
workspaces: [
|
||||
workspace({
|
||||
id: "main",
|
||||
projectKey: "remote:github.com/acme/app",
|
||||
projectName: "acme/app",
|
||||
cwd: "/repo/app",
|
||||
remoteUrl: "https://github.com/acme/app.git",
|
||||
}),
|
||||
],
|
||||
emptyProjects: [],
|
||||
},
|
||||
],
|
||||
runtimeStates: [runtimeState({ serverId: "local", isOnline: true })],
|
||||
});
|
||||
|
||||
expect(Object.keys(result.projects[0] ?? {}).sort()).toEqual([
|
||||
"githubUrl",
|
||||
|
||||
@@ -1,23 +1,45 @@
|
||||
import { useMemo, useSyncExternalStore } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
|
||||
import type { ProjectSummary } from "@/utils/projects";
|
||||
import { useCallback, useMemo, useRef, useSyncExternalStore } from "react";
|
||||
import equal from "fast-deep-equal";
|
||||
import { useStoreWithEqualityFn } from "zustand/traditional";
|
||||
import {
|
||||
fetchAggregatedProjects,
|
||||
type ProjectHostError,
|
||||
type ProjectsHostInput,
|
||||
} from "@/projects/aggregated-projects";
|
||||
getHostRuntimeStore,
|
||||
isHostRuntimeDirectoryLoading,
|
||||
useHosts,
|
||||
type HostRuntimeSnapshot,
|
||||
} from "@/runtime/host-runtime";
|
||||
import {
|
||||
useSessionStore,
|
||||
type EmptyProjectDescriptor,
|
||||
type WorkspaceDescriptor,
|
||||
} from "@/stores/session-store";
|
||||
import { buildProjects, type ProjectHost, type ProjectSummary } from "@/utils/projects";
|
||||
|
||||
export type {
|
||||
ProjectHostError,
|
||||
ProjectsHostInput,
|
||||
ProjectsRuntime,
|
||||
} from "@/projects/aggregated-projects";
|
||||
export interface ProjectHostError {
|
||||
serverId: string;
|
||||
serverName: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export const projectsQueryKey = ["projects"] as const;
|
||||
export interface ProjectHostReplica {
|
||||
serverId: string;
|
||||
serverName: string;
|
||||
workspaces: WorkspaceDescriptor[];
|
||||
emptyProjects: EmptyProjectDescriptor[];
|
||||
}
|
||||
|
||||
function projectsQueryRuntimeKey(hosts: readonly ProjectsHostInput[]) {
|
||||
return hosts.map((host) => host.serverId).join("|");
|
||||
export interface ProjectHostRuntimeState {
|
||||
serverId: string;
|
||||
isOnline: boolean;
|
||||
isLoading: boolean;
|
||||
isFetching: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface DerivedProjectsResult {
|
||||
projects: ProjectSummary[];
|
||||
hostErrors: ProjectHostError[];
|
||||
isLoading: boolean;
|
||||
isFetching: boolean;
|
||||
}
|
||||
|
||||
export interface UseProjectsResult {
|
||||
@@ -28,36 +50,112 @@ export interface UseProjectsResult {
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
export function useProjects(): UseProjectsResult {
|
||||
const hosts = useHosts();
|
||||
function toProjectHostRuntimeState(
|
||||
serverId: string,
|
||||
snapshot: HostRuntimeSnapshot | null,
|
||||
): ProjectHostRuntimeState {
|
||||
const isFetching =
|
||||
snapshot?.agentDirectoryStatus === "initial_loading" ||
|
||||
snapshot?.agentDirectoryStatus === "revalidating";
|
||||
return {
|
||||
serverId,
|
||||
isOnline: snapshot?.connectionStatus === "online",
|
||||
isLoading: isHostRuntimeDirectoryLoading(snapshot),
|
||||
isFetching,
|
||||
error: snapshot?.agentDirectoryError ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function selectProjectHostReplicas(
|
||||
hosts: readonly { serverId: string; label: string }[],
|
||||
): (state: ReturnType<typeof useSessionStore.getState>) => ProjectHostReplica[] {
|
||||
return (state) =>
|
||||
hosts.map((host) => {
|
||||
const session = state.sessions[host.serverId];
|
||||
return {
|
||||
serverId: host.serverId,
|
||||
serverName: host.label,
|
||||
workspaces: Array.from(session?.workspaces.values() ?? []),
|
||||
emptyProjects: Array.from(session?.emptyProjects.values() ?? []),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function deriveProjectsFromReplica(input: {
|
||||
replicas: readonly ProjectHostReplica[];
|
||||
runtimeStates: readonly ProjectHostRuntimeState[];
|
||||
}): DerivedProjectsResult {
|
||||
const runtimeByServerId = new Map(
|
||||
input.runtimeStates.map((state) => [state.serverId, state] as const),
|
||||
);
|
||||
const hosts: ProjectHost[] = input.replicas.map((replica) => {
|
||||
const runtimeState = runtimeByServerId.get(replica.serverId);
|
||||
return {
|
||||
serverId: replica.serverId,
|
||||
serverName: replica.serverName,
|
||||
isOnline: runtimeState?.isOnline ?? false,
|
||||
workspaces: replica.workspaces,
|
||||
emptyProjects: replica.emptyProjects,
|
||||
};
|
||||
});
|
||||
const hostErrors = input.replicas.flatMap((replica) => {
|
||||
const message = runtimeByServerId.get(replica.serverId)?.error;
|
||||
return message
|
||||
? [
|
||||
{
|
||||
serverId: replica.serverId,
|
||||
serverName: replica.serverName,
|
||||
message,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
});
|
||||
|
||||
return {
|
||||
...buildProjects({ hosts }),
|
||||
hostErrors,
|
||||
isLoading: input.runtimeStates.some((state) => state.isLoading),
|
||||
isFetching: input.runtimeStates.some((state) => state.isFetching),
|
||||
};
|
||||
}
|
||||
|
||||
function useProjectHostRuntimeStates(serverIds: readonly string[]): ProjectHostRuntimeState[] {
|
||||
const runtime = getHostRuntimeStore();
|
||||
const runtimeVersion = useSyncExternalStore(
|
||||
const previousStatesRef = useRef<ProjectHostRuntimeState[]>([]);
|
||||
const runtimeSnapshotTick = useSyncExternalStore(
|
||||
(onStoreChange) => runtime.subscribeAll(onStoreChange),
|
||||
() => runtime.getVersion(),
|
||||
() => runtime.getVersion(),
|
||||
);
|
||||
const hostInputs = useMemo<ProjectsHostInput[]>(
|
||||
() =>
|
||||
hosts.map((host) => ({
|
||||
serverId: host.serverId,
|
||||
serverName: host.label,
|
||||
})),
|
||||
[hosts],
|
||||
);
|
||||
return useMemo(() => {
|
||||
void runtimeSnapshotTick;
|
||||
const nextStates = serverIds.map((serverId) =>
|
||||
toProjectHostRuntimeState(serverId, runtime.getSnapshot(serverId)),
|
||||
);
|
||||
if (equal(previousStatesRef.current, nextStates)) {
|
||||
return previousStatesRef.current;
|
||||
}
|
||||
previousStatesRef.current = nextStates;
|
||||
return nextStates;
|
||||
}, [runtime, runtimeSnapshotTick, serverIds]);
|
||||
}
|
||||
|
||||
const projectsQuery = useQuery({
|
||||
queryKey: [...projectsQueryKey, projectsQueryRuntimeKey(hostInputs), runtimeVersion] as const,
|
||||
queryFn: () => fetchAggregatedProjects({ hosts: hostInputs, runtime }),
|
||||
staleTime: 5_000,
|
||||
});
|
||||
export function useProjects(): UseProjectsResult {
|
||||
const hosts = useHosts();
|
||||
const runtime = getHostRuntimeStore();
|
||||
const serverIds = useMemo(() => hosts.map((host) => host.serverId), [hosts]);
|
||||
const replicas = useStoreWithEqualityFn(useSessionStore, selectProjectHostReplicas(hosts), equal);
|
||||
const runtimeStates = useProjectHostRuntimeStates(serverIds);
|
||||
const derived = useMemo(
|
||||
() => deriveProjectsFromReplica({ replicas, runtimeStates }),
|
||||
[replicas, runtimeStates],
|
||||
);
|
||||
const refetch = useCallback(() => {
|
||||
runtime.refreshAllAgentDirectories({ serverIds });
|
||||
}, [runtime, serverIds]);
|
||||
|
||||
return {
|
||||
projects: projectsQuery.data?.projects ?? [],
|
||||
hostErrors: projectsQuery.data?.hostErrors ?? [],
|
||||
isLoading: projectsQuery.isLoading,
|
||||
isFetching: projectsQuery.isFetching,
|
||||
refetch: () => {
|
||||
void projectsQuery.refetch();
|
||||
},
|
||||
...derived,
|
||||
refetch,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,14 +4,13 @@ import { beforeEach, describe, expect, it } from "vitest";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
|
||||
import { draftAgentCommandsQueryKey } from "@/hooks/agent-commands-query";
|
||||
import { applyProvidersSnapshotUpdate, type ProvidersSnapshotUpdate } from "@/data/push-router";
|
||||
import {
|
||||
applyProvidersSnapshotUpdate,
|
||||
fetchProvidersSnapshot,
|
||||
providersSnapshotQueryKey,
|
||||
refreshAndApplyProvidersSnapshot,
|
||||
selectorOpenRefetchDecision,
|
||||
type ProvidersSnapshotClient,
|
||||
type ProvidersSnapshotUpdateMessage,
|
||||
} from "./use-providers-snapshot";
|
||||
|
||||
type GetProvidersSnapshotResult = Awaited<ReturnType<DaemonClient["getProvidersSnapshot"]>>;
|
||||
@@ -223,10 +222,7 @@ describe("applyProvidersSnapshotUpdate", () => {
|
||||
queryClient = new QueryClient();
|
||||
});
|
||||
|
||||
function updateMessage(
|
||||
entries: ProviderSnapshotEntry[],
|
||||
cwd?: string,
|
||||
): ProvidersSnapshotUpdateMessage {
|
||||
function updateMessage(entries: ProviderSnapshotEntry[], cwd?: string): ProvidersSnapshotUpdate {
|
||||
return {
|
||||
type: "providers_snapshot_update",
|
||||
payload: {
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { useMutation, useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useMutation, useQueryClient, type QueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { AgentProvider, ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { queryClient as singletonQueryClient } from "@/query/query-client";
|
||||
import { useReplicaQuery } from "@/data/query";
|
||||
import { queryClient as singletonQueryClient } from "@/data/query-client";
|
||||
import { agentCommandsQueryRoot } from "@/hooks/agent-commands-query";
|
||||
import {
|
||||
isProvidersSnapshotHomeScope,
|
||||
@@ -13,7 +14,7 @@ import {
|
||||
providersSnapshotQueryKey,
|
||||
providersSnapshotQueryRoot,
|
||||
providersSnapshotRequestOptions,
|
||||
} from "@/hooks/providers-snapshot-query";
|
||||
} from "@/data/providers-snapshot";
|
||||
|
||||
type GetProvidersSnapshotResult = Awaited<ReturnType<DaemonClient["getProvidersSnapshot"]>>;
|
||||
type RefreshProvidersSnapshotResult = Awaited<ReturnType<DaemonClient["refreshProvidersSnapshot"]>>;
|
||||
@@ -25,15 +26,6 @@ export type ProvidersSnapshotClient = Pick<
|
||||
"getProvidersSnapshot" | "refreshProvidersSnapshot"
|
||||
>;
|
||||
|
||||
export interface ProvidersSnapshotUpdateMessage {
|
||||
type: "providers_snapshot_update";
|
||||
payload: {
|
||||
cwd?: string;
|
||||
entries: ProviderSnapshotEntry[];
|
||||
generatedAt: string;
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchProvidersSnapshot(input: {
|
||||
client: ProvidersSnapshotClient;
|
||||
cwd: string | null;
|
||||
@@ -66,26 +58,6 @@ export async function refreshAndApplyProvidersSnapshot(input: {
|
||||
return refreshResult;
|
||||
}
|
||||
|
||||
export function applyProvidersSnapshotUpdate(input: {
|
||||
serverId: string;
|
||||
queryClient: QueryClient;
|
||||
message: ProvidersSnapshotUpdateMessage;
|
||||
}): void {
|
||||
if (input.message.type !== "providers_snapshot_update") {
|
||||
return;
|
||||
}
|
||||
const queryKey = providersSnapshotQueryKey(input.serverId, input.message.payload.cwd);
|
||||
input.queryClient.setQueryData(queryKey, {
|
||||
entries: input.message.payload.entries,
|
||||
generatedAt: input.message.payload.generatedAt,
|
||||
requestId: "providers_snapshot_update",
|
||||
});
|
||||
void input.queryClient.invalidateQueries({
|
||||
queryKey: agentCommandsQueryRoot(input.serverId),
|
||||
exact: false,
|
||||
});
|
||||
}
|
||||
|
||||
export type SelectorOpenRefetchDecision = "refetch-stale" | "refetch-always";
|
||||
|
||||
export function selectorOpenRefetchDecision(input: {
|
||||
@@ -134,10 +106,10 @@ export function useProvidersSnapshot(
|
||||
|
||||
const queryKey = useMemo(() => providersSnapshotQueryKey(serverId, cwd), [cwd, serverId]);
|
||||
|
||||
const snapshotQuery = useQuery({
|
||||
const snapshotQuery = useReplicaQuery({
|
||||
queryKey,
|
||||
enabled: Boolean(enabled && supportsSnapshot && serverId && client && isConnected),
|
||||
staleTime: 60_000,
|
||||
pushEvent: "providers_snapshot_update",
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
throw new Error(t("workspace.terminal.hostDisconnected"));
|
||||
@@ -162,19 +134,6 @@ export function useProvidersSnapshot(
|
||||
});
|
||||
const { mutateAsync: refreshSnapshot, isPending: isRefreshing } = refreshMutation;
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !supportsSnapshot || !client || !isConnected || !serverId) {
|
||||
return;
|
||||
}
|
||||
|
||||
return client.on("providers_snapshot_update", (message) => {
|
||||
if (message.type !== "providers_snapshot_update") {
|
||||
return;
|
||||
}
|
||||
applyProvidersSnapshotUpdate({ serverId, queryClient, message });
|
||||
});
|
||||
}, [client, enabled, isConnected, queryClient, serverId, supportsSnapshot]);
|
||||
|
||||
const refresh = useCallback(
|
||||
async (providers?: AgentProvider[]) => {
|
||||
await refreshSnapshot(providers);
|
||||
@@ -218,7 +177,7 @@ export function prefetchProvidersSnapshot(
|
||||
const queryKey = providersSnapshotQueryKey(serverId, cwd);
|
||||
void singletonQueryClient.prefetchQuery({
|
||||
queryKey,
|
||||
staleTime: 60_000,
|
||||
staleTime: Infinity,
|
||||
queryFn: () => fetchProvidersSnapshot({ client, cwd }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type {
|
||||
AggregatedSchedule,
|
||||
FetchAggregatedSchedulesResult,
|
||||
FetchAggregatedSchedulesState,
|
||||
} from "@/schedules/aggregated-schedules";
|
||||
import { schedulesQueryBaseKey } from "@/schedules/aggregated-schedules";
|
||||
import { updateAggregatedSchedulesData } from "./use-schedule-mutations";
|
||||
|
||||
function schedule(overrides: Partial<AggregatedSchedule> = {}): AggregatedSchedule {
|
||||
const base: ScheduleSummary = {
|
||||
id: "schedule-1",
|
||||
name: "Nightly",
|
||||
prompt: "Run the task",
|
||||
cadence: { type: "every", everyMs: 60_000 },
|
||||
target: { type: "new-agent", config: { provider: "codex", cwd: "/tmp/project" } },
|
||||
status: "active",
|
||||
createdAt: "2026-07-01T00:00:00.000Z",
|
||||
updatedAt: "2026-07-01T00:00:00.000Z",
|
||||
nextRunAt: "2026-07-02T01:00:00.000Z",
|
||||
lastRunAt: null,
|
||||
pausedAt: null,
|
||||
expiresAt: null,
|
||||
maxRuns: null,
|
||||
};
|
||||
return { ...base, serverId: "host-a", serverName: "Host A", ...overrides };
|
||||
}
|
||||
|
||||
function pauseSchedules(schedules: AggregatedSchedule[]): AggregatedSchedule[] {
|
||||
return schedules.map((entry) => ({ ...entry, status: "paused" }));
|
||||
}
|
||||
|
||||
function pauseAggregatedSchedules(
|
||||
current: FetchAggregatedSchedulesState | undefined,
|
||||
): FetchAggregatedSchedulesState | undefined {
|
||||
return updateAggregatedSchedulesData(current, pauseSchedules);
|
||||
}
|
||||
|
||||
describe("schedule mutation cache updates", () => {
|
||||
it("updates the canonical loaded data field", () => {
|
||||
const current: FetchAggregatedSchedulesResult = {
|
||||
status: "loaded",
|
||||
data: [schedule()],
|
||||
hostErrors: [],
|
||||
};
|
||||
|
||||
const result = updateAggregatedSchedulesData(current, pauseSchedules);
|
||||
|
||||
expect(result).toEqual({
|
||||
status: "loaded",
|
||||
data: [schedule({ status: "paused" })],
|
||||
hostErrors: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves an empty cache entry empty", () => {
|
||||
const result = updateAggregatedSchedulesData(undefined, () => [schedule()]);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("leaves connecting cache entries untouched while updating loaded entries", () => {
|
||||
const queryClient = new QueryClient();
|
||||
const connectingKey = [...schedulesQueryBaseKey, "connecting"];
|
||||
const loadedKey = [...schedulesQueryBaseKey, "loaded"];
|
||||
const connecting = { status: "connecting" } as const;
|
||||
const loaded: FetchAggregatedSchedulesResult = {
|
||||
status: "loaded",
|
||||
data: [schedule()],
|
||||
hostErrors: [],
|
||||
};
|
||||
queryClient.setQueryData(connectingKey, connecting);
|
||||
queryClient.setQueryData(loadedKey, loaded);
|
||||
|
||||
expect(() => {
|
||||
queryClient.setQueriesData<FetchAggregatedSchedulesState>(
|
||||
{ queryKey: schedulesQueryBaseKey },
|
||||
pauseAggregatedSchedules,
|
||||
);
|
||||
}).not.toThrow();
|
||||
|
||||
expect(queryClient.getQueryData(connectingKey)).toEqual(connecting);
|
||||
expect(queryClient.getQueryData(loadedKey)).toEqual({
|
||||
status: "loaded",
|
||||
data: [schedule({ status: "paused" })],
|
||||
hostErrors: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -12,11 +12,11 @@ import type {
|
||||
UpdateScheduleOptions,
|
||||
} from "@getpaseo/client/internal/daemon-client";
|
||||
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
|
||||
import { schedulesQueryBaseKey } from "@/hooks/use-schedules";
|
||||
import type {
|
||||
AggregatedSchedule,
|
||||
FetchAggregatedSchedulesResult,
|
||||
FetchAggregatedSchedulesState,
|
||||
} from "@/schedules/aggregated-schedules";
|
||||
import { schedulesQueryBaseKey } from "@/schedules/aggregated-schedules";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
|
||||
export type CreateScheduleInput = Omit<CreateScheduleOptions, "requestId">;
|
||||
@@ -38,7 +38,17 @@ export interface UseScheduleMutationsResult {
|
||||
}
|
||||
|
||||
interface ScheduleListSnapshot {
|
||||
previous: Array<[QueryKey, FetchAggregatedSchedulesResult | undefined]>;
|
||||
previous: Array<[QueryKey, FetchAggregatedSchedulesState | undefined]>;
|
||||
}
|
||||
|
||||
export function updateAggregatedSchedulesData(
|
||||
current: FetchAggregatedSchedulesState | undefined,
|
||||
updateSchedules: (schedules: AggregatedSchedule[]) => AggregatedSchedule[],
|
||||
): FetchAggregatedSchedulesState | undefined {
|
||||
if (!current || current.status !== "loaded") {
|
||||
return current;
|
||||
}
|
||||
return { ...current, data: updateSchedules(current.data) };
|
||||
}
|
||||
|
||||
function requireClient(serverId: string, unavailableMessage: string): DaemonClient {
|
||||
@@ -51,7 +61,7 @@ function requireClient(serverId: string, unavailableMessage: string): DaemonClie
|
||||
|
||||
function snapshotSchedules(queryClient: QueryClient): ScheduleListSnapshot {
|
||||
return {
|
||||
previous: queryClient.getQueriesData<FetchAggregatedSchedulesResult>({
|
||||
previous: queryClient.getQueriesData<FetchAggregatedSchedulesState>({
|
||||
queryKey: schedulesQueryBaseKey,
|
||||
}),
|
||||
};
|
||||
@@ -67,14 +77,9 @@ function updateSchedulesData(
|
||||
queryClient: QueryClient,
|
||||
updateSchedules: (schedules: AggregatedSchedule[]) => AggregatedSchedule[],
|
||||
): void {
|
||||
queryClient.setQueriesData<FetchAggregatedSchedulesResult>(
|
||||
queryClient.setQueriesData<FetchAggregatedSchedulesState>(
|
||||
{ queryKey: schedulesQueryBaseKey },
|
||||
(current) => {
|
||||
if (!current) {
|
||||
return current;
|
||||
}
|
||||
return { ...current, schedules: updateSchedules(current.schedules) };
|
||||
},
|
||||
(current) => updateAggregatedSchedulesData(current, updateSchedules),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { schedulesQueryKey } from "@/hooks/use-schedules";
|
||||
|
||||
describe("schedulesQueryKey", () => {
|
||||
it("uses only the sorted host identity", () => {
|
||||
expect(schedulesQueryKey(["laptop", "local"])).toEqual(["schedules", "laptop|local"]);
|
||||
expect(schedulesQueryKey(["local", "laptop"])).toEqual(["schedules", "laptop|local"]);
|
||||
});
|
||||
});
|
||||
@@ -1,30 +1,32 @@
|
||||
import { useMemo, useSyncExternalStore } from "react";
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
|
||||
import { useMemo } from "react";
|
||||
import { useFetchQuery } from "@/data/query";
|
||||
import {
|
||||
getHostRuntimeStore,
|
||||
useHostRuntimeConnectionStatuses,
|
||||
useHosts,
|
||||
} from "@/runtime/host-runtime";
|
||||
import {
|
||||
fetchAggregatedSchedules,
|
||||
schedulesQueryBaseKey,
|
||||
type AggregateLoadState,
|
||||
type AggregatedSchedule,
|
||||
type ScheduleHostError,
|
||||
type ScheduleHostInput,
|
||||
} from "@/schedules/aggregated-schedules";
|
||||
|
||||
export type { AggregatedSchedule, ScheduleHostError } from "@/schedules/aggregated-schedules";
|
||||
export type {
|
||||
AggregateLoadState,
|
||||
AggregatedSchedule,
|
||||
ScheduleHostError,
|
||||
} from "@/schedules/aggregated-schedules";
|
||||
|
||||
export const schedulesQueryBaseKey = ["schedules"] as const;
|
||||
|
||||
// Cache identity for the host set. The query also carries the runtime version
|
||||
// (below) so it retries as connectivity changes and reliably fetches once a host
|
||||
// comes online — even on a cold deep-link. The full-screen spinner flash that
|
||||
// keying on the version used to cause is prevented by keepPreviousData plus the
|
||||
// isInitialLoad(data === undefined) gate, not by dropping the version.
|
||||
export function schedulesQueryKey(serverIds: readonly string[]) {
|
||||
return [...schedulesQueryBaseKey, [...serverIds].sort().join("|")] as const;
|
||||
}
|
||||
|
||||
export interface UseSchedulesResult {
|
||||
schedules: AggregatedSchedule[];
|
||||
loadState: AggregateLoadState<AggregatedSchedule>;
|
||||
hostErrors: ScheduleHostError[];
|
||||
isInitialLoad: boolean;
|
||||
isError: boolean;
|
||||
error: Error | null;
|
||||
refetch: () => void;
|
||||
@@ -34,27 +36,36 @@ export interface UseSchedulesResult {
|
||||
export function useSchedules(): UseSchedulesResult {
|
||||
const hosts = useHosts();
|
||||
const runtime = getHostRuntimeStore();
|
||||
const runtimeVersion = useSyncExternalStore(
|
||||
(onStoreChange) => runtime.subscribeAll(onStoreChange),
|
||||
() => runtime.getVersion(),
|
||||
() => runtime.getVersion(),
|
||||
);
|
||||
const hostInputs = useMemo<ScheduleHostInput[]>(
|
||||
() => hosts.map((host) => ({ serverId: host.serverId, serverName: host.label })),
|
||||
[hosts],
|
||||
);
|
||||
const serverIds = useMemo(() => hostInputs.map((host) => host.serverId), [hostInputs]);
|
||||
const connectionStatuses = useHostRuntimeConnectionStatuses(serverIds);
|
||||
const connectionStatusKey = useMemo(
|
||||
() => serverIds.map((serverId) => connectionStatuses.get(serverId) ?? "connecting").join("|"),
|
||||
[connectionStatuses, serverIds],
|
||||
);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: [...schedulesQueryKey(hostInputs.map((host) => host.serverId)), runtimeVersion],
|
||||
const query = useFetchQuery({
|
||||
queryKey: [...schedulesQueryKey(serverIds), connectionStatusKey],
|
||||
queryFn: () => fetchAggregatedSchedules({ hosts: hostInputs, runtime }),
|
||||
staleTime: 5_000,
|
||||
placeholderData: keepPreviousData,
|
||||
dataShape: "list",
|
||||
staleTimeMs: 5_000,
|
||||
});
|
||||
|
||||
let loadState: AggregateLoadState<AggregatedSchedule>;
|
||||
if (query.data?.status === "connecting") {
|
||||
loadState = { status: "connecting" };
|
||||
} else if (query.data?.status === "loaded") {
|
||||
loadState = { status: "loaded", data: query.data.data };
|
||||
} else {
|
||||
loadState = { status: "loading" };
|
||||
}
|
||||
|
||||
return {
|
||||
schedules: query.data?.schedules ?? [],
|
||||
hostErrors: query.data?.hostErrors ?? [],
|
||||
isInitialLoad: query.isLoading && query.data === undefined,
|
||||
loadState,
|
||||
hostErrors: query.data?.status === "loaded" ? query.data.hostErrors : [],
|
||||
isError: query.isError,
|
||||
error: query.error,
|
||||
refetch: () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback } from "react";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query";
|
||||
import { queryClient as appQueryClient } from "@/query/query-client";
|
||||
import { queryClient as appQueryClient } from "@/data/query-client";
|
||||
import type { AppLanguage } from "@/i18n/locales";
|
||||
import {
|
||||
DEFAULT_DESKTOP_SETTINGS,
|
||||
|
||||
Reference in New Issue
Block a user