diff --git a/apps/desktop/src/renderer/src/App.auth-recovery.test.tsx b/apps/desktop/src/renderer/src/App.auth-recovery.test.tsx
new file mode 100644
index 000000000..b8e507ce5
--- /dev/null
+++ b/apps/desktop/src/renderer/src/App.auth-recovery.test.tsx
@@ -0,0 +1,234 @@
+import type { ReactNode } from "react";
+import { fireEvent, render, screen } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const state = vi.hoisted(() => ({
+ auth: {
+ isLoading: false,
+ retryAuthentication: vi.fn(),
+ status: "authenticated",
+ user: { id: "user-1" } as { id: string } | null,
+ },
+ refetchWorkspaceList: vi.fn(),
+ workspaceListUnavailable: false,
+}));
+
+const tabStore = vi.hoisted(() => ({
+ activeWorkspaceSlug: "acme" as string | null,
+ byWorkspace: {},
+ closeActiveTab: vi.fn(),
+ reset: vi.fn(),
+ switchWorkspace: vi.fn(),
+ validateWorkspaceSlugs: vi.fn(),
+}));
+
+vi.mock("@tanstack/react-query", () => ({
+ useQueryClient: () => ({ setQueryData: vi.fn() }),
+}));
+
+vi.mock("@multica/core/platform", () => ({
+ CoreProvider: ({ children }: { children: ReactNode }) => <>{children}>,
+ setCurrentWorkspace: vi.fn(),
+}));
+
+vi.mock("@multica/core/i18n", () => ({
+ pickLocale: () => "en",
+}));
+
+vi.mock("@multica/core/auth", () => ({
+ useAuthStore: (selector: (auth: typeof state.auth) => unknown) =>
+ selector(state.auth),
+}));
+
+vi.mock("@multica/core/onboarding", () => ({
+ useWelcomeStore: { getState: () => ({ reset: vi.fn() }) },
+}));
+
+vi.mock("@multica/core/workspace/queries", () => ({
+ workspaceKeys: { list: () => ["workspace-list"] },
+}));
+
+vi.mock("@multica/core/workspace", () => ({
+ useWorkspaceList: () => ({
+ isFetching: false,
+ ready: true,
+ refetch: state.refetchWorkspaceList,
+ unavailable: state.workspaceListUnavailable,
+ workspaces: [{ id: "ws-1", slug: "acme" }],
+ }),
+}));
+
+vi.mock("@multica/core/api", () => ({
+ api: {
+ listMyInvitations: vi.fn(),
+ listWorkspaces: vi.fn(),
+ },
+}));
+
+vi.mock("@multica/core/paths", () => ({
+ useHasOnboarded: () => true,
+}));
+
+vi.mock("@multica/core/analytics", () => ({ captureEvent: vi.fn() }));
+vi.mock("@multica/ui/components/common/theme-provider", () => ({
+ ThemeProvider: ({ children }: { children: ReactNode }) => <>{children}>,
+}));
+vi.mock("@multica/ui/components/common/multica-icon", () => ({
+ MulticaIcon: () =>
,
+}));
+vi.mock("@multica/ui/components/ui/sonner", () => ({ Toaster: () => null }));
+vi.mock("@multica/views/locales", () => ({ RESOURCES: { en: {} } }));
+
+vi.mock("./pages/login", () => ({
+ DesktopLoginPage: () => ,
+}));
+vi.mock("./pages/auth-recovery", () => ({
+ DesktopAuthRecoveryPage: ({
+ isRetrying = false,
+ onRetry,
+ }: {
+ isRetrying?: boolean;
+ onRetry?: () => void;
+ }) => (
+
+ {onRetry && (
+
+ )}
+
+ ),
+}));
+vi.mock("./components/desktop-layout", () => ({
+ DesktopShell: () => ,
+}));
+vi.mock("./components/update-notification", () => ({
+ UpdateNotification: () => null,
+}));
+vi.mock("./components/issue-window", () => ({
+ IssueWindow: () => ,
+}));
+
+vi.mock("./stores/tab-store", () => ({
+ useTabStore: Object.assign(
+ (selector: (value: typeof tabStore) => unknown) => selector(tabStore),
+ { getState: () => tabStore },
+ ),
+}));
+vi.mock("./stores/window-overlay-store", () => ({
+ useWindowOverlayStore: {
+ getState: () => ({ close: vi.fn(), open: vi.fn(), overlay: null }),
+ },
+}));
+
+vi.mock("./hooks/use-open-settings-shortcut", () => ({
+ useOpenSettingsShortcut: () => {},
+}));
+vi.mock("./hooks/use-tab-selection-shortcut", () => ({
+ useTabSelectionShortcut: () => {},
+}));
+vi.mock("./platform/daemon-ipc-bridge", () => ({
+ useDaemonIPCBridge: () => {},
+}));
+vi.mock("./platform/daemon-login-sync", () => ({
+ syncDaemonOnLogin: vi.fn(),
+}));
+vi.mock("./platform/i18n-adapter", () => ({
+ createDesktopLocaleAdapter: () => ({
+ getSystemPreferences: () => ["en"],
+ getUserChoice: () => null,
+ persist: vi.fn(),
+ }),
+}));
+vi.mock("./platform/client-usage-reporter", () => ({
+ DesktopClientUsageReporter: () => null,
+}));
+vi.mock("./platform/diagnostic-route-reporter", () => ({
+ DiagnosticRouteReporter: () => null,
+}));
+vi.mock("./freeze-flush", () => ({ flushFreezeBreadcrumb: vi.fn() }));
+vi.mock("./platform/auth-session-bridge", () => ({
+ DesktopAuthSessionBridge: () => null,
+}));
+vi.mock("./platform/session-teardown", () => ({
+ tearDownOnLogout: vi.fn(),
+ tearDownOnSessionExpiry: vi.fn(),
+}));
+
+const { default: App } = await import("./App");
+
+beforeEach(() => {
+ state.auth.isLoading = false;
+ state.auth.status = "authenticated";
+ state.auth.user = { id: "user-1" };
+ state.refetchWorkspaceList.mockReset();
+ state.workspaceListUnavailable = false;
+ tabStore.activeWorkspaceSlug = "acme";
+
+ Object.assign(window, {
+ daemonAPI: {
+ clearToken: vi.fn(),
+ restart: vi.fn(),
+ setTargetApiUrl: vi.fn(),
+ stop: vi.fn(),
+ },
+ desktopAPI: {
+ ackFreeze: vi.fn(),
+ appInfo: { os: "macos", version: "0.5.1" },
+ closeWindow: vi.fn(),
+ getLastFreeze: vi.fn(),
+ onAuthToken: () => () => {},
+ onCloseActiveTab: () => () => {},
+ onInviteOpen: () => () => {},
+ onSystemLocaleChanged: () => () => {},
+ reportAuthSession: vi.fn(),
+ runtimeConfig: {
+ config: { apiUrl: "http://localhost", wsUrl: "ws://localhost" },
+ ok: true,
+ },
+ systemLocale: "en",
+ windowContext: { kind: "main" },
+ },
+ });
+});
+
+describe("App main-window auth recovery", () => {
+ it("keeps auth recovery ahead of the desktop shell while the session recovers", () => {
+ state.auth.status = "recovering";
+
+ render();
+
+ expect(screen.getByTestId("auth-recovery")).toBeInTheDocument();
+ expect(screen.queryByTestId("desktop-shell")).toBeNull();
+ });
+
+ it("uses recovery with retry when the workspace list is unavailable", () => {
+ state.workspaceListUnavailable = true;
+
+ render();
+
+ expect(screen.getByTestId("auth-recovery")).toBeInTheDocument();
+ expect(screen.queryByTestId("desktop-shell")).toBeNull();
+
+ fireEvent.click(
+ screen.getByRole("button", { name: "Retry workspace list" }),
+ );
+ expect(state.refetchWorkspaceList).toHaveBeenCalledOnce();
+ });
+
+ it("keeps auth recovery ahead of a dedicated issue window", () => {
+ state.auth.status = "recovering";
+ window.desktopAPI.windowContext = {
+ issueId: "MUL-1",
+ kind: "issue",
+ path: "/acme/issues/MUL-1",
+ title: "MUL-1",
+ workspaceSlug: "acme",
+ };
+
+ render();
+
+ expect(screen.getByTestId("auth-recovery")).toBeInTheDocument();
+ expect(screen.queryByTestId("issue-window")).toBeNull();
+ });
+});
diff --git a/apps/desktop/src/renderer/src/components/desktop-layout.test.tsx b/apps/desktop/src/renderer/src/components/desktop-layout.test.tsx
index 844baf10f..a16c7fac4 100644
--- a/apps/desktop/src/renderer/src/components/desktop-layout.test.tsx
+++ b/apps/desktop/src/renderer/src/components/desktop-layout.test.tsx
@@ -1,5 +1,5 @@
import type { ReactNode } from "react";
-import { describe, expect, it, vi } from "vitest";
+import { beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@multica/core/i18n/react";
@@ -84,9 +84,21 @@ vi.mock("./window-overlay", () => ({ WindowOverlay: () => null }));
// a `PageHeader` reads before deciding to render its own fallback trigger.
vi.mock("./tab-content", () => ({
TabContent: () => {
- const { hasExternalTrigger } = useSidebar();
+ const { hasExternalTrigger, isCompact, setOpen, state } = useSidebar();
return (
-
+
+
+
+
);
},
}));
@@ -115,6 +127,14 @@ function renderShell() {
);
}
+beforeEach(() => {
+ Object.defineProperty(window, "innerWidth", {
+ configurable: true,
+ value: 1280,
+ });
+ localStorage.clear();
+});
+
describe("DesktopShell sidebar trigger", () => {
// The window toolbar parks a trigger beside the traffic lights that never
// scrolls away, so nothing inside the canvas may add a second one. Desktop
@@ -123,7 +143,7 @@ describe("DesktopShell sidebar trigger", () => {
// this one — and a third when a list/detail surface brought its own header
// along (MUL-6218).
it("keeps exactly one trigger and tells page headers not to add another", () => {
- const { container, getByTestId } = renderShell();
+ const { container, getByTestId, queryByRole } = renderShell();
expect(container.querySelectorAll("[data-slot='sidebar-trigger']")).toHaveLength(1);
expect(getByTestId("page-content")).toHaveAttribute(
@@ -142,5 +162,37 @@ describe("DesktopShell sidebar trigger", () => {
container.querySelector('[data-slot="sidebar-trigger"]')!,
);
expect(mainTopBar).toHaveStyle({ paddingLeft: "256px" });
+ expect(queryByRole("status", { name: /loading workspace/i })).toBeNull();
+ });
+
+ it("keeps full clearance for compact layouts in either sidebar state", () => {
+ Object.defineProperty(window, "innerWidth", {
+ configurable: true,
+ value: 800,
+ });
+
+ const { container, getByRole, getByTestId } = renderShell();
+ const mainTopBar = container.querySelector('[data-slot="main-top-bar"]');
+
+ expect(getByTestId("page-content")).toHaveAttribute("data-compact", "true");
+ expect(getByTestId("page-content")).toHaveAttribute(
+ "data-sidebar-state",
+ "expanded",
+ );
+ expect(mainTopBar).toHaveStyle({ paddingLeft: "256px" });
+
+ fireEvent.click(getByRole("button", { name: "Collapse sidebar" }));
+ expect(getByTestId("page-content")).toHaveAttribute(
+ "data-sidebar-state",
+ "collapsed",
+ );
+ expect(mainTopBar).toHaveStyle({ paddingLeft: "256px" });
+
+ fireEvent.click(getByRole("button", { name: "Expand sidebar" }));
+ expect(getByTestId("page-content")).toHaveAttribute(
+ "data-sidebar-state",
+ "expanded",
+ );
+ expect(mainTopBar).toHaveStyle({ paddingLeft: "256px" });
});
});
diff --git a/apps/desktop/src/renderer/src/components/desktop-layout.tsx b/apps/desktop/src/renderer/src/components/desktop-layout.tsx
index 37501db13..70ec88f54 100644
--- a/apps/desktop/src/renderer/src/components/desktop-layout.tsx
+++ b/apps/desktop/src/renderer/src/components/desktop-layout.tsx
@@ -2,6 +2,7 @@ import { useEffect, useRef, useSyncExternalStore } from "react";
import { motion } from "motion/react";
import { useQuery } from "@tanstack/react-query";
import { cn } from "@multica/ui/lib/utils";
+import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
import {
useNavigationInputBindings,
useTabHistory,
@@ -26,6 +27,7 @@ import {
} from "@multica/views/navigation";
import { getCurrentSlug, subscribeToCurrentSlug } from "@multica/core/platform";
import { useDesktopUnreadBadge } from "@multica/views/platform";
+import { useT } from "@multica/views/i18n";
import {
DesktopNavigationProvider,
routeContentLinkPath,
@@ -67,9 +69,9 @@ function useNativeNavigationGestures() {
// do not land beneath the traffic lights / navigation controls. The matching
// 200ms transition cancels the sidebar gap's movement during toggle; live
// resize previews disable it through data-sidebar-resize-consumer.
-function MainTopBar() {
+function MainTopBar({ sidebarMounted }: { sidebarMounted: boolean }) {
const { state, isCompact } = useSidebar();
- const sidebarHidden = state === "collapsed" || isCompact;
+ const sidebarHidden = !sidebarMounted || state === "collapsed" || isCompact;
const toolbarClearance: React.CSSProperties["paddingLeft"] = sidebarHidden
? WINDOW_TOOLBAR_CLEARANCE
: `max(0px, calc(${WINDOW_TOOLBAR_CLEARANCE}px - var(--sidebar-live-width, var(--sidebar-width))))`;
@@ -107,9 +109,17 @@ function MainTopBar() {
// The canvas hugs the expanded sidebar with a hairline gap. When the sidebar
// leaves the main flow, the left margin must grow to mirror the fixed mr-2 so
// the floating canvas sits symmetrically inside the window frame.
-function MainCanvas({ children }: { children: React.ReactNode }) {
+function MainCanvas({
+ children,
+ showWorkspaceLoading,
+}: {
+ children: React.ReactNode;
+ showWorkspaceLoading: boolean;
+}) {
const { state, isCompact } = useSidebar();
+ const { t } = useT("layout");
const sidebarHidden = state === "collapsed" || isCompact;
+ const loadingLabel = t(($) => $.workspace_loader.loading_workspace);
return (
{children}
+ {showWorkspaceLoading && (
+
+ )}
);
}
@@ -251,8 +274,8 @@ export function DesktopShell() {
{slug && } searchSlot={} />}
{/* Right side: header + content container */}
-
-
+
+
{/* Same indicator, same anchor as web: DashboardLayout puts it
at the top of SidebarInset, and MainCanvas is desktop's
equivalent relative/overflow-hidden content box. Desktop
diff --git a/apps/desktop/src/renderer/src/components/desktop-layout.workspace-gate.test.tsx b/apps/desktop/src/renderer/src/components/desktop-layout.workspace-gate.test.tsx
index 2b28b7af8..599795d6b 100644
--- a/apps/desktop/src/renderer/src/components/desktop-layout.workspace-gate.test.tsx
+++ b/apps/desktop/src/renderer/src/components/desktop-layout.workspace-gate.test.tsx
@@ -139,19 +139,43 @@ function renderShell() {
}
beforeEach(() => {
+ Object.defineProperty(window, "innerWidth", {
+ configurable: true,
+ value: 1280,
+ });
+ localStorage.clear();
state.currentSlug = "acme";
state.wsList = [{ id: "ws-1", slug: "acme" }];
});
describe("DesktopShell workspace gating", () => {
it("mounts workspace-scoped chrome while the slug resolves", () => {
- const { queryByTestId } = renderShell();
+ const { queryByRole, queryByTestId } = renderShell();
expect(queryByTestId("app-sidebar")).not.toBeNull();
expect(queryByTestId("search-command")).not.toBeNull();
expect(queryByTestId("global-shortcuts")).not.toBeNull();
expect(queryByTestId("modal-registry")).not.toBeNull();
expect(queryByTestId("floating-chat")).not.toBeNull();
+ expect(queryByRole("status", { name: /loading workspace/i })).toBeNull();
+ });
+
+ it("keeps traffic-light clearance and a loading shell while the slug is unresolved", () => {
+ state.currentSlug = null;
+
+ const { container, getByRole, getByTestId, queryByTestId } = renderShell();
+
+ expect(queryByTestId("app-sidebar")).toBeNull();
+ expect(
+ container.querySelectorAll("[data-slot='sidebar-trigger']"),
+ ).toHaveLength(0);
+ expect(
+ container.querySelector('[data-slot="main-top-bar"]'),
+ ).toHaveStyle({ paddingLeft: "256px" });
+ expect(
+ getByRole("status", { name: /loading workspace/i }),
+ ).toBeVisible();
+ expect(getByTestId("tab-content")).toBeInTheDocument();
});
it("drops workspace-scoped chrome when the singleton still points at a deleted workspace", () => {
@@ -166,6 +190,7 @@ describe("DesktopShell workspace gating", () => {
expect(queryByTestId("global-shortcuts")).toBeNull();
expect(queryByTestId("modal-registry")).toBeNull();
expect(queryByTestId("floating-chat")).toBeNull();
+ expect(queryByTestId("tab-content")).not.toBeNull();
});
it("keeps TabContent mounted with no workspace so the tab router can still resolve one", () => {