mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-10 10:26:46 +08:00
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:
co-authored by
Forge-Boy
multica-agent
parent
af62622e50
commit
f95c3b657a
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user