fix(desktop): preserve startup toolbar clearance (#8686)

Co-authored-by: Forge-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
This commit is contained in:
Multica Eve
2026-09-22 15:20:16 +08:00
committed by GitHub
co-authored by Forge-Boy multica-agent
parent af62622e50
commit f95c3b657a
4 changed files with 344 additions and 10 deletions
@@ -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: () => <div data-testid="app-loading" />,
}));
vi.mock("@multica/ui/components/ui/sonner", () => ({ Toaster: () => null }));
vi.mock("@multica/views/locales", () => ({ RESOURCES: { en: {} } }));
vi.mock("./pages/login", () => ({
DesktopLoginPage: () => <div data-testid="login-page" />,
}));
vi.mock("./pages/auth-recovery", () => ({
DesktopAuthRecoveryPage: ({
isRetrying = false,
onRetry,
}: {
isRetrying?: boolean;
onRetry?: () => void;
}) => (
<div data-testid="auth-recovery" data-retrying={isRetrying}>
{onRetry && (
<button type="button" onClick={onRetry}>
Retry workspace list
</button>
)}
</div>
),
}));
vi.mock("./components/desktop-layout", () => ({
DesktopShell: () => <div data-testid="desktop-shell" />,
}));
vi.mock("./components/update-notification", () => ({
UpdateNotification: () => null,
}));
vi.mock("./components/issue-window", () => ({
IssueWindow: () => <div data-testid="issue-window" />,
}));
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(<App />);
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(<App />);
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(<App />);
expect(screen.getByTestId("auth-recovery")).toBeInTheDocument();
expect(screen.queryByTestId("issue-window")).toBeNull();
});
});
@@ -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 (
<div data-testid="page-content" data-external-trigger={hasExternalTrigger} />
<div
data-compact={isCompact}
data-external-trigger={hasExternalTrigger}
data-sidebar-state={state}
data-testid="page-content"
>
<button type="button" onClick={() => setOpen(false)}>
Collapse sidebar
</button>
<button type="button" onClick={() => setOpen(true)}>
Expand sidebar
</button>
</div>
);
},
}));
@@ -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" });
});
});
@@ -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 (
<motion.div
@@ -119,6 +129,19 @@ function MainCanvas({ children }: { children: React.ReactNode }) {
transition={toolbarMotion}
>
{children}
{showWorkspaceLoading && (
<div
aria-label={loadingLabel}
aria-live="polite"
className="absolute inset-0 z-20 flex items-center justify-center bg-page-canvas"
role="status"
>
<div className="flex flex-col items-center gap-4">
<MulticaIcon className="size-8 animate-pulse" />
<p className="text-body text-muted-foreground">{loadingLabel}</p>
</div>
</div>
)}
</motion.div>
);
}
@@ -251,8 +274,8 @@ export function DesktopShell() {
{slug && <AppSidebar topSlot={<SidebarTopSpacer />} searchSlot={<SearchTrigger />} />}
{/* Right side: header + content container */}
<div className="flex flex-1 min-w-0 flex-col">
<MainTopBar />
<MainCanvas>
<MainTopBar sidebarMounted={Boolean(slug)} />
<MainCanvas showWorkspaceLoading={!slug}>
{/* 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
@@ -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", () => {