From 0f78f5003b953bb7fd20b69bb97f9b8872fe6ac4 Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 1 Jul 2026 16:47:15 -0600 Subject: [PATCH] fix(desktop): quiet gate for workspace switches instead of boot splash Switching workspaces via the rail replayed the full cold-boot splash ("Setting up your workspace..." with its rise animation) for the brief moment apply_workspace runs, making every switch jarring. Latch once the workspace key deviates from its cold-boot value and render a quiet gate (plain background, spinner only after 300ms) for switches. Cold boot and first-run onboarding keep the existing splash. Adds an applyWorkspaceDelayMs mock knob so the e2e test can observe the gate deterministically. Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co> Signed-off-by: Wes --- desktop/src/app/App.tsx | 45 ++++++++++++++++++++++-- desktop/src/testing/e2eBridge.ts | 12 ++++++- desktop/tests/e2e/workspace-rail.spec.ts | 28 +++++++++++++++ desktop/tests/helpers/bridge.ts | 2 ++ 4 files changed, 84 insertions(+), 3 deletions(-) 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 ? ( +
+ ); +} + 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;