Skip to content
Merged
6 changes: 4 additions & 2 deletions src/browser/components/AppLoader/AppLoader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { getAppConfigStore } from "../../stores/AppConfigStore";
import { getDraftStore, useDraftStoreReady } from "../../stores/DraftStore";
import { getProvidersConfigStore } from "../../stores/ProvidersConfigStore";
import { ProjectProvider, useProjectContext } from "../../contexts/ProjectContext";
import { APIProvider, useAPI, type APIClient } from "@/browser/contexts/API";
import { APIProvider, useAPI, useLiveAPI, type APIClient } from "@/browser/contexts/API";
import { WorkspaceProvider, useWorkspaceContext } from "../../contexts/WorkspaceContext";
import { RouterProvider } from "../../contexts/RouterContext";
import { TerminalRouterProvider } from "../../terminal/TerminalRouterContext";
Expand Down Expand Up @@ -165,7 +165,9 @@ function AppLoaderInner() {
const workspaceContext = useWorkspaceContext();
const projectContext = useProjectContext();
const apiState = useAPI();
const api = apiState.api;
// The stores must see "offline": they drop their client and resubscribe once it is back.
// useAPI().api can keep the last client while Xum reconnects (see useLiveAPI).
const api = useLiveAPI();

const workspaceStoreInstance = useWorkspaceStoreRaw();
const gitStatusStore = useGitStatusStoreRaw();
Expand Down
34 changes: 33 additions & 1 deletion src/browser/contexts/API.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -120,10 +120,11 @@ import type { UseAPIResult as _UseAPIResult, APIProvider as APIProviderType } fr
const RealAPIModule: {
APIProvider: typeof APIProviderType;
useAPI: () => _UseAPIResult;
useLiveAPI: () => _UseAPIResult["api"];
useConnectionLatencyMs: () => number | null;
} = require("./API?real=1");
/* eslint-enable @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment */
const { APIProvider, useAPI, useConnectionLatencyMs } = RealAPIModule;
const { APIProvider, useAPI, useLiveAPI, useConnectionLatencyMs } = RealAPIModule;
type UseAPIResult = _UseAPIResult;

// Each observation carries the API context value by reference (apiState) plus the latency
Expand Down Expand Up @@ -365,6 +366,37 @@ describe("API reconnection", () => {
});
});

// The stores and deferred writes read useLiveAPI(). Until the provider keeps the last client
// in the gap, it must equal useAPI().api in every render, so they see exactly what they saw.
test("useLiveAPI() equals useAPI().api in every render across drops and reconnects", async () => {
const renders: Array<{ status: string; same: boolean; api: unknown }> = [];
function Probe() {
const state = useAPI();
const live = useLiveAPI();
renders.push({ status: state.status, same: live === state.api, api: state.api });
return null;
}
render(
<APIProvider createWebSocket={createMockWebSocket}>
<Probe />
</APIProvider>
);
for (let cycle = 0; cycle < 3; cycle++) {
const ws = MockWebSocket.lastInstance();
await act(async () => {
ws!.simulateOpen();
await new Promise((r) => setTimeout(r, 10));
});
act(() => {
ws!.simulateClose(1006);
});
await waitFor(() => expect(MockWebSocket.instances.length).toBeGreaterThan(cycle + 1));
}
expect(renders.filter((r) => r.status === "connected")).not.toHaveLength(0);
expect(renders.filter((r) => r.status === "reconnecting")).not.toHaveLength(0);
expect(renders.filter((r) => !r.same)).toEqual([]);
});

test("shows auth_required on close with auth error codes (4401)", async () => {
const states: string[] = [];

Expand Down
26 changes: 23 additions & 3 deletions src/browser/contexts/API.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,12 @@
| { status: "connecting"; api: null; error: null }
| { status: "connected"; api: APIClient; error: null }
| { status: "degraded"; api: APIClient; error: null } // Connected but the backend answers slowly
| { status: "reconnecting"; api: null; error: null; attempt: number }
// While reconnecting, and in an error after a lost session, `api` can be the last client: the
// App stays mounted, and panes keep what they show. Calls on it then fail fast with
// ConnectionLostError (connectionLink.ts). Code that must know "offline" uses useLiveAPI().
| { status: "reconnecting"; api: APIClient | null; error: null; attempt: number }
| { status: "auth_required"; api: null; error: string | null }
| { status: "error"; api: null; error: string };
| { status: "error"; api: APIClient | null; error: string };

interface APIStateMethods {
authenticate: (token: string) => void;
Expand Down Expand Up @@ -190,503 +193,504 @@
}, [api, skip]);
}

function ManagedAPIProvider(props: Omit<APIProviderProps, "client">) {
const [state, setState] = useState<ConnectionState>({ status: "connecting" });
const [authToken, setAuthToken] = useState<string | null>(() => {
const urlParams = new URLSearchParams(window.location.search);
const urlToken = urlParams.get("token")?.trim();
if (urlToken) {
setStoredAuthToken(urlToken);
// Strip token from URL so it doesn't leak into bookmarks, browser
// history, PWA launch URLs, or Referer headers.
const cleanUrl = new URL(window.location.href);
cleanUrl.searchParams.delete("token");
window.history.replaceState({}, "", cleanUrl.pathname + cleanUrl.search);
return urlToken;
}

return getStoredAuthToken();
});

const cleanupRef = useRef<(() => void) | null>(null);
const hasConnectedRef = useRef(false);
const reconnectAttemptRef = useRef(0);
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const scheduleReconnectRef = useRef<(() => void) | null>(null);
const consecutiveSlowProbesRef = useRef(0);
const consecutiveRejectedProbesRef = useRef(0);
const [latencyMs, setLatencyMs] = useState<number | null>(null);
const connectionIdRef = useRef(0);
const forceReconnectInProgressRef = useRef(false);
// Probe ages, RTT, and silence use performance.now(): it is monotonic, whereas a wall-clock
// correction could make a stalled probe look young or a slow probe look fast.
const outstandingProbeRef = useRef<{ sentAt: number; connectionId: number } | null>(null);
const lastInboundBrowserFrameAtRef = useRef(0);

// When we decide the user needs to provide a token, stop the reconnect loop.
//
// Otherwise, the WebSocket close event (triggered by cleanup()) can schedule a reconnect
// which immediately flips the UI back to "reconnecting", causing the AuthTokenModal
// to flicker.
const authRequiredRef = useRef(false);

const authProbeAttemptedRef = useRef(false);
const wsFactory = useMemo(
() => props.createWebSocket ?? ((url: string) => new WebSocket(url)),
[props.createWebSocket]
);

const connect = useCallback(
(token: string | null) => {
const connectionId = ++connectionIdRef.current;
// Reset per-connection liveness bookkeeping so a prior socket's frames or probes
// cannot influence the next connection. Silence is measured from the connect time until
// the first frame arrives.
lastInboundBrowserFrameAtRef.current = performance.now();
outstandingProbeRef.current = null;
consecutiveSlowProbesRef.current = 0;
consecutiveRejectedProbesRef.current = 0;
setLatencyMs(null);

authRequiredRef.current = false;

// This connect() call supersedes any prior pending reconnect or active connection.
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = null;
}

cleanupRef.current?.();
cleanupRef.current = null;

// Skip Electron detection if custom WebSocket factory provided (for testing)
if (!props.createWebSocket && window.api) {
const { client, cleanup } = createElectronClient();
window.__ORPC_CLIENT__ = client;
cleanupRef.current = cleanup;
setState({ status: "connected", client, cleanup });
return;
}

// Once a session has been connected, any new attempt is a reconnect from the user's
// perspective: keep the "Reconnecting to server" banner up through the handshake instead
// of dropping to "connecting", which renders nothing and is meant for the initial load.
setState(
hasConnectedRef.current
? { status: "reconnecting", attempt: Math.max(1, reconnectAttemptRef.current) }
: { status: "connecting" }
);
const { client, cleanup, ws } = createBrowserClient(token, wsFactory);
ws.addEventListener("message", () => {
// Inbound frames prove the transport is alive, not that the backend is responsive:
// they only suppress the total-silence reconnect and never skip or satisfy a probe.
if (connectionId !== connectionIdRef.current) {
return;
}

lastInboundBrowserFrameAtRef.current = performance.now();
});

ws.addEventListener("open", () => {
// Ignore stale connections (can happen if we force reconnect while the old socket is mid-flight).
if (connectionId !== connectionIdRef.current) {
cleanup();
return;
}

client.general
.ping("auth-check")
.then(() => {
// Ignore stale connections (e.g., auth-check returned after a new connect()).
if (connectionId !== connectionIdRef.current) {
cleanup();
return;
}

const reconnected = hasConnectedRef.current;
authRequiredRef.current = false;
hasConnectedRef.current = true;
reconnectAttemptRef.current = 0;
consecutiveSlowProbesRef.current = 0;
forceReconnectInProgressRef.current = false;
window.__ORPC_CLIENT__ = client;
cleanupRef.current = cleanup;
setState({ status: "connected", client, cleanup });
// A reconnected socket may belong to a newer server than this loaded bundle. The probe
// runs after the client is published so a slow /version never delays reconnection, and
// only a bundle served by that server can be refreshed by reloading, so split-origin
// setups (VITE_BACKEND_URL, extension webviews) skip it.
const backendBaseUrl = getBrowserBackendBaseUrl();
if (reconnected && new URL(backendBaseUrl).origin === window.location.origin) {
void reloadIfServerBuildChanged(
backendBaseUrl,
() => connectionId === connectionIdRef.current
);
}
})
.catch((err: unknown) => {
if (connectionId !== connectionIdRef.current) {
cleanup();
return;
}

forceReconnectInProgressRef.current = false;
const errMsg = getErrorMessage(err);
const errMsgLower = errMsg.toLowerCase();
const isAuthError =
errMsgLower.includes("unauthorized") ||
errMsgLower.includes("401") ||
errMsgLower.includes("auth token") ||
errMsgLower.includes("authentication");

if (isAuthError) {
authRequiredRef.current = true;
clearStoredAuthToken();
hasConnectedRef.current = false; // Reset - need fresh auth
setState({ status: "auth_required", error: token ? "Invalid token" : undefined });
cleanup();
return;
}

cleanup();
setState({ status: "error", error: errMsg });
});
});

// Note: Browser fires 'error' before 'close', so we handle reconnection
// only in 'close' to avoid double-scheduling. The 'error' event just
// signals that something went wrong; 'close' provides the final state.
ws.addEventListener("error", () => {
// Error occurred - close event will follow and handle reconnection
// We don't call cleanup() here since close handler will do it
});

ws.addEventListener("close", (event) => {
cleanup();

// Ignore stale connections (can happen if we force reconnect while the old socket is mid-flight).
if (connectionId !== connectionIdRef.current) {
return;
}

forceReconnectInProgressRef.current = false;

// If we've already decided auth is required (e.g. via ping error), don't immediately
// overwrite the modal with a reconnect attempt.
// Auth-specific close codes
if (event.code === 1008 || event.code === 4401) {
authRequiredRef.current = true;
clearStoredAuthToken();
hasConnectedRef.current = false; // Reset - need fresh auth
setState({ status: "auth_required", error: "Authentication required" });
return;
}

if (authRequiredRef.current) {
return;
}

// If this is the initial connection attempt and the WS handshake failed, browsers often
// collapse HTTP auth errors (401/403) into an abnormal closure (1006) with no status.
//
// If the backend is reachable over HTTP, we can use the OpenAPI spec to disambiguate:
// the server includes a `security` stanza when a bearer token is required.
if (
!hasConnectedRef.current &&
!token &&
event.code === 1006 &&
!authProbeAttemptedRef.current
) {
authProbeAttemptedRef.current = true;

const apiBaseUrl = getBrowserBackendBaseUrl();
const specUrl = new URL(`${apiBaseUrl}/api/spec.json`);

type AuthProbeResult = "requires_auth" | "no_auth" | "unknown";

const controller = new AbortController();
let timeoutId: ReturnType<typeof setTimeout> | null = null;

// `fetch` has no builtin timeout, and some environments don't reliably reject on abort.
// Use a race so the probe cannot hang the connection loop.
const timeoutPromise = new Promise<AuthProbeResult>((resolve) => {
timeoutId = setTimeout(() => {
controller.abort();
resolve("unknown");
}, AUTH_PROBE_TIMEOUT_MS);
});

const fetchPromise: Promise<AuthProbeResult> = fetch(specUrl, {
signal: controller.signal,
})
.then(async (res): Promise<AuthProbeResult> => {
if (!res.ok) return "unknown";

try {
const spec = (await res.json()) as { security?: unknown };
const requiresAuth = Array.isArray(spec.security) && spec.security.length > 0;
return requiresAuth ? "requires_auth" : "no_auth";
} catch {
return "unknown";
}
})
.catch((): AuthProbeResult => "unknown");

void Promise.race([fetchPromise, timeoutPromise])
.then((result) => {
if (connectionId !== connectionIdRef.current) {
return;
}

if (result === "requires_auth") {
authRequiredRef.current = true;
clearStoredAuthToken();
hasConnectedRef.current = false; // Reset - need fresh auth
setState({ status: "auth_required", error: "Authentication required" });
return;
}

if (result === "unknown") {
// Probe was inconclusive (timeout, network error, non-OK, invalid JSON). Allow re-probe
// on a later initial-handshake failure.
authProbeAttemptedRef.current = false;
}

scheduleReconnectRef.current?.();
})
.finally(() => {
if (timeoutId) {
clearTimeout(timeoutId);
}
});

return;
}
// If we were previously connected, try to reconnect
if (hasConnectedRef.current) {
scheduleReconnectRef.current?.();
return;
}

// First connection failed.
// This can happen in dev-server mode if the UI boots before the backend is ready.
// Prefer retry/backoff over forcing the auth modal (auth will be detected via ping/close codes).
scheduleReconnectRef.current?.();
});
},
[props.createWebSocket, wsFactory]
);

