fix(desktop): quiet gate for workspace switches instead of boot splash (#1449)

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Wes
2026-07-02 07:46:20 -07:00
committed by GitHub
co-authored by Brain
parent 7da936fff8
commit b779a3ee2f
4 changed files with 84 additions and 3 deletions
+43 -2
View File
@@ -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 (
<div
className="flex min-h-dvh items-center justify-center bg-background"
data-testid="workspace-switch-gate"
role="status"
>
<StartupWindowDragRegion />
<span className="sr-only">Switching workspace…</span>
{showSpinner ? (
<Spinner aria-hidden="true" className="text-muted-foreground" />
) : null}
</div>
);
}
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 <OnboardingLoadingGate />;
}
return <AppLoadingGate />;
return isWorkspaceSwitch ? <WorkspaceSwitchGate /> : <AppLoadingGate />;
}
return <RouterProvider router={router} />;
@@ -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 <OnboardingLoadingGate />;
}
return <AppLoadingGate />;
return isWorkspaceSwitch ? <WorkspaceSwitchGate /> : <AppLoadingGate />;
}
return (
@@ -314,6 +354,7 @@ export function App() {
<AppReady
canBackToWorkspaceSetup={canBackToWorkspaceSetup}
isCompletingFirstRunWorkspace={isCompletingFirstRunWorkspace}
isWorkspaceSwitch={isWorkspaceSwitch}
key={workspaceKey}
isSharedIdentity={sharedIdentity}
onFirstRunWorkspaceSettled={handleFirstRunWorkspaceSettled}
+11 -1
View File
@@ -93,6 +93,9 @@ type E2eConfig = {
channelsReadError?: string;
feedReadError?: string;
canvasReadError?: string;
/** Delay (ms) for `apply_workspace` so e2e tests can observe the
* workspace-switch gate. 0/undefined = instant. */
applyWorkspaceDelayMs?: number;
openDmDelayMs?: number;
sendMessageDelayMs?: number;
usersBatchDelayMs?: number;
@@ -7462,8 +7465,15 @@ export function maybeInstallE2eTauriMocks() {
return importMockIdentity(
(payload as { nsec?: string } | null)?.nsec ?? "",
);
case "apply_workspace":
case "apply_workspace": {
const applyDelayMs = activeConfig?.mock?.applyWorkspaceDelayMs ?? 0;
if (applyDelayMs > 0) {
return new Promise((resolve) =>
window.setTimeout(resolve, applyDelayMs),
);
}
return;
}
case "get_profile":
return handleGetProfile(activeConfig);
case "update_profile":
+28
View File
@@ -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);
+2
View File
@@ -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;