diff --git a/desktop/src/app/App.tsx b/desktop/src/app/App.tsx
index 4be18f502..86bce8376 100644
--- a/desktop/src/app/App.tsx
+++ b/desktop/src/app/App.tsx
@@ -7,6 +7,7 @@ import {
useCallback,
useEffect,
useLayoutEffect,
+ useRef,
useState,
} from "react";
@@ -26,6 +27,7 @@ import { isSharedIdentity as isSharedIdentityCmd } from "@/shared/api/tauri";
import { listenForDeepLinks } from "@/shared/deep-link";
import { useSystemColorScheme } from "@/shared/theme/useSystemColorScheme";
import { Button } from "@/shared/ui/button";
+import { Spinner } from "@/shared/ui/spinner";
import { StartupWindowDragRegion } from "@/shared/ui/StartupWindowDragRegion";
import { StepProgress } from "@/shared/ui/step-progress";
@@ -54,6 +56,31 @@ function AppLoadingGate() {
);
}
+// Quiet gate for switching between already-set-up workspaces: visually empty
+// unless the switch takes long, so fast switches don't flash the boot splash.
+function WorkspaceSwitchGate() {
+ const [showSpinner, setShowSpinner] = useState(false);
+
+ useEffect(() => {
+ const timer = window.setTimeout(() => setShowSpinner(true), 300);
+ return () => window.clearTimeout(timer);
+ }, []);
+
+ return (
+
+
+ Switching workspace…
+ {showSpinner ? (
+
+ ) : null}
+
+ );
+}
+
function OnboardingLoadingGate() {
const systemColorScheme = useSystemColorScheme();
@@ -155,12 +182,14 @@ function AppReady({
canBackToWorkspaceSetup,
isCompletingFirstRunWorkspace,
isSharedIdentity,
+ isWorkspaceSwitch,
onFirstRunWorkspaceSettled,
onBackToWorkspaceSetup,
}: {
canBackToWorkspaceSetup: boolean;
isCompletingFirstRunWorkspace: boolean;
isSharedIdentity: boolean;
+ isWorkspaceSwitch: boolean;
onFirstRunWorkspaceSettled: () => void;
onBackToWorkspaceSetup: () => void;
}) {
@@ -193,7 +222,7 @@ function AppReady({
return ;
}
- return ;
+ return isWorkspaceSwitch ? : ;
}
return ;
@@ -251,6 +280,17 @@ export function App() {
// Composite key: changes when workspace ID changes OR when
// the active workspace's config is updated (relayUrl/token).
const workspaceKey = `${activeWorkspace?.id ?? "none"}-${reinitKey}`;
+
+ // Latch once the workspace key deviates from its cold-boot value: from then
+ // on, loading phases are in-app switches and get the quiet gate instead of
+ // the full "Setting up your workspace" splash.
+ const initialWorkspaceKeyRef = useRef(workspaceKey);
+ const hasSwitchedWorkspaceRef = useRef(false);
+ if (workspaceKey !== initialWorkspaceKeyRef.current) {
+ hasSwitchedWorkspaceRef.current = true;
+ }
+ const isWorkspaceSwitch = hasSwitchedWorkspaceRef.current;
+
const workspace = useWorkspaceInit(
activeWorkspace,
workspaceKey,
@@ -306,7 +346,7 @@ export function App() {
return ;
}
- return ;
+ return isWorkspaceSwitch ? : ;
}
return (
@@ -314,6 +354,7 @@ export function App() {
0) {
+ return new Promise((resolve) =>
+ window.setTimeout(resolve, applyDelayMs),
+ );
+ }
return;
+ }
case "get_profile":
return handleGetProfile(activeConfig);
case "update_profile":
diff --git a/desktop/tests/e2e/workspace-rail.spec.ts b/desktop/tests/e2e/workspace-rail.spec.ts
index e57eaa5bf..fcbb9be25 100644
--- a/desktop/tests/e2e/workspace-rail.spec.ts
+++ b/desktop/tests/e2e/workspace-rail.spec.ts
@@ -73,6 +73,34 @@ test.describe("workspace rail", () => {
.toBe(WORKSPACE_B.id);
});
+ test("shows the quiet switch gate, not the boot splash, while switching", async ({
+ page,
+ }) => {
+ // Slow down apply_workspace so the loading phase is observable.
+ await installMockBridge(
+ page,
+ { applyWorkspaceDelayMs: 800 },
+ { skipWorkspaceSeed: true },
+ );
+ await seedWorkspaces(page, [WORKSPACE_A, WORKSPACE_B], WORKSPACE_A.id);
+ await page.goto("/");
+
+ // Cold boot still uses the full splash.
+ await expect(page.getByTestId("app-loading-gate")).toBeVisible();
+ const buttonB = page.getByTestId(`workspace-rail-button-${WORKSPACE_B.id}`);
+ await expect(buttonB).toBeVisible();
+
+ await buttonB.click();
+
+ // The switch renders the quiet gate; the "Setting up your workspace"
+ // splash must not reappear.
+ await expect(page.getByTestId("workspace-switch-gate")).toBeVisible();
+ await expect(page.getByTestId("app-loading-gate")).toHaveCount(0);
+
+ // The app settles into the new workspace once apply completes.
+ await expect(buttonB).toHaveAttribute("aria-current", "true");
+ });
+
test("hides the rail with a single workspace", async ({ page }) => {
await installMockBridge(page, undefined, { skipWorkspaceSeed: true });
await seedWorkspaces(page, [WORKSPACE_A], WORKSPACE_A.id);
diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts
index 2d1cc2561..6b09e023f 100644
--- a/desktop/tests/helpers/bridge.ts
+++ b/desktop/tests/helpers/bridge.ts
@@ -111,6 +111,8 @@ type MockBridgeOptions = {
channelsReadError?: string;
feedReadError?: string;
canvasReadError?: string;
+ /** Delay (ms) for `apply_workspace`; see e2eBridge mock config. */
+ applyWorkspaceDelayMs?: number;
openDmDelayMs?: number;
sendMessageDelayMs?: number;
usersBatchDelayMs?: number;