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 && ( +
+
+ +

{loadingLabel}

+
+
+ )}
); } @@ -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", () => {