// Schedule reconnection with exponential backoff
const scheduleReconnect = useCallback(() => {
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}

const attempt = reconnectAttemptRef.current;
if (attempt >= MAX_RECONNECT_ATTEMPTS) {
const error = hasConnectedRef.current
? "Connection lost. Please refresh the page."
: "Failed to connect to the Xum backend after multiple attempts.";
setState({ status: "error", error });
return;
}

const delay = Math.min(BASE_DELAY_MS * Math.pow(2, attempt), MAX_DELAY_MS);
reconnectAttemptRef.current = attempt + 1;
setState({ status: "reconnecting", attempt: attempt + 1 });

reconnectTimeoutRef.current = setTimeout(() => {
connect(authToken);
}, delay);
}, [authToken, connect]);

// Keep ref in sync with latest scheduleReconnect
scheduleReconnectRef.current = scheduleReconnect;

useEffect(() => {
connect(authToken);
return () => {
cleanupRef.current?.();
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

// Liveness check: probe the backend's round-trip time to detect a slow server, for both
// browser WebSocket and Electron MessagePort clients. Keyed on the live client identity
// rather than the whole state object: degraded <-> connected transitions keep the same
// client, so per-tick latency updates must not restart the interval or the outstanding probe.
const isLive = state.status === "connected" || state.status === "degraded";
const liveClient = isLive ? state.client : null;
const liveCleanup = isLive ? state.cleanup : null;
useEffect(() => {
if (!liveClient || !liveCleanup) return;
const client = liveClient;
const cleanup = liveCleanup;
// Electron has no socket to reconnect and no inbound-frame signal, so it only gets
// RTT-based degraded detection.
const isBrowserWebSocket = Boolean(props.createWebSocket) || !window.api;
let effectDisposed = false;

const forceReconnect = (reason: string) => {
if (!isBrowserWebSocket || forceReconnectInProgressRef.current) return false;
forceReconnectInProgressRef.current = true;
console.warn(`[APIProvider] ${reason}; reconnecting...`);
cleanup();
if (!effectDisposed) connect(authToken);
return true;
};

const recordSlowObservation = (observedLatencyMs: number) => {
consecutiveSlowProbesRef.current += 1;
if (consecutiveSlowProbesRef.current < SLOW_OBSERVATIONS_FOR_DEGRADED) return;
setLatencyMs(observedLatencyMs);
// Returning prev while already degraded keeps the API context value stable, so only the
// latency context consumers re-render on later ticks.
setState((prev) =>
prev.status === "connected" && prev.client === client
? { status: "degraded", client, cleanup }
: prev
);
};

const recordFastObservation = () => {
consecutiveSlowProbesRef.current = 0;
forceReconnectInProgressRef.current = false;
setLatencyMs(null);
setState((prev) =>
prev.status === "degraded" && prev.client === client
? { status: "connected", client, cleanup }
: prev
);
};

const sendProbe = () => {
const probe = { sentAt: performance.now(), connectionId: connectionIdRef.current };
outstandingProbeRef.current = probe;

const settle = (resolved: boolean) => {
// Ignore settlements from a superseded socket, a disposed effect, or an abandoned probe.
if (
effectDisposed ||
probe.connectionId !== connectionIdRef.current ||
outstandingProbeRef.current !== probe
) {
return;
}
outstandingProbeRef.current = null;
const rtt = performance.now() - probe.sentAt;
if (resolved) {
consecutiveRejectedProbesRef.current = 0;
if (rtt <= SLOW_RESPONSE_MS) {
recordFastObservation();
return;
}
recordSlowObservation(rtt);
return;
}
// A rejection is a prompt answer, not slowness: it never feeds the latency indicator.
consecutiveRejectedProbesRef.current += 1;
if (consecutiveRejectedProbesRef.current >= REJECTED_PROBES_FOR_RECONNECT) {
forceReconnect(`Liveness probe rejected ${consecutiveRejectedProbesRef.current} times`);
}
};

client.general.ping("liveness").then(
() => settle(true),
() => settle(false)
);
};

const tick = () => {
if (effectDisposed) return;
const now = performance.now();
const outstanding = outstandingProbeRef.current;

if (outstanding) {
const pendingMs = now - outstanding.sentAt;
// Silence is checked before the probe is aged out: a throttled or suspended tab can
// fire its next tick past MAX_PROBE_AGE_MS, and a half-open socket must reconnect on
// that tick rather than merely re-probe.
const silentMs = now - lastInboundBrowserFrameAtRef.current;
if (
pendingMs >= SILENCE_FOR_RECONNECT_MS &&
silentMs >= SILENCE_FOR_RECONNECT_MS &&
forceReconnect(`Liveness probe outstanding for ${pendingMs}ms with no inbound traffic`)
) {
return;
}
if (pendingMs <= MAX_PROBE_AGE_MS) {
// At most one probe in flight; a pending probe past the slow threshold is itself a
// slow observation, with the pending age as the live latency figure.
if (pendingMs > SLOW_RESPONSE_MS) {
recordSlowObservation(pendingMs);
}
return;
}
// Presume the probe lost; its eventual settlement is ignored via the identity check.
outstandingProbeRef.current = null;
}

sendProbe();
};

tick();
const intervalId = setInterval(tick, LIVENESS_INTERVAL_MS);
return () => {
effectDisposed = true;
clearInterval(intervalId);
outstandingProbeRef.current = null;
};
}, [liveClient, liveCleanup, props.createWebSocket, connect, authToken]);

const authenticate = useCallback(
(token: string) => {
authProbeAttemptedRef.current = false;
setStoredAuthToken(token);
setAuthToken(token);
connect(token);
},
[connect]
);

const retry = useCallback(() => {
authProbeAttemptedRef.current = false;
connect(authToken);
}, [connect, authToken]);

// Convert internal state to the discriminated union API
const value = useMemo((): UseAPIResult => {
const base = { authenticate, retry };
switch (state.status) {
case "connecting":
return { status: "connecting", api: null, error: null, ...base };
case "connected":
return { status: "connected", api: state.client, error: null, ...base };
case "degraded":
return { status: "degraded", api: state.client, error: null, ...base };
case "reconnecting":
return { status: "reconnecting", api: null, error: null, attempt: state.attempt, ...base };
case "auth_required":
return { status: "auth_required", api: null, error: state.error ?? null, ...base };
case "error":
return { status: "error", api: null, error: state.error, ...base };
}
}, [state, authenticate, retry]);
useAppConfigStoreClient(value.api);
// The store must see "offline" to resubscribe after the reconnect.
useAppConfigStoreClient(liveClientOf(value));

// Always render children - consumers handle their own loading/error states
return (
<APIContext.Provider value={value}>
<ConnectionLatencyContext.Provider value={latencyMs}>
{props.children}
</ConnectionLatencyContext.Provider>
</APIContext.Provider>
);
}

Check warning on line 693 in src/browser/contexts/API.tsx

View workflow job for this annotation

GitHub Actions / Quality / CRAP

CRAP crap-tax (warning)

src/browser/contexts/API.tsx:196-693 ManagedAPIProvider: touched hotspot, CC 79 unchanged: consider shrinking it

function InjectedClientAPIProvider(
props: Pick<APIProviderProps, "children" | "skipAppConfigStore"> & { client: APIClient }
Expand Down Expand Up @@ -738,6 +742,22 @@
return context;
};

/** The client while the connection is live (connected or degraded), else null. */
export function liveClientOf(state: APIState): APIClient | null {
return state.status === "connected" || state.status === "degraded" ? state.api : null;
}

/**
* The API client only while the connection is live; null while offline.
*
* `useAPI().api` can stay set while Xum reconnects, so the UI keeps what it shows. Code that must
* see "offline" uses this instead: the stores (they resubscribe when it comes back), deferred
* writes that must wait for a live server, and handlers that report "API not connected".
*/
export function useLiveAPI(): APIClient | null {
return liveClientOf(useAPI());
}

/**
* Like {@link useAPI} but returns null instead of throwing when there is no APIProvider.
* Use this in best-effort hooks that may render outside an APIProvider (e.g. isolated
Expand Down
5 changes: 3 additions & 2 deletions src/browser/contexts/WorkspaceContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
import { deleteWorkspaceStorage, migrateWorkspaceStorage } from "@/browser/utils/workspaceStorage";
import { SCRATCH_PROJECT_CONFIG_KEY } from "@/common/constants/scratch";
import { MULTI_PROJECT_CONFIG_KEY } from "@/common/constants/multiProject";
import { useAPI } from "@/browser/contexts/API";
import { useAPI, useLiveAPI } from "@/browser/contexts/API";
import { readPersistedState, updatePersistedState } from "@/browser/hooks/usePersistedState";
import { useProjectContext } from "@/browser/contexts/ProjectContext";
import { useWorkspaceStoreRaw } from "@/browser/stores/WorkspaceStore";
Expand Down Expand Up @@ -406,1498 +406,1499 @@
return metadataMap;
}

export function WorkspaceProvider(props: WorkspaceProviderProps) {
const { api } = useAPI();

// Get project refresh function from ProjectContext
const {
resolveProjectPath,
resolveNewChatProjectPath,
getProjectConfig,
hasAnyProject,
refreshProjects,
loading: projectsLoading,
} = useProjectContext();
// Get router navigation functions and current route state
const {
navigateToWorkspace,
navigateToProject,
navigateToHome,
currentWorkspaceId,
currentProjectId,
currentProjectPathFromState,
currentSettingsSection,
isAnalyticsOpen,
pendingDraftId,
} = useRouter();
const location = useLocation();

const workspaceStore = useWorkspaceStoreRaw();

useLayoutEffect(() => {
// Settings and analytics keep the workspace they were opened over in currentWorkspaceId.
// Cold modal links carry none, but should still preserve the active workspace subscription
// so chat messages aren't cleared.
if (currentWorkspaceId) {
workspaceStore.setActiveWorkspaceId(currentWorkspaceId);
} else if (!currentSettingsSection && !isAnalyticsOpen) {
// Only null out the active workspace when truly leaving a workspace
// context (for example, via the compatibility root route).
workspaceStore.setActiveWorkspaceId(null);
}
}, [workspaceStore, currentWorkspaceId, currentSettingsSection, isAnalyticsOpen]);
const [workspaceMetadata, setWorkspaceMetadataState] = useState<
Map<string, FrontendWorkspaceMetadata>
>(new Map());
const setWorkspaceMetadata = useCallback(
(update: SetStateAction<Map<string, FrontendWorkspaceMetadata>>) => {
setWorkspaceMetadataState((prev) => {
const next = typeof update === "function" ? update(prev) : update;
// IMPORTANT: Sync the imperative WorkspaceStore first so hooks (AIView,
// LeftSidebar, etc.) never render with a selected workspace ID before
// the store has subscribed and created its aggregator. Otherwise the
// render path hits WorkspaceStore.assertGet() and throws the
// "Workspace <id> not found - must call addWorkspace() first" assert.
workspaceStore.syncWorkspaces(next);
return next;
});
},
[workspaceStore]
);
useDiscardSideChatOnLeave(api, currentWorkspaceId, workspaceMetadata);
// The discard waits for a live server: useAPI().api can keep the last client while offline.
useDiscardSideChatOnLeave(useLiveAPI(), currentWorkspaceId, workspaceMetadata);
// The routed creation draft id, for the startup creation-draft storage GC (never collected).
const routedDraftIdRef = useRef<string | null>(null);
const [loading, setLoading] = useState(true);
const [loaded, setLoaded] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);

const [workspaceDraftPromotionsByProject, setWorkspaceDraftPromotionsByProject] =
useState<WorkspaceDraftPromotionsByProject>({});
// The creation draft list lives on the backend (drafts/list.json, #5225).
const workspaceDraftsByProject = useCreationDraftsByProject();
const draftsReady = useDraftStoreReady();

const pendingDeepLinksRef = useRef<DeepLinkPayload[]>([]);

const handleDeepLink = useCallback(
(payload: DeepLinkPayload) => {
if (payload.type !== "new_chat") {
return;
}

const resolvedProjectPath = resolveNewChatProjectPath({
projectPath: payload.projectPath,
projectId: payload.projectId,
project: payload.project,
});

if (!resolvedProjectPath) {
// Startup deep links can arrive before the projects list is populated.
//
// NOTE: ProjectContext can set `projectsLoading=false` even when the API isn't
// connected yet (refreshProjects() returns early but the effect still flips loading).
// In that window, buffer unresolved links in-memory and retry once projects load.
const shouldBuffer = projectsLoading || !api || !hasAnyProject;
if (shouldBuffer) {
const queue = pendingDeepLinksRef.current;
if (queue.length >= 10) {
queue.shift();
}
queue.push(payload);
}
return;
}

const resolvedProjectConfig = getProjectConfig(resolvedProjectPath);
const owningProjectPath = resolvedProjectConfig?.parentProjectPath ?? resolvedProjectPath;
const normalizedSubProjectPath = resolvedProjectConfig?.parentProjectPath
? resolvedProjectPath
: null;

// IMPORTANT: Deep links should always create a fresh draft, even if an existing draft
// is empty. This keeps deep-link navigations predictable and avoids surprising reuse.
const draftId = createDraftId();
const createdAt = Date.now();

const prompt =
typeof payload.prompt === "string" && payload.prompt.trim().length > 0
? payload.prompt
: null;

getDraftStore().putCreationDraft(owningProjectPath, {
draftId,
subProjectPath: normalizedSubProjectPath,
createdAt,
});

if (prompt) {
getDraftStore().setText(
{ kind: "creation", projectPath: owningProjectPath, draftId },
prompt
);
}

navigateToProject(owningProjectPath, draftId);
},
[
api,
navigateToProject,
projectsLoading,
resolveNewChatProjectPath,
getProjectConfig,
hasAnyProject,
]
);

const deepLinkHandlerRef = useRef(handleDeepLink);
deepLinkHandlerRef.current = handleDeepLink;

useEffect(() => {
const unsubscribe = window.api?.onDeepLink?.((payload) => {
deepLinkHandlerRef.current(payload);
});

const pending = window.api?.consumePendingDeepLinks?.() ?? [];
for (const payload of pending) {
deepLinkHandlerRef.current(payload);
}

return () => {
unsubscribe?.();
};
}, [deepLinkHandlerRef]);

useEffect(() => {
if (pendingDeepLinksRef.current.length === 0) {
return;
}

const queued = pendingDeepLinksRef.current;
pendingDeepLinksRef.current = [];

for (const payload of queued) {
deepLinkHandlerRef.current(payload);
}
}, [projectsLoading, resolveNewChatProjectPath, hasAnyProject, deepLinkHandlerRef]);

// Clean up promotions that point at removed drafts or archived workspaces so
// promoted entries never hide the real workspace list.
useEffect(() => {
if (loading) {
return;
}

setWorkspaceDraftPromotionsByProject((prev) => {
let changed = false;
const next: WorkspaceDraftPromotionsByProject = {};

for (const [projectPath, promotions] of Object.entries(prev)) {
const draftIds = new Set(
(workspaceDraftsByProject[projectPath] ?? []).map((draft) => draft.draftId)
);
if (draftIds.size === 0) {
if (Object.keys(promotions).length > 0) {
changed = true;
}
continue;
}

const nextPromotions: Record<string, FrontendWorkspaceMetadata> = {};
for (const [draftId, metadata] of Object.entries(promotions)) {
if (!draftIds.has(draftId)) {
changed = true;
continue;
}

const liveMetadata = workspaceMetadata.get(metadata.id);
if (!liveMetadata) {
changed = true;
continue;
}

nextPromotions[draftId] = liveMetadata;
}

if (Object.keys(nextPromotions).length > 0) {
next[projectPath] = nextPromotions;
if (Object.keys(nextPromotions).length !== Object.keys(promotions).length) {
changed = true;
}
} else if (Object.keys(promotions).length > 0) {
changed = true;
}
}

return changed ? next : prev;
});
}, [loading, workspaceDraftsByProject, workspaceMetadata]);

const currentProjectPath = useMemo(() => {
if (currentProjectPathFromState) return currentProjectPathFromState;
if (!currentProjectId) return null;

return (
resolveProjectPath({ type: "path", value: currentProjectId }) ??
resolveProjectPath({ type: "routeId", value: currentProjectId })
);
}, [currentProjectId, currentProjectPathFromState, resolveProjectPath]);

// pendingNewWorkspaceProject is derived from current project in URL/state
const pendingNewWorkspaceProject = currentProjectPath;
const pendingNewWorkspaceDraftId = pendingNewWorkspaceProject ? pendingDraftId : null;
// The route's draft id, set on the first render: on a cold start the route's project path
// resolves only after the projects load, and draft ids are unique across projects.
routedDraftIdRef.current = pendingDraftId;
const pendingNewWorkspaceSubProjectPathRaw =
pendingNewWorkspaceProject && pendingNewWorkspaceDraftId
? ((workspaceDraftsByProject[pendingNewWorkspaceProject] ?? []).find(
(draft) => draft.draftId === pendingNewWorkspaceDraftId
)?.subProjectPath ?? null)
: null;
// Drop the draft's stored sub-project path if the sub-project has since been
// removed/unregistered. WorkspaceService.create rejects unknown sub-project
// paths under the parent, so leaking a stale value would brick reopening a
// legacy draft. Coerce to null and let creation fall back to the parent cwd.
const pendingNewWorkspaceSubProjectPath = (() => {
if (!pendingNewWorkspaceProject || !pendingNewWorkspaceSubProjectPathRaw) {
return null;
}
const pendingSubProjectConfig = getProjectConfig(pendingNewWorkspaceSubProjectPathRaw);
return pendingSubProjectConfig?.parentProjectPath === pendingNewWorkspaceProject
? pendingNewWorkspaceSubProjectPathRaw
: null;
})();

// selectedWorkspace is derived from currentWorkspaceId in URL + workspaceMetadata
const selectedWorkspace = useMemo(() => {
if (!currentWorkspaceId) return null;
const metadata = workspaceMetadata.get(currentWorkspaceId);
if (!metadata) return null;
return toWorkspaceSelection(metadata);
}, [currentWorkspaceId, workspaceMetadata]);

const hasBlockingStartupRouteState =
selectedWorkspace != null ||
currentSettingsSection != null ||
isAnalyticsOpen ||
pendingNewWorkspaceProject != null;

const resolveFallbackWorkspaceScope = useCallback((): WorkspaceCreationScope | null => {
const recentScope = getMostRecentVisibleWorkspaceScope(
workspaceMetadata,
workspaceStore.getWorkspaceRecency(),
getProjectConfig
);
if (recentScope) {
return recentScope;
}

const projectPath = resolveNewChatProjectPath({});
return projectPath ? { projectPath, subProjectPath: null } : null;
}, [workspaceMetadata, workspaceStore, getProjectConfig, resolveNewChatProjectPath]);

// Keep a ref to the current selectedWorkspace for use in functional updates.
// Update synchronously so route-driven selection changes are visible before
// any async creation callbacks decide whether to auto-navigate.
const selectedWorkspaceRef = useRef(selectedWorkspace);
selectedWorkspaceRef.current = selectedWorkspace;
// Sticky in-memory flag to preserve explicit root-route navigation intent.
// Prevents startup-only launch-project/new-chat fallbacks from overriding later
// user-driven visits to the compatibility "/" route.
const hasExplicitRootRouteRef = useRef(false);
const hasProcessedInitialLocationRef = useRef(false);

const hasCheckedDesktopLaunchProjectRef = useRef(false);
const hasHandledStartupRootRouteRef = useRef(false);
const hasSkippedPostStartupRootRedirectRef = useRef(false);
const hasCheckedStaleRouteRef = useRef(false);

useEffect(() => {
// Skip initial mount so cold startup at "/" can still honor launch-project.
if (!hasProcessedInitialLocationRef.current) {
hasProcessedInitialLocationRef.current = true;
return;
}

// Browser history can still land on / even though the dedicated home page is gone.
// Treat that as explicit root navigation so startup-only auto-routing stays out of the way.
if (location.pathname === "/") {
hasExplicitRootRouteRef.current = true;
}
}, [location.pathname]);

// setSelectedWorkspace navigates to the workspace URL (or clears if null)
const setSelectedWorkspace = useCallback(
(update: SetStateAction<WorkspaceSelection | null>) => {
// Handle functional updates by resolving against the ref (always fresh)
const current = selectedWorkspaceRef.current;
const newValue = typeof update === "function" ? update(current) : update;

// Keep the ref in sync immediately so async handlers (metadata events, etc.) can
// reliably see the user's latest navigation intent.
selectedWorkspaceRef.current = newValue;

if (newValue) {
hasExplicitRootRouteRef.current = false;
navigateToWorkspace(newValue.workspaceId);
// Persist only the id for next session: readers re-derive the rest from metadata, and the
// paths would make the value grow with the project path.
updatePersistedState(SELECTED_WORKSPACE_KEY, { workspaceId: newValue.workspaceId });
} else {
hasExplicitRootRouteRef.current = true;
navigateToHome();
updatePersistedState(SELECTED_WORKSPACE_KEY, null);
}
},
[navigateToWorkspace, navigateToHome]
);

/**
* Clear the workspace selection and navigate to a specific project page
* instead of bouncing through the compatibility root route. Use this when
* deselecting a workspace where we know which project the user was working in.
*/
const clearSelectionToProject = useCallback(
(projectPath: string) => {
selectedWorkspaceRef.current = null;
hasExplicitRootRouteRef.current = false;
updatePersistedState(SELECTED_WORKSPACE_KEY, null);
navigateToProject(projectPath);
},
[navigateToProject]
);

// Used by async subscription handlers to safely access the most recent metadata map
// without triggering render-phase state updates.
const workspaceMetadataRef = useRef(workspaceMetadata);
useEffect(() => {
workspaceMetadataRef.current = workspaceMetadata;
}, [workspaceMetadata]);

// Arrival order of metadata stream deliveries, so a separate list() (refreshWorkspaceMetadata)
// never overwrites a snapshot or update that arrived after it was requested (#5189).
const metadataArrivalsRef = useRef({
count: 0,
lastSnapshot: 0,
lastByWorkspaceId: new Map<string, number>(),
});

// URL restoration is now handled by RouterContext which parses the URL on load
// and provides currentWorkspaceId/currentProjectId that we derive state from.

// Check for launch project from server (for --add-project flag).
// The backend decides when launch-project applies, so the frontend should
// trust any returned path as explicit startup intent.
useEffect(() => {
if (loading || !api) return;

const shouldOverrideRestoredDesktopRoute =
window.location.protocol === "file:" && !hasCheckedDesktopLaunchProjectRef.current;
if (
shouldBlockStartupAutoNavigation({
hasExplicitRootRoute: hasExplicitRootRouteRef.current,
hasBlockingRouteState: hasBlockingStartupRouteState,
shouldOverrideRestoredDesktopRoute,
})
) {
return;
}

let cancelled = false;
if (shouldOverrideRestoredDesktopRoute) {
hasCheckedDesktopLaunchProjectRef.current = true;
}

const checkLaunchProject = async () => {
// Only available in server mode (checked via platform/capabilities in future)
// For now, try the call - it will return null if not applicable
try {
const launchProjectPath = await api.server.getLaunchProject(undefined);
if (cancelled || !launchProjectPath) return;

// Find first workspace in this project
const projectWorkspaces = Array.from(workspaceMetadata.values()).filter(
(meta) => meta.projectPath === launchProjectPath
);

if (cancelled) return;

if (projectWorkspaces.length > 0) {
const metadata = projectWorkspaces[0];
if (shouldOverrideRestoredDesktopRoute) {
setSelectedWorkspace(toWorkspaceSelection(metadata));
} else {
// Use functional update to avoid race: user may have clicked a workspace
// while this async call was in flight.
setSelectedWorkspace((current) => current ?? toWorkspaceSelection(metadata));
}
return;
}

// If --add-project created a project but there are no workspaces yet, route
// directly to the project creation view so first-time users can start
// chatting immediately instead of parking on the compatibility root route.
navigateToProject(launchProjectPath);
} catch (error) {
if (!cancelled) {
// Ignore errors (e.g. method not found if running against old backend)
console.debug("Failed to check launch project:", error);
}
}
// If no workspaces exist yet, just leave the project in the sidebar
// The user will need to create a workspace
};

void checkLaunchProject();

return () => {
cancelled = true;
};
}, [
api,
loading,
selectedWorkspace,
currentSettingsSection,
isAnalyticsOpen,
pendingNewWorkspaceProject,
hasBlockingStartupRouteState,
workspaceMetadata,
setSelectedWorkspace,
navigateToProject,
]);

// Self-heal: if the initial route targets a workspace that no longer exists
// in metadata (e.g., deleted since last session), clear the stale route and
// persisted selection so the user isn't stuck in a restore loop.
useEffect(() => {
if (loading || !loaded || loadError) return;
if (hasCheckedStaleRouteRef.current) return;
hasCheckedStaleRouteRef.current = true;

if (!currentWorkspaceId) return;
if (workspaceMetadata.has(currentWorkspaceId)) return;

// If metadata is empty, a transient backend failure may have caused
// workspace.list to return nothing (it swallows getAllWorkspaceMetadata
// errors and resolves []), so don't clear a potentially valid route.
if (workspaceMetadata.size === 0) return;

setSelectedWorkspace(null);
}, [loading, loaded, loadError, currentWorkspaceId, workspaceMetadata, setSelectedWorkspace]);

// The metadata stream owns the initial load: its first event is a snapshot built after the
// server attached the listener, so no change can fall between the load and the updates (#5189).
useEffect(() => {
setLoading(true);
if (!api) {
// api not available yet - effect will run again when api connects
setLoaded(false);
setLoadError("API not connected");
return;
}
const controller = new AbortController();
const { signal } = controller;
let snapshotApplied = false;

const finishInitialLoad = async (snapshotLoaded: boolean) => {
// After loading metadata (which may trigger migration), reload projects
// to ensure frontend has the updated config with workspace IDs
try {
await refreshProjects();
} catch (error) {
if (!signal.aborted) {
setLoadError(getErrorMessage(error));
setLoading(false);
}
return;
}
if (signal.aborted) return;
setLoading(false);
// Orphaned-key GC runs only after a successful startup load, never from later refreshes
// (see workspaceStorageGc.ts). Not awaited so it cannot block or break startup.
if (!snapshotLoaded) return;
collectOrphanedWorkspaceStorage({
listKnownWorkspaceIds: async () =>
(await api.workspace.listKnownIdsForStorageGc()).workspaceIds,
}).catch((error: unknown) => {
console.error("Failed to collect orphaned workspace storage:", error);
});
// Creation-draft settings keys, against the backend draft list (see
// creationDraftStorageGc.ts); only after a real drafts snapshot, never after its timeout.
getDraftStore()
.whenReady()
.then(() => {
if (signal.aborted || !getDraftStore().isHydrated()) return;
return collectOrphanedCreationDraftStorage({
listCreationDrafts: () => api.drafts.getList(),
isLive: (projectPath, draftId) =>
(getDraftStore().getCreationDraftsByProject()[projectPath] ?? []).some(
(draft) => draft.draftId === draftId
) || routedDraftIdRef.current === draftId,
});
})
.catch((error: unknown) => {
console.error("Failed to collect orphaned creation draft storage:", error);
});
};

(async () => {
try {
const iterator = await api.workspace.onMetadata(undefined, { signal });

for await (const event of iterator) {
if (signal.aborted) break;

const arrivals = metadataArrivalsRef.current;
arrivals.count++;
if ("type" in event) {
arrivals.lastSnapshot = arrivals.count;
arrivals.lastByWorkspaceId.clear();
setWorkspaceMetadata(buildActiveWorkspaceMetadataMap(event.workspaces));
setLoaded(true);
setLoadError(null);
if (!snapshotApplied) {
snapshotApplied = true;
// Not awaited: updates must keep applying while the projects refresh is pending.
finishInitialLoad(true).catch((error: unknown) => {
console.error("Failed to finish the initial workspace load:", error);
});
}
continue;
}
arrivals.lastByWorkspaceId.set(event.workspaceId, arrivals.count);

const meta = event.metadata;

const isNowArchived =
meta !== null && isWorkspaceArchived(meta.archivedAt, meta.unarchivedAt);

// Archived metadata never enters the active map or needs renderer settings.
if (meta !== null && !isNowArchived) {
ensureCreatedAt(meta);
setWorkspaceAiMetadata(meta.id, meta);
}

// If the currently-selected workspace is being archived, navigate away *before*
// removing it from the active metadata map. Otherwise we can briefly render the
// root-route empty shell while still on `/workspace/:id`.
//
// Prefer nearby chats in the same project first so archive does not jump the
// user into another project; fall back to the project page when none remain.
if (meta !== null && isNowArchived) {
const currentSelection = selectedWorkspaceRef.current;
if (currentSelection?.workspaceId === event.workspaceId) {
const nextId = findAdjacentWorkspaceId(event.workspaceId, {
preferredProjectPath: getWorkspaceProjectRoutePath(meta),
getProjectPath: (workspaceId) => {
const workspace = workspaceMetadataRef.current.get(workspaceId);
return workspace ? getWorkspaceProjectRoutePath(workspace) : undefined;
},
});
const nextMeta = nextId ? workspaceMetadataRef.current.get(nextId) : null;

if (nextMeta) {
setSelectedWorkspace(toWorkspaceSelection(nextMeta));
} else {
clearSelectionToProject(getWorkspaceProjectRoutePath(meta));
}
}
}

// Capture deleted workspace info before removing from map (needed for navigation)
const deletedMeta =
meta === null ? workspaceMetadataRef.current.get(event.workspaceId) : null;

setWorkspaceMetadata((prev) => {
const updated = new Map(prev);
const isNewWorkspace = !prev.has(event.workspaceId) && meta !== null;
const existingMeta = prev.get(event.workspaceId);
const wasInitializing = existingMeta?.isInitializing === true;
const isNowReady = meta !== null && meta.isInitializing !== true;

if (meta === null || isNowArchived) {
// Remove deleted or newly-archived workspaces from active map
updated.delete(event.workspaceId);
} else {
// Only add/update non-archived workspaces (including unarchived ones)
updated.set(event.workspaceId, meta);
}

// Reload projects when archive state changes so the sidebar's
// embedded workspace arrays stay in sync (projects.list excludes
// archived workspaces, so archiving/unarchiving changes them).
const wasInActiveMap = prev.has(event.workspaceId);
const archiveStateChanged = isNowArchived
? wasInActiveMap // was active, now archived
: !wasInActiveMap && meta !== null; // was absent (archived), now active (unarchived)

// Config notifications refresh deleted workspace lists once after a cascade.
// Keep metadata refreshes for new workspaces and completed initialization.
if (isNewWorkspace || (wasInitializing && isNowReady) || archiveStateChanged) {
void refreshProjects();
}

return updated;
});

// 2. THEN handle side effects (cleanup, navigation) - these can't break data updates
if (meta === null) {
deleteWorkspaceStorage(event.workspaceId);
getReviewStateStore().removeWorkspace(event.workspaceId);
getDraftStore().forgetWorkspace(event.workspaceId);

// Navigate away only if the deleted workspace was selected
const currentSelection = selectedWorkspaceRef.current;
if (currentSelection?.workspaceId !== event.workspaceId) continue;

// Try parent workspace first
const parentSelection = getParentWorkspaceSelection(
deletedMeta,
workspaceMetadataRef.current
);
if (parentSelection) {
setSelectedWorkspace(parentSelection);
continue;
}

// Try sibling workspace in same project
const projectPath = deletedMeta ? getWorkspaceProjectRoutePath(deletedMeta) : undefined;
const fallbackMeta =
(projectPath
? Array.from(workspaceMetadataRef.current.values()).find(
(meta) =>
getWorkspaceProjectRoutePath(meta) === projectPath &&
meta.id !== event.workspaceId
)
: null) ??
Array.from(workspaceMetadataRef.current.values()).find(
(meta) => meta.id !== event.workspaceId
);

if (fallbackMeta) {
setSelectedWorkspace(toWorkspaceSelection(fallbackMeta));
} else if (projectPath) {
clearSelectionToProject(projectPath);
} else {
setSelectedWorkspace(null);
}
}
}
if (!snapshotApplied && !signal.aborted) {
throw new Error("Workspace metadata stream ended before its snapshot");
}
} catch (err) {
if (!signal.aborted && !isAbortError(err)) {
console.error("Failed to subscribe to metadata:", err);
// Keep the previous metadata map on failure so scoped preferences are not pruned.
if (!snapshotApplied) {
setLoadError(getErrorMessage(err));
await finishInitialLoad(false);
}
}
}
})();

return () => {
controller.abort();
};
}, [clearSelectionToProject, refreshProjects, setSelectedWorkspace, setWorkspaceMetadata, api]);

const createWorkspace = useCallback(
async (
projectPath: string,
branchName: string,
trunkBranch: string,
runtimeConfig?: RuntimeConfig
) => {
if (!api) throw new Error("API not connected");
console.assert(
typeof trunkBranch === "string" && trunkBranch.trim().length > 0,
"Expected trunk branch to be provided when creating a workspace"
);
const result = await api.workspace.create({
projectPath,
branchName,
trunkBranch,
runtimeConfig,
});
if (result.success) {
// Backend has already updated the config - reload projects to get updated state
await refreshProjects();

// Update metadata immediately to avoid race condition with validation effect
ensureCreatedAt(result.metadata);
setWorkspaceAiMetadata(result.metadata.id, result.metadata);
setWorkspaceMetadata((prev) => {
const updated = new Map(prev);
updated.set(result.metadata.id, result.metadata);
return updated;
});

// Return the new workspace selection
return {
projectPath,
projectName: result.metadata.projectName,
namedWorkspacePath: result.metadata.namedWorkspacePath,
workspaceId: result.metadata.id,
};
} else {
throw new Error(result.error);
}
},
[api, refreshProjects, setWorkspaceMetadata]
);

// Shared by workspace removal and user-confirmed sub-agent removal (#5106): both end with the
// same local cleanup and navigation.
const runWorkspaceRemoval = useCallback(
async (
workspaceId: string,
remove: (client: APIClient) => Promise<WorkspaceRemoveResult>
): Promise<WorkspaceRemoveResult> => {
if (!api) return { success: false, error: "API not connected" };

// Capture state before the async operation.
// We check currentWorkspaceId (from URL) rather than selectedWorkspace
// because it's the source of truth for what's actually selected.
const wasSelected = currentWorkspaceId === workspaceId;
const metadata = workspaceMetadata.get(workspaceId);
const projectPath = metadata
? getWorkspaceProjectRoutePath(metadata)
: selectedWorkspace?.projectPath;
const parentSelection = getParentWorkspaceSelection(metadata, workspaceMetadata);

try {
const result = await remove(api);
if (result.success) {
// Clean up workspace-specific localStorage keys
deleteWorkspaceStorage(workspaceId);
getReviewStateStore().removeWorkspace(workspaceId);
getDraftStore().forgetWorkspace(workspaceId);

// Optimistically remove from the local metadata map so the sidebar updates immediately.
// Relying on the metadata subscription can leave the item visible until the next refresh.
setWorkspaceMetadata((prev) => {
const updated = new Map(prev);
updated.delete(workspaceId);
return updated;
});

// Backend has already updated the config - reload projects to get updated state
await refreshProjects();

// Workspace metadata subscription handles the removal automatically.
// No need to refetch all metadata - this avoids expensive post-compaction
// state checks for all workspaces.

// If the removed workspace was selected (URL was on this workspace), return a sub-agent
// to its parent like the deletion handler (#5190); otherwise go to its project page.
if (wasSelected && parentSelection) {
setSelectedWorkspace(parentSelection);
} else if (wasSelected && projectPath) {
navigateToProject(projectPath);
}
// If not selected, don't navigate at all - stay where we are
// Keep the warnings: forced callers show what the removal left behind (#5143).
return { success: true, ...(result.warnings ? { warnings: result.warnings } : {}) };
} else {
console.error("Failed to remove workspace:", result.error);
return result;
}
} catch (error) {
const errorMessage = getErrorMessage(error);
console.error("Failed to remove workspace:", errorMessage);
return { success: false, error: errorMessage };
}
},
[
currentWorkspaceId,
navigateToProject,
refreshProjects,
selectedWorkspace,
setSelectedWorkspace,
workspaceMetadata,
api,
setWorkspaceMetadata,
]
);

const removeWorkspace = useCallback(
(
workspaceId: string,
options?: Parameters<APIClient["workspace"]["remove"]>[0]["options"]
): Promise<WorkspaceRemoveResult> =>
runWorkspaceRemoval(workspaceId, (client) =>
client.workspace.remove({ workspaceId, options })
),
[runWorkspaceRemoval]
);

const removeSubagent = useCallback(
(
workspaceId: string,
acknowledgedWork: Parameters<APIClient["tasks"]["remove"]>[0]["acknowledgedWork"]
): Promise<WorkspaceRemoveResult> =>
runWorkspaceRemoval(workspaceId, async (client) => {
const result = await client.tasks.remove({ taskId: workspaceId, acknowledgedWork });
return result.success
? { success: true, ...(result.data.warnings ? { warnings: result.data.warnings } : {}) }
: { success: false, error: result.error };
}),
[runWorkspaceRemoval]
);

/**
* Update workspace title (formerly "rename").
* Unlike the old rename which changed the git branch/directory name,
* this only updates the display title and can be called during streaming.
*
* Note: This is simpler than the old rename because the workspace ID doesn't change.
* We just reload metadata after the update - no need to update selectedWorkspace
* since the ID stays the same and the metadata map refresh handles the title update.
*/
const updateWorkspaceTitle = useCallback(
async (
workspaceId: string,
newTitle: string
): Promise<{ success: boolean; error?: string }> => {
if (!api) return { success: false, error: "API not connected" };
try {
const result = await api.workspace.updateTitle({ workspaceId, title: newTitle });
if (result.success) {
// Workspace metadata subscription handles the title update automatically.
// No need to refetch all metadata - this avoids expensive post-compaction
// state checks for all workspaces (which can be slow for SSH workspaces).
return { success: true };
} else {
console.error("Failed to update workspace title:", result.error);
return { success: false, error: result.error };
}
} catch (error) {
const errorMessage = getErrorMessage(error);
console.error("Failed to update workspace title:", errorMessage);
return { success: false, error: errorMessage };
}
},
[api]
);

/**
* Pin or unpin a chat. Applies an optimistic pinnedAt locally so the row
* moves on click, even over slow connections; the workspace metadata
* subscription round-trip then reconciles all clients with the server's
* authoritative monotonic timestamp (and reverts on failure).
*/
const setWorkspacePinned = useCallback(
async (workspaceId: string, pinned: boolean): Promise<{ success: boolean; error?: string }> => {
if (!api) return { success: false, error: "API not connected" };

let previousPinnedAtById: Map<string, string | undefined> | null = null;
setWorkspaceMetadata((prev) => {
const meta = prev.get(workspaceId);
if (!meta || Boolean(meta.pinnedAt) === pinned) return prev;
const touched = new Map<string, string | undefined>([[workspaceId, meta.pinnedAt]]);
const next = new Map(prev);
// Mirror the server's global append-only ordering (all projects, not
// just this one), including its write-path healing of corrupted pin
// timestamps, so every row sits where authoritative metadata will
// keep it.
let optimisticPinnedAt: string | undefined;
if (pinned) {
const { changed, pinnedAt } = appendPinnedTimestamp(
[...prev.values()]
.filter((other) => other.pinnedAt)
.map((other) => ({ id: other.id, pinnedAt: other.pinnedAt }))
);
for (const [id, healedPinnedAt] of changed) {
const other = next.get(id);
if (!other) continue;
touched.set(id, other.pinnedAt);
next.set(id, { ...other, pinnedAt: healedPinnedAt });
}
optimisticPinnedAt = pinnedAt;
}
next.set(workspaceId, { ...meta, pinnedAt: optimisticPinnedAt });
previousPinnedAtById = touched;
return next;
});
const revert = () => {
const touched: Map<string, string | undefined> | null = previousPinnedAtById;
if (!touched) return;
setWorkspaceMetadata((prev) => {
const next = new Map(prev);
for (const [id, priorPinnedAt] of touched) {
const meta = next.get(id);
if (meta) next.set(id, { ...meta, pinnedAt: priorPinnedAt });
}
return next;
});
};

try {
const result = await api.workspace.setPinned({ workspaceId, pinned });
if (result.success) {
return { success: true };
}
revert();
console.error("Failed to update workspace pin state:", result.error);
return { success: false, error: result.error };
} catch (error) {
revert();
const errorMessage = getErrorMessage(error);
console.error("Failed to update workspace pin state:", errorMessage);
return { success: false, error: errorMessage };
}
},
[api, setWorkspaceMetadata]
);

/**
* Reorder pinned chats within one project bucket. `workspaceIds` is the full
* desired pinned order for that bucket. Optimistically re-deals the existing
* pinnedAt pool locally (same algorithm as the server) so rows move on drop;
* the metadata subscription round-trip then reconciles all clients with the
* server's authoritative values (and we revert on failure).
*/
const reorderPinnedWorkspaces = useCallback(
async (workspaceIds: string[]): Promise<{ success: boolean; error?: string }> => {
if (!api) return { success: false, error: "API not connected" };

let previousPinnedAtById: Map<string, string> | undefined;
let optimisticPinnedAtById: Map<string, string> | undefined;
setWorkspaceMetadata((prev) => {
const currentPinnedAtById = new Map<string, string>();
for (const id of workspaceIds) {
const meta = prev.get(id);
if (meta?.pinnedAt) currentPinnedAtById.set(id, meta.pinnedAt);
}
const orderedIds = workspaceIds.filter((id) => currentPinnedAtById.has(id));
const changes = reassignPinnedTimestamps(orderedIds, currentPinnedAtById);
if (changes.size === 0) return prev;

const snapshot = new Map<string, string>();
const applied = new Map<string, string>();
const next = new Map(prev);
for (const [id, pinnedAt] of changes) {
const meta = prev.get(id);
const previousPinnedAt = currentPinnedAtById.get(id);
if (!meta || previousPinnedAt === undefined) continue;
snapshot.set(id, previousPinnedAt);
applied.set(id, pinnedAt);
next.set(id, { ...meta, pinnedAt });
}
previousPinnedAtById = snapshot;
optimisticPinnedAtById = applied;
return next;
});
const revert = () => {
const snapshot = previousPinnedAtById;
const applied = optimisticPinnedAtById;
if (!snapshot || !applied) return;
setWorkspaceMetadata((prev) => {
let changed = false;
const next = new Map(prev);
for (const [id, pinnedAt] of snapshot) {
const meta = prev.get(id);
// Unawaited reorders can interleave; only roll back entries this
// request still owns (current value == our optimistic value).
// Otherwise a stale failure would clobber a newer reorder's state.
if (!meta || meta.pinnedAt !== applied.get(id)) continue;
next.set(id, { ...meta, pinnedAt });
changed = true;
}
return changed ? next : prev;
});
};

try {
const result = await api.workspace.reorderPinned({ workspaceIds });
if (result.success) {
return { success: true };
}
revert();
console.error("Failed to reorder pinned chats:", result.error);
return { success: false, error: result.error };
} catch (error) {
revert();
const errorMessage = getErrorMessage(error);
console.error("Failed to reorder pinned chats:", errorMessage);
return { success: false, error: errorMessage };
}
},
[api, setWorkspaceMetadata]
);

const [archivingWorkspaceIds, setArchivingWorkspaceIds] = useState<ReadonlySet<string>>(
() => new Set()
);
const archivingRequestCounts = useRef(new Map<string, number>());

const preflightArchiveWorkspace = useCallback(
async (
workspaceId: string
): Promise<{ success: boolean; error?: string; data?: ArchivePreflightResult }> => {
if (!api) return { success: false, error: "API not connected" };

try {
const result = await trackArchivingRequest(
archivingRequestCounts.current,
setArchivingWorkspaceIds,
workspaceId,
() => api.workspace.preflightArchive({ workspaceId })
);
if (result.success) {
return { success: true, data: result.data };
}
return { success: false, error: result.error };
} catch (error) {
return { success: false, error: getErrorMessage(error) };
}
},
[api]
);

const archiveWorkspace = useCallback(
async (
workspaceId: string,
options?: { acknowledgedUntrackedPaths?: string[] }
): Promise<{ success: boolean; error?: string; data?: ArchiveWorkspaceResult }> => {
if (!api) return { success: false, error: "API not connected" };

try {
const result = await trackArchivingRequest(
archivingRequestCounts.current,
setArchivingWorkspaceIds,
workspaceId,
() =>
api.workspace.archive({
workspaceId,
acknowledgedUntrackedPaths: options?.acknowledgedUntrackedPaths,
})
);
if (result.success) {
// Older mocks/story fixtures may still return `{ success: true }` without the newer
// typed archive payload. Treat that legacy success shape as an ordinary archive so
// archive flows stay downgrade-friendly instead of crashing on `result.data.kind`.
const archiveResult: ArchiveWorkspaceResult = result.data ?? { kind: "archived" };

if (archiveResult.kind === "archived") {
// Terminal PTYs are killed on archive; clear persisted terminal tabs so
// unarchive doesn't briefly flash dead terminal tabs.
const layoutKey = getRightSidebarLayoutKey(workspaceId);
const rawLayout = readPersistedState<unknown>(layoutKey, null);

if (rawLayout != null) {
// Use parseRightSidebarLayoutState to handle legacy migrations
// (e.g. "stats" tab stripped) before cleaning terminal tabs.
const layout = parseRightSidebarLayoutState(rawLayout);
const terminalTabs = collectAllTabs(layout.root).filter(isTerminalTab);
let cleanedLayout = layout;
for (const tab of terminalTabs) {
cleanedLayout = removeTabEverywhere(cleanedLayout, tab);
}
updatePersistedState(layoutKey, cleanedLayout);
}

// Also clear persisted terminal titles since those sessions are gone.
updatePersistedState(getTerminalTitlesKey(workspaceId), {});
}

// Workspace list + navigation are driven by the workspace metadata subscription.
return { success: true, data: archiveResult };
}

console.error("Failed to archive workspace:", result.error);
return { success: false, error: result.error };
} catch (error) {
const errorMessage = getErrorMessage(error);
console.error("Failed to archive workspace:", errorMessage);
return { success: false, error: errorMessage };
}
},
[api]
);

const unarchiveWorkspace = useCallback(
async (workspaceId: string): Promise<{ success: boolean; error?: string }> => {
if (!api) return { success: false, error: "API not connected" };
try {
const result = await api.workspace.unarchive({ workspaceId });
if (result.success) {
// Workspace metadata subscription handles the state update automatically.
return { success: true };
} else {
console.error("Failed to unarchive workspace:", result.error);
return { success: false, error: result.error };
}
} catch (error) {
const errorMessage = getErrorMessage(error);
console.error("Failed to unarchive workspace:", errorMessage);
return { success: false, error: errorMessage };
}
},
[api]
);

const refreshWorkspaceMetadata = useCallback(async () => {
if (!api) {
setLoaded(false);
setLoadError("API not connected");
return;
}
const arrivals = metadataArrivalsRef.current;
const requestedAt = arrivals.count;
try {
const metadataList = await api.workspace.list();
// A snapshot that arrived after the request, plus the updates after it, supersedes this list.
if (arrivals.lastSnapshot > requestedAt) return;
// Rows an update touched after the request keep their current state, including a deletion.
const touchedIds = new Set<string>();
for (const [workspaceId, arrivedAt] of arrivals.lastByWorkspaceId) {
if (arrivedAt > requestedAt) touchedIds.add(workspaceId);
}
const listed = buildActiveWorkspaceMetadataMap(
metadataList.filter((metadata) => !touchedIds.has(metadata.id))
);
setWorkspaceMetadata((prev) => {
const next = new Map(listed);
for (const workspaceId of touchedIds) {
const current = prev.get(workspaceId);
if (current) next.set(workspaceId, current);
}
return next;
});
setLoaded(true);
setLoadError(null);
} catch (error) {
console.error("Failed to load workspace metadata:", error);
// Keep the previous metadata map on failure so scoped preferences are not pruned.
setLoadError(getErrorMessage(error));
}
}, [api, setWorkspaceMetadata]);

const getWorkspaceInfo = useCallback(
async (workspaceId: string) => {
if (!api) return null;
const metadata = await api.workspace.getInfo({ workspaceId });
if (metadata) {
ensureCreatedAt(metadata);
setWorkspaceAiMetadata(metadata.id, metadata);
}
return metadata;
},
[api]
);

const promoteWorkspaceDraft = useCallback(
(projectPath: string, draftId: string, metadata: FrontendWorkspaceMetadata) => {
if (projectPath.trim().length === 0) return;
if (draftId.trim().length === 0) return;

setWorkspaceDraftPromotionsByProject((prev) => {
const currentProject = prev[projectPath] ?? {};
const existing = currentProject[draftId];
if (existing?.id === metadata.id) {
return prev;
}

return {
...prev,
[projectPath]: {
...currentProject,
[draftId]: metadata,
},
};
});
},
[]
);
const beginWorkspaceCreation = useCallback(
(projectPath: string) => {
navigateToProject(projectPath);
},
[navigateToProject]
);
// Persist sub-project selection + URL updates so draft sub-project switches stick across navigation.
const updateWorkspaceDraftSubProject = useCallback(
(projectPath: string, draftId: string, subProjectPath: string | null) => {
if (projectPath.trim().length === 0) return;
if (draftId.trim().length === 0) return;

const normalizedSubProjectPath =
typeof subProjectPath === "string" && subProjectPath.trim().length > 0
? subProjectPath
: null;

const existing = (getDraftStore().getCreationDraftsByProject()[projectPath] ?? []).find(
(draft) => draft.draftId === draftId
);
if (existing && existing.subProjectPath !== normalizedSubProjectPath) {
getDraftStore().putCreationDraft(projectPath, {
...existing,
subProjectPath: normalizedSubProjectPath,
});
}

navigateToProject(projectPath, draftId);
},
[navigateToProject]
);

const createWorkspaceDraft = useCallback(
(projectPath: string, subProjectPath?: string, options?: { replace?: boolean }) => {
// Read the store directly to get the freshest value, avoiding stale closure issues.
// The React state (workspaceDraftsByProject) may be out of date if this is called rapidly.
const existingDrafts = getDraftStore().getCreationDraftsByProject()[projectPath] ?? [];

// Text typed on the bare project page (no draft id) lives in the project's default creation
// draft, which only that page shows. While it holds anything, the project row opens it
// instead of a new draft, so typed text is never hidden behind an empty composer (#5071).
if (subProjectPath === undefined && !isDefaultCreationDraftEmpty(projectPath)) {
navigateToProject(projectPath, undefined, { replace: options?.replace });
return;
}

// If there's an existing empty draft (optionally in the same sub-project), reuse it
// instead of creating yet another empty draft.
const existingEmptyDraftId = findExistingEmptyDraft(
existingDrafts,
projectPath,
subProjectPath
);
if (existingEmptyDraftId) {
navigateToProject(projectPath, existingEmptyDraftId, {
replace: options?.replace,
});
return;
}

const draftId = createDraftId();
const createdAt = Date.now();
const draft: WorkspaceDraft = {
draftId,
subProjectPath: subProjectPath ?? null,
createdAt,
};

// One-time migration: if the user has an old per-project pending draft, move it
// into the first draft scope so it stays accessible.
// The default creation composer's draft (no draft id) moves into the first listed one.
if (existingDrafts.length === 0) {
const pendingScopeId = getPendingScopeId(projectPath);
const defaultScope = defaultCreationDraftScope(projectPath);
const pending = getDraftStore().getView(defaultScope);
if (pending.text.trim().length > 0 || pending.attachmentCount > 0) {
// Never rejects; the default draft is deleted only once the new one is saved.
getDraftStore()
.moveDraft(defaultScope, { kind: "creation", projectPath, draftId })
.catch(() => undefined);
migrateWorkspaceStorage(pendingScopeId, getDraftScopeId(projectPath, draftId));
}
}
getDraftStore().putCreationDraft(projectPath, draft);

navigateToProject(projectPath, draftId, { replace: options?.replace });
},
[navigateToProject]
);

useEffect(() => {
// Waits for the drafts: the "new-chat" launch behavior reuses an empty listed draft, and the
// list is hydrated from the backend.
if (loading || projectsLoading || !draftsReady || hasHandledStartupRootRouteRef.current) {
return;
}

if (
shouldBlockStartupAutoNavigation({
hasExplicitRootRoute: hasExplicitRootRouteRef.current,
hasBlockingRouteState: hasBlockingStartupRouteState,
})
) {
return;
}

const behavior = getUserPreferences().navigation?.launchBehavior ?? "dashboard";
let cancelled = false;

const resolveStartupRootRoute = async () => {
// Defer to launch-project whenever the backend reports one so explicit startup
// intent can win before the local fallback chooses a project route.
try {
const launchProject = await api?.server.getLaunchProject(undefined);
if (cancelled || launchProject) return;
} catch {
// Ignore backend capability errors and continue with the local startup fallback.
}

if (cancelled) return;

const fallbackScope = resolveFallbackWorkspaceScope();
if (!fallbackScope) return;

hasHandledStartupRootRouteRef.current = true;

// The old landing page is gone. Treat "/" as a compatibility entrypoint and
// immediately replace it with a concrete project route instead of rendering a dashboard.
// Reusing an empty listed draft needs the real list; after a drafts timeout, open the
// project page instead of creating a draft that could duplicate an existing empty one.
if (behavior === "new-chat" && getDraftStore().isHydrated()) {
createWorkspaceDraft(fallbackScope.projectPath, fallbackScope.subProjectPath ?? undefined, {
replace: true,
});
return;
}

navigateToProject(fallbackScope.projectPath, undefined, { replace: true });
};

void resolveStartupRootRoute();

return () => {
cancelled = true;
};
}, [
api,
loading,
projectsLoading,
draftsReady,
hasBlockingStartupRouteState,
resolveFallbackWorkspaceScope,
createWorkspaceDraft,
navigateToProject,
]);

useEffect(() => {
if (!hasSkippedPostStartupRootRedirectRef.current) {
hasSkippedPostStartupRootRedirectRef.current = true;
return;
}
if (loading || projectsLoading) return;
if (location.pathname !== "/") return;

const fallbackScope = resolveFallbackWorkspaceScope();
if (!fallbackScope) return;

navigateToProject(fallbackScope.projectPath, undefined, { replace: true });
}, [
loading,
projectsLoading,
location.pathname,
resolveFallbackWorkspaceScope,
navigateToProject,
]);

const openWorkspaceDraft = useCallback(
(projectPath: string, draftId: string) => {
navigateToProject(projectPath, draftId);
},
[navigateToProject]
);

const deleteWorkspaceDraft = useCallback(
(projectPath: string, draftId: string) => {
setWorkspaceDraftPromotionsByProject((prev) => {
const currentProject = prev[projectPath];
if (!currentProject || !(draftId in currentProject)) {
return prev;
}

const nextProject = { ...currentProject };
delete nextProject[draftId];

const next: WorkspaceDraftPromotionsByProject = { ...prev };
if (Object.keys(nextProject).length === 0) {
delete next[projectPath];
} else {
next[projectPath] = nextProject;
}
return next;
});

deleteWorkspaceStorage(getDraftScopeId(projectPath, draftId));
// Delists the draft and deletes its backend file; failures are retried by the store.
getDraftStore()
.deleteDraft({ kind: "creation", projectPath, draftId })
.catch(() => undefined);
},
[setWorkspaceDraftPromotionsByProject]
);

// Split into two context values so metadata-Map churn doesn't re-render
// components that only need actions/selection/drafts.
const metadataValue = useMemo<WorkspaceMetadataContextValue>(
() => ({ workspaceMetadata, loading, loaded, loadError }),
[workspaceMetadata, loading, loaded, loadError]
);

const actionsValue = useMemo(
() => ({
createWorkspace,
removeWorkspace,
removeSubagent,
updateWorkspaceTitle,
setWorkspacePinned,
reorderPinnedWorkspaces,
preflightArchiveWorkspace,
archiveWorkspace,
unarchiveWorkspace,
archivingWorkspaceIds,
refreshWorkspaceMetadata,
setWorkspaceMetadata,
selectedWorkspace,
setSelectedWorkspace,
pendingNewWorkspaceProject,
pendingNewWorkspaceSubProjectPath,
pendingNewWorkspaceDraftId,
beginWorkspaceCreation,
workspaceDraftsByProject,
workspaceDraftPromotionsByProject,
promoteWorkspaceDraft,
createWorkspaceDraft,
updateWorkspaceDraftSubProject,
openWorkspaceDraft,
deleteWorkspaceDraft,
getWorkspaceInfo,
}),
[
createWorkspace,
removeWorkspace,
removeSubagent,
updateWorkspaceTitle,
setWorkspacePinned,
reorderPinnedWorkspaces,
preflightArchiveWorkspace,
archiveWorkspace,
unarchiveWorkspace,
archivingWorkspaceIds,
refreshWorkspaceMetadata,
setWorkspaceMetadata,
selectedWorkspace,
setSelectedWorkspace,
pendingNewWorkspaceProject,
pendingNewWorkspaceSubProjectPath,
pendingNewWorkspaceDraftId,
beginWorkspaceCreation,
workspaceDraftsByProject,
workspaceDraftPromotionsByProject,
promoteWorkspaceDraft,
createWorkspaceDraft,
updateWorkspaceDraftSubProject,
openWorkspaceDraft,
deleteWorkspaceDraft,
getWorkspaceInfo,
]
);

return (
<WorkspaceMetadataContext.Provider value={metadataValue}>
<WorkspaceActionsContext.Provider value={actionsValue}>
{props.children}
</WorkspaceActionsContext.Provider>
</WorkspaceMetadataContext.Provider>
);
}

Check warning on line 1901 in src/browser/contexts/WorkspaceContext.tsx

View workflow job for this annotation

GitHub Actions / Quality / CRAP

CRAP crap-tax (warning)

src/browser/contexts/WorkspaceContext.tsx:409-1901 WorkspaceProvider: touched hotspot, CC 249 unchanged: consider shrinking it

/**
* Subscribe to workspace metadata only. Use this in components that need the
Expand Down
120 changes: 120 additions & 0 deletions src/browser/contexts/connection.testHarness.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { act } from "@testing-library/react";
import type { ClientContext, ClientLink } from "@orpc/client";
import { useState, type ReactNode } from "react";
import { APIContext, type APIClient, type UseAPIResult } from "@/browser/contexts/API";
import { ConnectionLink } from "@/browser/contexts/connectionLink";
import type { TestApiOverrides } from "@/browser/testUtils";
import { createClient } from "@/common/orpc/client";

/**
* How the API context looks while Xum reconnects.
* - `null-gap` (the provider today): `api` goes from a client to `null`, then to a new client.
* - `kept-gap` (the provider after the switch): `api` stays the last client with status
* "reconnecting"; its calls reject with ConnectionLostError. Then a new client arrives.
*
* Code that passes in both modes is safe today and cannot break when the provider switches.
*/
export type GapMode = "null-gap" | "kept-gap";
export const GAP_MODES: readonly GapMode[] = ["null-gap", "kept-gap"];

type Procedure = (input: unknown, options: unknown) => unknown;

/** An inner link that runs each call on the matching function of a test override object. */
function overridesLink(overrides: TestApiOverrides<APIClient>): ClientLink<ClientContext> {
return {
// A then() so a missing override or a throwing one rejects, as a real call does.
call: (path, input, options) =>
Promise.resolve().then(() => {
let node: unknown = overrides;
for (const key of path) node = (node as Record<string, unknown> | undefined)?.[key];
if (typeof node !== "function") {
throw new Error(`No test override for ${path.join(".")}`);
}
return (node as Procedure)(input, { signal: options.signal, context: options.context });
}),
};
}

const noop = () => undefined;

function connected(api: APIClient): UseAPIResult {
return { status: "connected", api, error: null, authenticate: noop, retry: noop };
}

/**
* A connection the test drops and restores. Each connect builds a real oRPC client over one
* ConnectionLink, as the provider will, from fresh overrides (`makeOverrides()`), so a test can
* make the next server answer differently.
*
* The drop keeps the real order of each mode. Today the provider's state change renders first
* (React handles a socket `close` event as discrete) and the transport rejects the streams after.
* After the switch, the provider detaches the link first, so calls reject before React renders.
*/
export function createConnectionHarness(
mode: GapMode,
makeOverrides: () => TestApiOverrides<APIClient>
) {
const link = new ConnectionLink();
let client: APIClient | null = null;
let publish: ((value: UseAPIResult) => void) | null = null;
// The value a Wrapper mounted now starts with (a panel reopened after a tab switch).
let latest: UseAPIResult;
let attempt = 0;

const connect = (): UseAPIResult => {
link.attach(overridesLink(makeOverrides()));
client = createClient(link);
return connected(client);
};
latest = connect();

const setValue = (value: UseAPIResult) => {
latest = value;
if (publish != null) act(() => publish?.(value));
};

function Wrapper(props: { children: ReactNode }) {
const [value, setValue] = useState<UseAPIResult>(() => latest);
publish = setValue;
return <APIContext.Provider value={value}>{props.children}</APIContext.Provider>;
}

return {
mode,
Wrapper,
/** The client of the current (or last) connection. */
get client(): APIClient | null {
return client;
},
/** Lose the connection. In-flight calls and streams reject with ConnectionLostError. */
async drop(): Promise<void> {
attempt += 1;
const reconnecting = (api: APIClient | null): UseAPIResult => ({
status: "reconnecting",
api,
error: null,
attempt,
authenticate: noop,
retry: noop,
});
if (mode === "null-gap") {
setValue(reconnecting(null));
link.detach();
} else {
link.detach();
await act(async () => {
// Let the rejections reach their handlers before React sees the drop. A macrotask runs
// only after every queued microtask, however long each rejection chain is.
await new Promise((resolve) => setTimeout(resolve, 0));
});
setValue(reconnecting(client));
}
},
/** Connect again: a new client object, as after a real reconnect. */
reconnect(): void {
setValue(connect());
},
};
}

export type ConnectionHarness = ReturnType<typeof createConnectionHarness>;
82 changes: 82 additions & 0 deletions src/browser/contexts/connectionLink.bench.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/**
* Cost the ConnectionLink adds per call and per stream event, against the inner link alone.
* Run: make bench BENCH=connectionLink
*
* Every API call and every stream event (terminal output, chat deltas) goes through the link
* once the provider uses it. Gate for the switch: the wrapper must add less than 1 µs per event.
*/
import { bench, do_not_optimize, summary } from "mitata";
import type { ClientContext, ClientLink, ClientOptions } from "@orpc/client";
import { createClient } from "@/common/orpc/client";
import { ConnectionLink } from "@/browser/contexts/connectionLink";

const OPTIONS: ClientOptions<ClientContext> = { context: {} };
const EVENTS = 1000;

/** A stream that yields EVENTS values with no I/O, so the bench measures only the wrappers. */
function syntheticStream(): AsyncIteratorObject<unknown, unknown, unknown> &
AsyncIterable<unknown> {
let i = 0;
const stream = {
next: () =>
Promise.resolve(
i < EVENTS
? { done: false as const, value: { n: i++ } }
: { done: true as const, value: undefined }
),
return: (value?: unknown) => Promise.resolve({ done: true as const, value }),
throw: (error?: unknown) =>
Promise.reject(error instanceof Error ? error : new Error(String(error))),
[Symbol.asyncIterator]() {
return stream;
},
[Symbol.asyncDispose]: () => Promise.resolve(),
};
return stream;
}

const callLink: ClientLink<ClientContext> = { call: () => Promise.resolve("pong") };
const streamLink: ClientLink<ClientContext> = { call: () => Promise.resolve(syntheticStream()) };

function attached(inner: ClientLink<ClientContext>): ClientLink<ClientContext> {
const link = new ConnectionLink();
link.attach(inner);
return link;
}

const wrappedCallLink = attached(callLink);
const wrappedStreamLink = attached(streamLink);

summary(() => {
bench("one-shot call: inner link", async () => {
do_not_optimize(await callLink.call(["general", "ping"], "x", OPTIONS));
});
bench("one-shot call: ConnectionLink", async () => {
do_not_optimize(await wrappedCallLink.call(["general", "ping"], "x", OPTIONS));
});
});

summary(() => {
bench(`stream of ${EVENTS} events: inner link`, async () => {
const stream = (await streamLink.call(
["terminal", "attach"],
{},
OPTIONS
)) as AsyncIterable<unknown>;
for await (const event of stream) do_not_optimize(event);
});
bench(`stream of ${EVENTS} events: ConnectionLink`, async () => {
const stream = (await wrappedStreamLink.call(
["terminal", "attach"],
{},
OPTIONS
)) as AsyncIterable<unknown>;
for await (const event of stream) do_not_optimize(event);
});
});

// Runs once per successful connect after the switch: a new client on the shared link.
bench("createClient(link) + first property access", () => {
const client = createClient(wrappedCallLink);
do_not_optimize(client.general.ping);
});
Loading
Loading