Compare commits

...

1 Commits

Author SHA1 Message Date
Naiyuan Qing
f85494dcf8 refactor(desktop): model pre-workspace flows as window overlays, not tab routes
Previously /workspaces/new and /invite/:id were tab routes on desktop.
That meant the TabBar rendered on top of flows that conceptually aren't
"places" the user sits at — creating a workspace or accepting an invite
is a one-shot transition, not a session. The mismatch also produced
several downstream bugs: tab state persisted these paths, the invite
deep link had no clean dispatch target, and NoAccessPage leaked TabBar
chrome when a workspace slug went stale.

Fix by recognising the underlying category mistake: on desktop, these
flows are application state, not routes. Move them to a window-level
overlay driven by a small Zustand store; the navigation adapter
intercepts pushes to the corresponding paths and routes them to the
overlay instead. Web keeps the routes (users need shareable URLs and
back-button semantics), so shared view components are reused as-is.

UX affordances (Back button when dismissable, Log out escape) live in
the shared NewWorkspacePage/InvitePage so both platforms render
identical content; the desktop overlay is now a thin platform shell
(drag strip + useImmersiveMode) that wraps the shared UX. Web wires
onBack based on whether the user has any workspaces.

Also addresses several related issues uncovered along the way:
- Logout now resets the in-memory tab + overlay stores (previously only
  localStorage was cleared, so the next login inherited the prior
  user's tabs).
- WorkspaceRouteLayout auto-heals a stale workspace slug by navigating
  to "/" instead of rendering NoAccessPage — on desktop without a URL
  bar, "no access" is always stale state, not a legitimate destination.
- IndexRedirect overlay lifecycle is bidirectional: opens when wsList
  is empty, closes when it becomes non-empty (realtime workspace:added
  would otherwise leave the overlay stuck open).
- tryRouteToOverlay resets the current tab to "/" when opening the
  new-workspace overlay; otherwise workspace-scoped components under
  the overlay continue to render and throw when the workspace they
  reference disappears from the cache (reproducible by deleting the
  last workspace from Settings).
- handleDeepLink now accepts multica://invite/<id>, IPC'd through to
  the renderer and opened as an invite overlay. Email template still
  links to https:// (unchanged), but the desktop dispatch path is now
  wired for a future "open in desktop app" bridge.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 10:38:33 +08:00
16 changed files with 473 additions and 130 deletions

View File

@@ -48,6 +48,19 @@ function handleDeepLink(url: string): void {
if (token && mainWindow) {
mainWindow.webContents.send("auth:token", token);
}
return;
}
// multica://invite/<invitationId>
// Dispatched from the web invite page when the user chooses "Open in
// desktop app". The renderer opens the invite overlay — no tab, no
// route persistence, so deep-linking the same invite twice stays safe.
if (parsed.hostname === "invite") {
const id = parsed.pathname.replace(/^\//, "");
if (id && mainWindow) {
mainWindow.webContents.send("invite:open", decodeURIComponent(id));
}
return;
}
} catch {
// Ignore malformed URLs

View File

@@ -3,6 +3,8 @@ import { ElectronAPI } from "@electron-toolkit/preload";
interface DesktopAPI {
/** Listen for auth token delivered via deep link. Returns an unsubscribe function. */
onAuthToken: (callback: (token: string) => void) => () => void;
/** Listen for invitation IDs delivered via deep link. Returns an unsubscribe function. */
onInviteOpen: (callback: (invitationId: string) => void) => () => void;
/** Open a URL in the default browser. */
openExternal: (url: string) => Promise<void>;
/** Hide macOS traffic lights for full-screen modals; restore when false. */

View File

@@ -11,6 +11,15 @@ const desktopAPI = {
ipcRenderer.removeListener("auth:token", handler);
};
},
/** Listen for invitation IDs delivered via deep link */
onInviteOpen: (callback: (invitationId: string) => void) => {
const handler = (_event: Electron.IpcRendererEvent, invitationId: string) =>
callback(invitationId);
ipcRenderer.on("invite:open", handler);
return () => {
ipcRenderer.removeListener("invite:open", handler);
};
},
/** Open a URL in the default browser */
openExternal: (url: string) => ipcRenderer.invoke("shell:openExternal", url),
/** Toggle immersive mode — hide macOS traffic lights for full-screen modals */

View File

@@ -11,6 +11,7 @@ import { DesktopLoginPage } from "./pages/login";
import { DesktopShell } from "./components/desktop-layout";
import { UpdateNotification } from "./components/update-notification";
import { useTabStore } from "./stores/tab-store";
import { useWindowOverlayStore } from "./stores/window-overlay-store";
function AppContent() {
const user = useAuthStore((s) => s.user);
@@ -31,6 +32,17 @@ function AppContent() {
window.daemonAPI.setTargetApiUrl(DAEMON_TARGET_API_URL);
}, []);
// Listen for invite IDs delivered via deep link (multica://invite/<id>).
// We open the overlay regardless of login state — if the user isn't logged
// in, InvitePage's queries will fail and render the "not found" state,
// which is acceptable; the expected pre-flight happens in the web app
// (login + next=/invite/... dance) before the deep link is ever dispatched.
useEffect(() => {
return window.desktopAPI.onInviteOpen((invitationId) => {
useWindowOverlayStore.getState().open({ type: "invite", invitationId });
});
}, []);
// Listen for auth token delivered via deep link (multica://auth/callback?token=...).
// daemonAPI.syncToken is handled separately by the [user] effect below, which
// fires whenever a user logs in (deep link, session restore, account switch).
@@ -135,9 +147,14 @@ function AppContent() {
const DAEMON_TARGET_API_URL =
import.meta.env.VITE_API_URL || "http://localhost:8080";
// On logout, clear any cached PAT and stop the daemon so that a subsequent
// login as a different user never inherits the previous user's credentials.
// On logout, wipe desktop-only in-memory state and stop the daemon so that
// a subsequent login as a different user never inherits the previous user's
// tabs, overlay, or credentials. Zustand persist only writes to localStorage;
// useLogout clears the storage key, but the live stores stay populated until
// we explicitly reset them here.
async function handleDaemonLogout() {
useTabStore.getState().reset();
useWindowOverlayStore.getState().close();
try {
await window.daemonAPI.clearToken();
} catch {

View File

@@ -18,6 +18,7 @@ import { getCurrentSlug, subscribeToCurrentSlug } from "@multica/core/platform";
import { DesktopNavigationProvider } from "@/platform/navigation";
import { TabBar } from "./tab-bar";
import { TabContent } from "./tab-content";
import { WindowOverlay } from "./window-overlay";
function SidebarTopBar() {
const { canGoBack, canGoForward, goBack, goForward } = useTabHistory();
@@ -113,7 +114,8 @@ export function DesktopShell() {
mount WorkspaceRouteLayout, which calls setCurrentWorkspace()
to populate the slug. The sidebar gates on slug being present
to avoid the useRequiredWorkspaceSlug throw. Zero-workspace
users are routed to /workspaces/new by IndexRedirect. */}
users see the window-level overlay (new-workspace flow)
triggered by IndexRedirect, not a route. */}
<WorkspaceSlugProvider slug={slug}>
<div className="flex h-screen">
<SidebarProvider className="flex-1">
@@ -132,6 +134,7 @@ export function DesktopShell() {
</div>
{slug && <ModalRegistry />}
{slug && <SearchCommand />}
<WindowOverlay />
</WorkspaceSlugProvider>
</DesktopNavigationProvider>
);

View File

@@ -0,0 +1,76 @@
import { useQuery } from "@tanstack/react-query";
import { useImmersiveMode } from "@multica/views/platform";
import { NewWorkspacePage } from "@multica/views/workspace/new-workspace-page";
import { InvitePage } from "@multica/views/invite";
import { useNavigation } from "@multica/views/navigation";
import { paths } from "@multica/core/paths";
import { workspaceListOptions } from "@multica/core/workspace/queries";
import { useWindowOverlayStore } from "@/stores/window-overlay-store";
/**
* Window-level transition overlay: renders above the tab system when the
* user is in a pre-workspace flow (create workspace, accept invite).
*
* This component is a thin **platform shell**:
* - Hands the window-drag strip and macOS traffic-light hiding
* (`useImmersiveMode`) — both are platform-specific, web has neither
* - Covers the tab system (fixed inset, z-50) so the Shell's own TabBar
* doesn't leak through
*
* All UX affordances (Back button, Log out button, welcome copy, invite
* card) live inside the shared `NewWorkspacePage` / `InvitePage`
* components under `packages/views/`, so web and desktop render identical
* content. The platform split is: UX in shared code, chrome here.
*/
export function WindowOverlay() {
const overlay = useWindowOverlayStore((s) => s.overlay);
if (!overlay) return null;
return <WindowOverlayInner />;
}
function WindowOverlayInner() {
const overlay = useWindowOverlayStore((s) => s.overlay);
const close = useWindowOverlayStore((s) => s.close);
const { push } = useNavigation();
const { data: wsList = [] } = useQuery(workspaceListOptions());
useImmersiveMode();
if (!overlay) return null;
// Back is only meaningful when there's somewhere to go — i.e. the user
// has at least one workspace. Zero-workspace users can only Log out or
// complete the flow.
const onBack = wsList.length > 0 ? close : undefined;
return (
<div className="fixed inset-0 z-50 flex flex-col bg-background">
{/* Window-drag strip — OS-level. Transparent; covers the area
traffic lights would normally sit (they're hidden by
useImmersiveMode on macOS). */}
<div
aria-hidden
className="absolute inset-x-0 top-0 h-10 z-10"
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
/>
<div
className="flex-1 min-h-0 overflow-auto"
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
>
{overlay.type === "new-workspace" && (
<NewWorkspacePage
onSuccess={(ws) => push(paths.workspace(ws.slug).issues())}
onBack={onBack}
/>
)}
{overlay.type === "invite" && (
<InvitePage
invitationId={overlay.invitationId}
onBack={onBack}
/>
)}
</div>
</div>
);
}

View File

@@ -5,7 +5,6 @@ import { WorkspaceSlugProvider, paths } from "@multica/core/paths";
import { workspaceBySlugOptions } from "@multica/core/workspace";
import { setCurrentWorkspace } from "@multica/core/platform";
import { useAuthStore } from "@multica/core/auth";
import { NoAccessPage } from "@multica/views/workspace/no-access-page";
import { useWorkspaceSeen } from "@multica/views/workspace/use-workspace-seen";
/**
@@ -17,9 +16,13 @@ import { useWorkspaceSeen } from "@multica/views/workspace/use-workspace-seen";
* guaranteed non-null when called. Two industry-standard identities are
* kept distinct: slug (URL / browser) and UUID (API / cache keys).
*
* If the slug doesn't resolve to any workspace the user has access to,
* we render NoAccessPage instead of silently redirecting — users get
* explicit feedback for stale bookmarks or revoked access.
* Unlike web, desktop never renders a "workspace not available" page: the
* app has no URL bar and no clickable links from outside the session, so
* landing on an inaccessible slug can only mean stale state (persisted tab
* from a previous account) or active eviction (admin removal, realtime
* delete). Both cases resolve by bouncing to `/`, where IndexRedirect
* picks a valid destination — the next workspace, or the new-workspace
* overlay if the user has none.
*/
export function WorkspaceRouteLayout() {
const { workspaceSlug } = useParams<{ workspaceSlug: string }>();
@@ -49,11 +52,22 @@ export function WorkspaceRouteLayout() {
setCurrentWorkspace(workspaceSlug, workspace.id);
}
// Remember whether this slug has resolved before (see hook docs). Gates
// the NoAccessPage render below so active workspace removal doesn't
// flash "Workspace not available" before the navigate lands.
// Remember whether this slug has resolved before. `useWorkspaceSeen`
// gates the auto-heal below so the mid-flight frame of an active-removal
// navigation doesn't double-bounce.
const hasBeenSeen = useWorkspaceSeen(workspaceSlug, !!workspace);
// Stale slug (tab persisted from a previous account, or revoked access
// that hasn't yet been cleaned up by validateWorkspaceSlugs): auto-heal
// to `/`. IndexRedirect takes it from there.
useEffect(() => {
if (!user) return;
if (!listFetched) return;
if (workspace) return;
if (hasBeenSeen) return; // active eviction in flight — let the other path win
navigate("/", { replace: true });
}, [user, listFetched, workspace, hasBeenSeen, navigate]);
if (isAuthLoading) return null;
if (!workspaceSlug) return null;
// Don't render children until workspace is resolved. useWorkspaceId()
@@ -61,15 +75,7 @@ export function WorkspaceRouteLayout() {
// unknown — gating here is the single point where that invariant is
// enforced, so every descendant can call useWorkspaceId() safely.
if (!listFetched) return null;
if (!workspace) {
// Active workspace just removed (delete/leave/realtime eviction) —
// navigate is in flight; hold null briefly instead of flashing
// NoAccessPage.
if (hasBeenSeen) return null;
// Genuinely inaccessible slug (stale bookmark, revoked access, or a
// link from a former teammate's workspace) → explicit feedback.
return <NoAccessPage />;
}
if (!workspace) return null; // auto-heal effect above handles the navigation
return (
<WorkspaceSlugProvider slug={workspaceSlug}>

View File

@@ -6,15 +6,67 @@ import {
} from "@multica/views/navigation";
import { useAuthStore } from "@multica/core/auth";
import { useTabStore, resolveRouteIcon } from "@/stores/tab-store";
import { useWindowOverlayStore } from "@/stores/window-overlay-store";
const ROOT_PATH = "/";
// Public web app URL — injected at build time via .env.production. Falls
// back to the production host for dev builds so "Copy link" yields a URL
// that actually points somewhere a teammate can open.
const APP_URL = import.meta.env.VITE_APP_URL || "https://multica.ai";
/**
* Intercept navigation to "transition" paths — pre-workspace flows that on
* desktop are rendered as a window-level overlay instead of a tab route.
* Returns `true` if the navigation was handled (caller should NOT proceed
* with its own router navigation).
*
* Side effect: when opening the new-workspace overlay, the tab router is
* ALSO reset to "/". Rationale — the only way a push lands on
* /workspaces/new is that the workspace context is gone (fresh install,
* delete-last, leave-last). Leaving the tab parked on a workspace-scoped
* path like /acme/settings keeps those components mounted under the
* overlay; the next render after the list cache updates would then throw
* (useWorkspaceId, useCurrentWorkspace, etc) because the slug no longer
* resolves. Resetting to "/" unmounts them synchronously. For invite
* overlay we intentionally do NOT reset — invite is orthogonal and the
* user's workspace context may still be valid.
*
* Navigations to any other path implicitly close an open overlay, so the
* overlay's lifetime is tied to the user staying in a transition state.
*/
function tryRouteToOverlay(path: string, router?: DataRouter): boolean {
const overlay = useWindowOverlayStore.getState();
if (path === "/workspaces/new") {
overlay.open({ type: "new-workspace" });
if (router && router.state.location.pathname !== ROOT_PATH) {
router.navigate(ROOT_PATH, { replace: true });
}
return true;
}
if (path.startsWith("/invite/")) {
let id = "";
try {
id = decodeURIComponent(path.slice("/invite/".length));
} catch {
// Malformed percent-encoding — treat as no-op so the caller's push
// doesn't attempt to navigate into the (now-removed) invite route
// and leave the tab stuck on a 404-shaped path.
return true;
}
if (id) {
overlay.open({ type: "invite", invitationId: id });
return true;
}
}
// Any other navigation cancels a live overlay.
if (overlay.overlay) overlay.close();
return false;
}
/**
* Root-level navigation provider for components outside the per-tab RouterProviders
* (sidebar, search dialog, modals, etc.).
* (sidebar, search dialog, modals, WindowOverlay contents, etc.).
*
* Reads from the active tab's memory router via router.subscribe().
* Does NOT use any react-router hooks — it's above all RouterProviders.
@@ -25,7 +77,7 @@ export function DesktopNavigationProvider({
children: React.ReactNode;
}) {
const activeTab = useTabStore((s) => s.tabs.find((t) => t.id === s.activeTabId));
const [pathname, setPathname] = useState(activeTab?.path ?? "/issues");
const [pathname, setPathname] = useState(activeTab?.path ?? "/");
// Subscribe to the active tab's router for pathname updates
useEffect(() => {
@@ -47,12 +99,14 @@ export function DesktopNavigationProvider({
const tab = useTabStore.getState().tabs.find(
(t) => t.id === useTabStore.getState().activeTabId,
);
if (tryRouteToOverlay(path, tab?.router)) return;
tab?.router.navigate(path);
},
replace: (path: string) => {
const tab = useTabStore.getState().tabs.find(
(t) => t.id === useTabStore.getState().activeTabId,
);
if (tryRouteToOverlay(path, tab?.router)) return;
tab?.router.navigate(path, { replace: true });
},
back: () => {
@@ -101,8 +155,14 @@ export function TabNavigationProvider({
const adapter: NavigationAdapter = useMemo(
() => ({
push: (path: string) => router.navigate(path),
replace: (path: string) => router.navigate(path, { replace: true }),
push: (path: string) => {
if (tryRouteToOverlay(path, router)) return;
router.navigate(path);
},
replace: (path: string) => {
if (tryRouteToOverlay(path, router)) return;
router.navigate(path, { replace: true });
},
back: () => router.navigate(-1),
pathname: location.pathname,
searchParams: new URLSearchParams(location.search),

View File

@@ -20,14 +20,12 @@ import { DaemonRuntimeCard } from "./components/daemon-runtime-card";
import { AgentsPage } from "@multica/views/agents";
import { InboxPage } from "@multica/views/inbox";
import { SettingsPage } from "@multica/views/settings";
import { NewWorkspacePage } from "@multica/views/workspace/new-workspace-page";
import { InvitePage } from "@multica/views/invite";
import { useNavigation } from "@multica/views/navigation";
import { paths } from "@multica/core/paths";
import { workspaceListOptions } from "@multica/core/workspace/queries";
import { Server } from "lucide-react";
import { DaemonSettingsTab } from "./components/daemon-settings-tab";
import { WorkspaceRouteLayout } from "./components/workspace-route-layout";
import { useWindowOverlayStore } from "./stores/window-overlay-store";
/**
* Sets document.title from the deepest matched route's handle.title.
@@ -59,15 +57,6 @@ function PageShell() {
);
}
function NewWorkspaceRoute() {
const nav = useNavigation();
return (
<NewWorkspacePage
onSuccess={(ws) => nav.push(paths.workspace(ws.slug).issues())}
/>
);
}
/**
* Root index route: resolves the URL-less `/` path to a concrete destination.
*
@@ -76,15 +65,32 @@ function NewWorkspaceRoute() {
* duplicate fetches across tabs — each tab's memory router hits this
* component independently but the query is deduped.
*
* Sends first-time users without any workspace to /workspaces/new,
* everyone else to their first workspace's issues page. Persisted tab
* paths that already carry a workspace slug bypass this component
* entirely.
* Sends users with workspaces to the first workspace's issues page.
* Users with zero workspaces get the window-level new-workspace overlay —
* desktop treats pre-workspace flows as application state, not tab routes,
* so there's no URL to navigate to.
*/
function IndexRedirect() {
const { data: wsList, isFetched } = useQuery(workspaceListOptions());
// Wait for the query to settle so we don't redirect to /workspaces/new
// Bidirectional overlay lifecycle: open the new-workspace overlay when
// the user has zero workspaces AND no other overlay is already showing,
// and close it when the list becomes non-empty (e.g. a realtime workspace
// event arrived while the overlay was open on a different code path).
// Only touches the new-workspace type — an active invite overlay is the
// user's in-flight task and must not be interrupted.
useEffect(() => {
if (!isFetched) return;
const { overlay, open, close } = useWindowOverlayStore.getState();
const isEmpty = !wsList || wsList.length === 0;
if (isEmpty) {
if (!overlay) open({ type: "new-workspace" });
} else if (overlay?.type === "new-workspace") {
close();
}
}, [isFetched, wsList]);
// Wait for the query to settle so we don't flash the empty-state overlay
// on the initial render before the seeded/fetched data arrives.
if (!isFetched) return null;
@@ -92,24 +98,21 @@ function IndexRedirect() {
if (firstWorkspace) {
return <Navigate to={paths.workspace(firstWorkspace.slug).issues()} replace />;
}
return <Navigate to={paths.newWorkspace()} replace />;
}
function InviteRoute() {
const matches = useMatches();
const match = matches.find((m) => (m.params as { id?: string }).id);
const id = (match?.params as { id?: string })?.id ?? "";
return <InvitePage invitationId={id} />;
// Zero workspaces — overlay is opened via the effect above. Tab stays on
// `/`; the overlay covers the window. When the user creates a workspace,
// onSuccess navigates to the new workspace path, closing the overlay.
return null;
}
/**
* Route definitions shared by all tabs.
*
* Structure mirrors the web app's [workspaceSlug]/... layout: all dashboard
* pages live under /:workspaceSlug, with WorkspaceRouteLayout resolving the
* slug to a workspace and syncing side-effects (api client, persist namespace,
* Zustand mirror). Global (pre-workspace) routes — workspaces/new and invite —
* sit at the top level alongside the workspace wrapper.
* Only workspace-scoped ("session") routes live here. Pre-workspace
* transitions (create workspace, accept invite) are NOT routes on desktop —
* they render as a window-level overlay via WindowOverlay, dispatched by
* the navigation adapter's transition-path interception. See
* `platform/navigation.tsx` and `stores/window-overlay-store.ts`.
*/
export const appRoutes: RouteObject[] = [
{
@@ -118,18 +121,9 @@ export const appRoutes: RouteObject[] = [
// Top-level index: no slug yet. `IndexRedirect` reads the workspace
// list from React Query cache (seeded by AuthInitializer on reopen
// or App.tsx on deep-link login) and bounces to the first
// workspace's issues page — or /workspaces/new if the user has none.
// workspace's issues page — or opens the new-workspace overlay if
// the user has none.
{ index: true, element: <IndexRedirect /> },
{
path: "workspaces/new",
element: <NewWorkspaceRoute />,
handle: { title: "Create Workspace" },
},
{
path: "invite/:id",
element: <InviteRoute />,
handle: { title: "Accept Invite" },
},
{
path: ":workspaceSlug",
element: <WorkspaceRouteLayout />,

View File

@@ -13,11 +13,14 @@ describe("sanitizeTabPath", () => {
expect(sanitizeTabPath("/")).toBe("/");
});
it("passes through global paths", () => {
expect(sanitizeTabPath("/login")).toBe("/login");
expect(sanitizeTabPath("/workspaces/new")).toBe("/workspaces/new");
expect(sanitizeTabPath("/invite/abc")).toBe("/invite/abc");
expect(sanitizeTabPath("/auth/callback")).toBe("/auth/callback");
it("rejects transition paths — these are overlay state, not tabs", () => {
// Silently rewritten (no warn) since they're legitimate inputs that
// the navigation adapter has already redirected to the overlay.
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
expect(sanitizeTabPath("/workspaces/new")).toBe("/");
expect(sanitizeTabPath("/invite/abc")).toBe("/");
expect(warn).not.toHaveBeenCalled();
warn.mockRestore();
});
it("passes through valid workspace-scoped paths", () => {
@@ -25,7 +28,7 @@ describe("sanitizeTabPath", () => {
expect(sanitizeTabPath("/my-team/projects/abc")).toBe("/my-team/projects/abc");
});
it("rejects paths whose first segment is a reserved slug", () => {
it("rejects paths whose first segment is a reserved slug (missing workspace prefix)", () => {
// A stray "/issues" (pre-refactor leftover, missing workspace prefix)
// would be interpreted as workspaceSlug="issues" → NoAccessPage.
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});

View File

@@ -3,7 +3,7 @@ import { createJSONStorage, persist } from "zustand/middleware";
import { arrayMove } from "@dnd-kit/sortable";
import { createPersistStorage, defaultStorage } from "@multica/core/platform";
import { createSafeId } from "@multica/core/utils";
import { isGlobalPath, isReservedSlug } from "@multica/core/paths";
import { isReservedSlug } from "@multica/core/paths";
import type { DataRouter } from "react-router-dom";
import { createTabRouter } from "../routes";
@@ -44,10 +44,17 @@ interface TabStore {
* current user doesn't have access to. Called after login + workspace list
* is populated (and on every subsequent list change, e.g. realtime
* workspace:deleted). Stale tabs get reset to `/` so IndexRedirect picks
* a valid workspace; tabs on global paths (/login, /workspaces/new, etc.)
* are untouched.
* a valid workspace (or opens the new-workspace overlay if the user now
* has none).
*/
validateWorkspaceSlugs: (validSlugs: Set<string>) => void;
/**
* Wipe all in-memory tabs and reseed with a single default tab at `/`.
* Called on logout so the next user doesn't inherit the previous user's
* tab layout — Zustand persist only writes to localStorage; clearing
* localStorage alone leaves the live store untouched until app restart.
*/
reset: () => void;
}
// ---------------------------------------------------------------------------
@@ -67,21 +74,17 @@ const ROUTE_ICONS: Record<string, string> = {
};
/**
* Resolve a route icon from a pathname. Title is NOT determined here — it
* comes from document.title.
* Resolve a route icon from a pathname.
*
* Path shape after the workspace URL refactor:
* - workspace-scoped: `/{workspaceSlug}/{route}/...` → use segment index 1
* - global (workspaces/new, invite, auth, login): `/{route}/...` → use segment index 0
* Tab paths are always workspace-scoped on desktop: `/{slug}/{route}/...`,
* so the route segment lives at index 1. Pre-workspace flows (create,
* invite) are rendered by the window overlay, never as tabs.
*
* `isGlobalPath` is the single source of truth for which prefixes are global.
* Title is NOT determined here — it comes from document.title.
*/
export function resolveRouteIcon(pathname: string): string {
const segments = pathname.split("/").filter(Boolean);
const routeSegment = isGlobalPath(pathname)
? (segments[0] ?? "")
: (segments[1] ?? "");
return ROUTE_ICONS[routeSegment] ?? "ListTodo";
return ROUTE_ICONS[segments[1] ?? ""] ?? "ListTodo";
}
// ---------------------------------------------------------------------------
@@ -105,30 +108,37 @@ function createId(): string {
}
/**
* Defensive: catch tab paths that were constructed without a workspace slug
* (e.g. a hardcoded "/issues" leftover from before the URL refactor). Such
* paths would get matched as `workspaceSlug="issues"` by the router and
* render NoAccessPage. Sanitize by falling back to "/" (IndexRedirect picks
* a valid workspace).
* Defensive: catch paths that don't belong in the tab store.
*
* Passes through:
* - "/" and global paths (/login, /workspaces/new, /invite/..., /auth/...)
* - workspace-scoped paths whose first segment is not a reserved word
* Two kinds of rejects:
* 1. **Transition paths** (`/workspaces/new`, `/invite/...`). These are
* pre-workspace flows rendered by the window overlay on desktop, not
* tab routes. They must never persist as tab state — otherwise the user
* reopens the app into a stale form/invite. Navigation to these paths
* is intercepted by the navigation adapter, so they shouldn't reach
* tab-store in normal flow; this guard catches older persisted state.
* 2. **Malformed workspace-scoped paths** like a stray `/issues/abc` that
* was constructed without the workspace prefix. Router would interpret
* `issues` as a workspace slug → NoAccessPage.
*
* Rejects (and rewrites to "/"):
* - Paths whose first segment is a reserved slug (=/=workspace slug), which
* means the caller forgot to prefix the workspace. Logs a warning so the
* buggy call site is easy to find.
* Both rewrite to `/` so `IndexRedirect` picks a valid destination (or
* opens the new-workspace overlay if the user has none).
*/
export function sanitizeTabPath(path: string): string {
if (path === DEFAULT_PATH || isGlobalPath(path)) return path;
if (path === DEFAULT_PATH) return path;
const firstSegment = path.split("/").filter(Boolean)[0] ?? "";
if (isReservedSlug(firstSegment)) {
// eslint-disable-next-line no-console
console.warn(
`[tab-store] tab path "${path}" starts with reserved slug "${firstSegment}" — ` +
`caller likely forgot the workspace prefix. Falling back to "/".`,
);
// Don't log for known transition paths — these are legitimate inputs
// at the interception boundary (older persisted state or stale callers).
// Log only for truly buggy cases where a view forgot the workspace prefix.
const isTransition = path === "/workspaces/new" || path.startsWith("/invite/");
if (!isTransition) {
// eslint-disable-next-line no-console
console.warn(
`[tab-store] tab path "${path}" starts with reserved slug "${firstSegment}" — ` +
`caller likely forgot the workspace prefix. Falling back to "/".`,
);
}
return DEFAULT_PATH;
}
return path;
@@ -227,15 +237,15 @@ export const useTabStore = create<TabStore>()(
const { tabs } = get();
let changed = false;
const nextTabs = tabs.map((t) => {
// Skip tabs on non-workspace-scoped paths — nothing to validate.
if (t.path === "/" || isGlobalPath(t.path)) return t;
// Root sentinel doesn't carry a slug — nothing to validate.
if (t.path === "/") return t;
const firstSegment = t.path.split("/").filter(Boolean)[0] ?? "";
if (validSlugs.has(firstSegment)) return t;
// Stale slug: dispose the old router and replace with a fresh one
// pointing at `/`. IndexRedirect will send the tab to a valid
// workspace (or /workspaces/new if the user now has none).
// workspace, or open the new-workspace overlay if none remain.
changed = true;
t.router.dispose();
return {
@@ -252,6 +262,13 @@ export const useTabStore = create<TabStore>()(
if (!changed) return;
set({ tabs: nextTabs });
},
reset() {
const { tabs } = get();
for (const t of tabs) t.router.dispose();
const fresh = makeTab(DEFAULT_PATH, "Issues", resolveRouteIcon(DEFAULT_PATH));
set({ tabs: [fresh], activeTabId: fresh.id });
},
}),
{
name: "multica_tabs",

View File

@@ -0,0 +1,29 @@
import { create } from "zustand";
/**
* Window-level transition overlay: pre-workspace flows that are NOT pages
* inside a tab. Triggered by navigation-adapter interception, zero-workspace
* auto-redirect, or deep link; rendered above the tab system as a full-window
* takeover.
*
* These flows used to be routes (`/workspaces/new`, `/invite/:id`) but on
* desktop the URL is invisible to users — routes are an implementation detail
* of the tab system. Representing transitions as routes meant tabs tried to
* persist them, TabBar rendered on top, and invite deep-linking had no clean
* dispatch target. Modeling them as application state removes all three.
*/
export type WindowOverlay =
| { type: "new-workspace" }
| { type: "invite"; invitationId: string };
interface WindowOverlayStore {
overlay: WindowOverlay | null;
open: (overlay: WindowOverlay) => void;
close: () => void;
}
export const useWindowOverlayStore = create<WindowOverlayStore>((set) => ({
overlay: null,
open: (overlay) => set({ overlay }),
close: () => set({ overlay: null }),
}));

View File

@@ -2,8 +2,10 @@
import { useEffect } from "react";
import { useRouter, useParams } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { useAuthStore } from "@multica/core/auth";
import { paths } from "@multica/core/paths";
import { workspaceListOptions } from "@multica/core/workspace/queries";
import { InvitePage } from "@multica/views/invite";
export default function InviteAcceptPage() {
@@ -11,6 +13,10 @@ export default function InviteAcceptPage() {
const params = useParams<{ id: string }>();
const user = useAuthStore((s) => s.user);
const isLoading = useAuthStore((s) => s.isLoading);
const { data: wsList = [] } = useQuery({
...workspaceListOptions(),
enabled: !!user,
});
// Redirect to login if not authenticated, with a redirect back to this page.
useEffect(() => {
@@ -23,5 +29,8 @@ export default function InviteAcceptPage() {
if (isLoading || !user) return null;
return <InvitePage invitationId={params.id} />;
const onBack =
wsList.length > 0 ? () => router.push(paths.root()) : undefined;
return <InvitePage invitationId={params.id} onBack={onBack} />;
}

View File

@@ -2,14 +2,20 @@
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { useQuery } from "@tanstack/react-query";
import { useAuthStore } from "@multica/core/auth";
import { paths } from "@multica/core/paths";
import { workspaceListOptions } from "@multica/core/workspace/queries";
import { NewWorkspacePage } from "@multica/views/workspace/new-workspace-page";
export default function Page() {
const router = useRouter();
const user = useAuthStore((s) => s.user);
const isLoading = useAuthStore((s) => s.isLoading);
const { data: wsList = [] } = useQuery({
...workspaceListOptions(),
enabled: !!user,
});
useEffect(() => {
if (!isLoading && !user) router.replace(paths.login());
@@ -17,9 +23,16 @@ export default function Page() {
if (isLoading || !user) return null;
// Back goes to the root path — the workspace layout redirects from
// there to the user's default workspace. Only show Back when there's
// somewhere to go back to (user already has at least one workspace).
const onBack =
wsList.length > 0 ? () => router.push(paths.root()) : undefined;
return (
<NewWorkspacePage
onSuccess={(ws) => router.push(paths.workspace(ws.slug).issues())}
onBack={onBack}
/>
);
}

View File

@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useState, type ReactNode } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { api } from "@multica/core/api";
import {
@@ -9,15 +9,30 @@ import {
} from "@multica/core/workspace/queries";
import { paths } from "@multica/core/paths";
import { useNavigation } from "../navigation";
import { useLogout } from "../auth";
import { Button } from "@multica/ui/components/ui/button";
import { Card, CardContent } from "@multica/ui/components/ui/card";
import { Users, Check, X } from "lucide-react";
import { ArrowLeft, LogOut, Users, Check, X } from "lucide-react";
export interface InvitePageProps {
invitationId: string;
/**
* Optional "go back" handler. Caller passes it only when there's a
* sensible destination (user has at least one workspace, or arrived
* from an in-app flow). Omitted on first-invite/zero-workspace paths
* where Back would have nowhere to go — Log out is then the only exit.
*/
onBack?: () => void;
}
export function InvitePage({ invitationId }: InvitePageProps) {
/**
* Full-page shell for the "accept invitation" transition. Shared between
* web (Next.js route `/invite/[id]`) and desktop (window-overlay).
* Top-bar affordances (Back, Log out) live here so both platforms get
* identical UX. Platform chrome (window drag region, immersive mode) is
* layered on by the desktop overlay; web just renders the page directly.
*/
export function InvitePage({ invitationId, onBack }: InvitePageProps) {
const { push } = useNavigation();
const qc = useQueryClient();
const [accepting, setAccepting] = useState(false);
@@ -78,15 +93,15 @@ export function InvitePage({ invitationId }: InvitePageProps) {
if (isLoading) {
return (
<div className="flex min-h-screen items-center justify-center">
<InviteShell onBack={onBack}>
<div className="text-sm text-muted-foreground">Loading invitation...</div>
</div>
</InviteShell>
);
}
if (fetchError || !invitation) {
return (
<div className="flex min-h-screen items-center justify-center">
<InviteShell onBack={onBack}>
<Card className="w-full max-w-md">
<CardContent className="flex flex-col items-center gap-4 py-12">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-muted">
@@ -101,13 +116,13 @@ export function InvitePage({ invitationId }: InvitePageProps) {
</Button>
</CardContent>
</Card>
</div>
</InviteShell>
);
}
if (done === "accepted") {
return (
<div className="flex min-h-screen items-center justify-center">
<InviteShell onBack={onBack}>
<Card className="w-full max-w-md">
<CardContent className="flex flex-col items-center gap-4 py-12">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/10">
@@ -117,13 +132,13 @@ export function InvitePage({ invitationId }: InvitePageProps) {
<p className="text-sm text-muted-foreground">Redirecting to workspace...</p>
</CardContent>
</Card>
</div>
</InviteShell>
);
}
if (done === "declined") {
return (
<div className="flex min-h-screen items-center justify-center">
<InviteShell onBack={onBack}>
<Card className="w-full max-w-md">
<CardContent className="flex flex-col items-center gap-4 py-12">
<h2 className="text-lg font-semibold">Invitation declined</h2>
@@ -133,7 +148,7 @@ export function InvitePage({ invitationId }: InvitePageProps) {
</Button>
</CardContent>
</Card>
</div>
</InviteShell>
);
}
@@ -141,7 +156,7 @@ export function InvitePage({ invitationId }: InvitePageProps) {
const isAlreadyHandled = invitation.status === "accepted" || invitation.status === "declined";
return (
<div className="flex min-h-screen items-center justify-center">
<InviteShell onBack={onBack}>
<Card className="w-full max-w-md">
<CardContent className="flex flex-col items-center gap-6 py-12">
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-primary/10">
@@ -191,6 +206,46 @@ export function InvitePage({ invitationId }: InvitePageProps) {
)}
</CardContent>
</Card>
</InviteShell>
);
}
/**
* Shared chrome for every InvitePage render state (loading, error,
* default, accepted, declined). Keeps Back + Log out buttons in a
* consistent position across all branches and across platforms.
*/
function InviteShell({
onBack,
children,
}: {
onBack?: () => void;
children: ReactNode;
}) {
const logout = useLogout();
return (
<div className="relative flex min-h-svh flex-col items-center justify-center bg-background px-6 py-12">
{onBack && (
<Button
variant="ghost"
size="sm"
className="absolute top-12 left-12 text-muted-foreground"
onClick={onBack}
>
<ArrowLeft />
Back
</Button>
)}
<Button
variant="ghost"
size="sm"
className="absolute top-12 right-12 text-muted-foreground hover:text-destructive"
onClick={logout}
>
<LogOut />
Log out
</Button>
{children}
</div>
);
}

View File

@@ -1,31 +1,68 @@
"use client";
import { ArrowLeft, LogOut } from "lucide-react";
import { Button } from "@multica/ui/components/ui/button";
import type { Workspace } from "@multica/core/types";
import { useLogout } from "../auth";
import { CreateWorkspaceForm } from "./create-workspace-form";
/**
* Full-page shell for the /workspaces/new route. Shared between web
* (Next.js) and desktop (react-router) so the two apps can't drift.
* Callers provide the onSuccess handler — that's the only app-specific
* piece, because each app uses its own navigation primitive.
* Full-page shell for the "create workspace" transition. Shared between web
* (Next.js route `/workspaces/new`) and desktop (window-overlay). The
* top-bar affordances — Back (when dismissable) and Log out — live here
* so both platforms get identical UX; platform-specific concerns like
* window-drag region and macOS traffic-light handling stay in each app's
* shell.
*
* `onBack` is optional: caller passes it only when there's somewhere to go
* back to (user has other workspaces, or the flow was entered from an
* existing session). On the zero-workspace entry path it's omitted, which
* hides Back — Log out is then the only escape.
*/
export function NewWorkspacePage({
onSuccess,
onBack,
}: {
onSuccess: (workspace: Workspace) => void;
onBack?: () => void;
}) {
const logout = useLogout();
return (
<div className="flex min-h-svh flex-col items-center justify-center bg-background px-6 py-12">
<div className="flex w-full max-w-md flex-col items-center gap-6">
<div className="text-center">
<h1 className="text-3xl font-semibold tracking-tight">
Welcome to Multica
</h1>
<p className="mt-2 text-muted-foreground">
Create your workspace to get started.
</p>
<div className="relative flex min-h-svh flex-col bg-background px-6 py-12">
{onBack && (
<Button
variant="ghost"
size="sm"
className="absolute top-12 left-12 text-muted-foreground"
onClick={onBack}
>
<ArrowLeft />
Back
</Button>
)}
<Button
variant="ghost"
size="sm"
className="absolute top-12 right-12 text-muted-foreground hover:text-destructive"
onClick={logout}
>
<LogOut />
Log out
</Button>
<div className="flex flex-1 flex-col items-center justify-center">
<div className="flex w-full max-w-md flex-col items-center gap-6">
<div className="text-center">
<h1 className="text-3xl font-semibold tracking-tight">
Welcome to Multica
</h1>
<p className="mt-2 text-muted-foreground">
Create your workspace to get started.
</p>
</div>
<CreateWorkspaceForm onSuccess={onSuccess} />
</div>
<CreateWorkspaceForm onSuccess={onSuccess} />
</div>
</div>
);