Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 43 additions & 2 deletions desktop/src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";

Expand All @@ -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";

Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -155,12 +182,14 @@ function AppReady({
canBackToWorkspaceSetup,
isCompletingFirstRunWorkspace,
isSharedIdentity,
isWorkspaceSwitch,
onFirstRunWorkspaceSettled,
onBackToWorkspaceSetup,
}: {
canBackToWorkspaceSetup: boolean;
isCompletingFirstRunWorkspace: boolean;
isSharedIdentity: boolean;
isWorkspaceSwitch: boolean;
onFirstRunWorkspaceSettled: () => void;
onBackToWorkspaceSetup: () => void;
}) {
Expand Down Expand Up @@ -193,7 +222,7 @@ function AppReady({
return <OnboardingLoadingGate />;
}

return <AppLoadingGate />;
return isWorkspaceSwitch ? <WorkspaceSwitchGate /> : <AppLoadingGate />;
}

return <RouterProvider router={router} />;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -306,14 +346,15 @@ export function App() {
return <OnboardingLoadingGate />;
}

return <AppLoadingGate />;
return isWorkspaceSwitch ? <WorkspaceSwitchGate /> : <AppLoadingGate />;
}

return (
<WorkspaceQueryProvider key={workspaceKey}>
<AppReady
canBackToWorkspaceSetup={canBackToWorkspaceSetup}
isCompletingFirstRunWorkspace={isCompletingFirstRunWorkspace}
isWorkspaceSwitch={isWorkspaceSwitch}
key={workspaceKey}
isSharedIdentity={sharedIdentity}
onFirstRunWorkspaceSettled={handleFirstRunWorkspaceSettled}
Expand Down
12 changes: 11 additions & 1 deletion desktop/src/testing/e2eBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,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;
Expand Down Expand Up @@ -7130,8 +7133,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":
Expand Down
28 changes: 28 additions & 0 deletions desktop/tests/e2e/workspace-rail.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions desktop/tests/helpers/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading