Files
paseo/packages/app/src/stores/workspace-setup-store.ts
T
Mohamed BoudraandGitHub cf4dd8616c refactor(app): move workspace setup-status fetch into the setup store (#1641)
* refactor(app): move workspace setup-status fetch into the setup store

The workspace screen ran a fetch-once-and-store workflow inline in a
useEffect, deduped by a hidden requestedWorkspaceSetupStatusKeyRef state
machine and a manual cancellation flag. The fetch + dedup + response
validation only existed inside the component, so it could only be
exercised through E2E.

Move the workflow into useWorkspaceSetupStore as an idempotent
ensureSetupStatus action with the daemon client injected as a port. The
store owns the in-flight dedup (requestedKeys) and clears the marker on
error/removal so a later attempt retries. The component effect now just
delegates; the ref and cancellation flag are gone.

This makes the workflow unit-testable with a fake client (no mocks) and
is the first slice of pulling business logic out of the workspace-screen
god component.

* fix(app): release the setup-status in-flight marker on every settle

Greptile P1: when fetchWorkspaceSetupStatus returned a null snapshot or a
mismatched workspaceId, the key stayed in requestedKeys forever, so a later
mount could never retry — a regression versus the old component-scoped ref,
which reset on remount.

Make requestedKeys a pure in-flight marker: add it before the fetch and
release it in a finally once the request settles (success, ignored, or
error). A settle that stored no snapshot leaves no marker, so the next call
retries; once a snapshot lands, the snapshots[key] guard prevents redundant
refetches. This also lets removeWorkspace/clearServer drop their now-redundant
requestedKeys pruning.

Adds tests for retry-after-null-snapshot and retry-after-mismatched-workspace.
2026-06-21 16:40:13 +08:00

147 lines
4.5 KiB
TypeScript

import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
import { create } from "zustand";
import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-tabs-store";
export type WorkspaceCreationMethod = "open_project" | "create_worktree";
export interface PendingWorkspaceSetup {
serverId: string;
sourceDirectory: string;
sourceWorkspaceId?: string;
displayName?: string;
creationMethod: WorkspaceCreationMethod;
}
export type WorkspaceSetupProgressPayload = Extract<
SessionOutboundMessage,
{ type: "workspace_setup_progress" }
>["payload"];
export type WorkspaceSetupStatusResult = Extract<
SessionOutboundMessage,
{ type: "workspace_setup_status_response" }
>["payload"];
export interface WorkspaceSetupStatusClient {
fetchWorkspaceSetupStatus: (workspaceId: string) => Promise<WorkspaceSetupStatusResult>;
}
export interface WorkspaceSetupSnapshot extends WorkspaceSetupProgressPayload {
updatedAt: number;
}
export function shouldShowWorkspaceSetup(snapshot: WorkspaceSetupSnapshot | null): boolean {
if (!snapshot) {
return false;
}
return snapshot.error !== null || snapshot.detail.commands.length > 0;
}
interface WorkspaceSetupStoreState {
pendingWorkspaceSetup: PendingWorkspaceSetup | null;
snapshots: Record<string, WorkspaceSetupSnapshot>;
requestedKeys: Set<string>;
beginWorkspaceSetup: (value: PendingWorkspaceSetup) => void;
clearWorkspaceSetup: () => void;
upsertProgress: (input: { serverId: string; payload: WorkspaceSetupProgressPayload }) => void;
ensureSetupStatus: (input: {
serverId: string;
workspaceId: string;
client: WorkspaceSetupStatusClient;
}) => void;
removeWorkspace: (input: { serverId: string; workspaceId: string }) => void;
clearServer: (serverId: string) => void;
}
function buildWorkspaceSetupKey(input: { serverId: string; workspaceId: string }): string | null {
return buildWorkspaceTabPersistenceKey(input);
}
export const useWorkspaceSetupStore = create<WorkspaceSetupStoreState>()((set, get) => ({
pendingWorkspaceSetup: null,
snapshots: {},
requestedKeys: new Set(),
beginWorkspaceSetup: (value) => {
set({ pendingWorkspaceSetup: value });
},
clearWorkspaceSetup: () => {
set({ pendingWorkspaceSetup: null });
},
upsertProgress: ({ serverId, payload }) => {
const key = buildWorkspaceSetupKey({ serverId, workspaceId: payload.workspaceId });
if (!key) {
return;
}
set((state) => ({
snapshots: {
...state.snapshots,
[key]: {
...payload,
updatedAt: Date.now(),
},
},
}));
},
ensureSetupStatus: async ({ serverId, workspaceId, client }) => {
const key = buildWorkspaceSetupKey({ serverId, workspaceId });
if (!key) {
return;
}
const state = get();
if (state.snapshots[key] || state.requestedKeys.has(key)) {
return;
}
// requestedKeys is a pure in-flight marker: it dedupes concurrent fetches and is
// released once the request settles. A settle that stored no snapshot (null snapshot,
// mismatched workspace, or error) leaves no marker, so a later call can retry; once a
// snapshot lands, the snapshots[key] guard above prevents redundant refetches.
set((current) => ({ requestedKeys: new Set(current.requestedKeys).add(key) }));
try {
const response = await client.fetchWorkspaceSetupStatus(workspaceId);
if (response.workspaceId === workspaceId && response.snapshot) {
get().upsertProgress({
serverId,
payload: { workspaceId: response.workspaceId, ...response.snapshot },
});
}
} catch {
// Swallowed: the finally clears the in-flight marker so a later call retries.
} finally {
set((current) => {
const next = new Set(current.requestedKeys);
next.delete(key);
return { requestedKeys: next };
});
}
},
removeWorkspace: ({ serverId, workspaceId }) => {
const key = buildWorkspaceSetupKey({ serverId, workspaceId });
if (!key) {
return;
}
set((state) => {
if (!(key in state.snapshots)) {
return state;
}
const next = { ...state.snapshots };
delete next[key];
return { snapshots: next };
});
},
clearServer: (serverId) => {
set((state) => {
const nextEntries = Object.entries(state.snapshots).filter(
([key]) => !key.startsWith(`${serverId}:`),
);
if (nextEntries.length === Object.keys(state.snapshots).length) {
return state;
}
return { snapshots: Object.fromEntries(nextEntries) };
});
},
}));