Compare commits

...

13 Commits

Author SHA1 Message Date
Naiyuan Qing
bcdddd895a fix(auth): redirect to /login on logout and unauthenticated workspace visits
Two gaps previously left users stuck on blank workspace pages:

1. app-sidebar logout() cleared all state but never moved the URL. The
   current path is /{workspaceSlug}/... which has no meaning without
   auth; the workspace layout would then see user=null, render null
   (via the hasBeenSeen short-circuit), and the user saw a blank page
   thinking logout didn't work.

2. The workspace layouts (web + desktop) had no !user handling at all.
   Any path that leaves user=null — token expiration, cross-tab logout,
   or fresh visit to a workspace URL without a session — resulted in
   the same blank screen.

Fix:
- app-sidebar.logout() explicitly push(paths.login()) after authLogout()
  to cover the primary (user-initiated) logout path.
- Both workspace layouts get a defensive useEffect that redirects to
  /login whenever auth has settled and user is null. Covers token
  expiration, realtime logout, and any other silent session loss.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 19:14:01 +08:00
Naiyuan Qing
1e05eee2dc refactor: remove rollback compat layer and tighten daemon restart trigger
Two cleanup items:

1. Drop localStorage['multica_workspace_id'] double-write in both
   workspace layouts. That write was added as a rollback safety net
   for the workspace-slug URL refactor (PR #1138) — the refactor has
   since landed and stabilized, so the compat shim is no longer
   needed. Per CLAUDE.md: don't keep compat layers beyond their
   purpose.

2. Tighten the desktop daemon-restart trigger. The previous ref-based
   logic fired a restart on any 0→1 workspace-count transition,
   including account switches (user A logout → user B login). Scope
   it precisely to 'this session started with zero workspaces and
   just gained one' using a three-state ref (null=undecided,
   true=empty-start, false=already-restarted-or-started-nonempty).
   Account switches are already handled by daemon-manager.ts on
   token change, so this avoids a redundant restart there.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 18:59:50 +08:00
Naiyuan Qing
ca8ef0e1e7 refactor(views): extract shared NewWorkspacePage shell
The web (/new-workspace) and desktop (NewWorkspaceRoute) pages had
identical outer layout — same container, heading, and copy — with only
the onSuccess navigation primitive differing. That's exactly the
No-Duplication Rule pattern: extract the shared UI, inject the
platform-specific behavior.

The apps now only own the thin auth guard (web needs it, desktop
routes below WorkspaceRouteLayout already handle it) and the
onSuccess → navigate call.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 18:58:17 +08:00
Naiyuan Qing
230f20df0b chore: clean stale 'onboarding' references in comments and CLI helpers
Batch cleanup of references to the removed onboarding flow:
- 13 comment sites mentioning 'onboarding' updated to reflect the
  new /new-workspace flow or removed where no longer accurate
- CLI waitForOnboarding renamed to waitForWorkspaceCreation (function
  name + docstring); behavior unchanged

The 'onboarding' reserved slug entries (frontend + backend) are
intentionally retained — see prior commit rationale.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 18:56:58 +08:00
Naiyuan Qing
f3ace7951f refactor: remove onboarding flow
The 4-step onboarding wizard (workspace → runtime → agent → demo issues)
is replaced by:
- /new-workspace: a single-page workspace creation form (Phase 3)
- NoAccessPage: explicit feedback when a slug doesn't resolve (Phase 4)
- daemon zero-workspace bootstrap (Phase 1) so the daemon doesn't
  crash before the user creates their first workspace
- desktop daemon restart on first workspace creation (Phase 5) for
  instant pickup instead of the 30s workspaceSyncLoop tick

Deletions:
- packages/views/onboarding/ (OnboardingWizard + 4 step components + tests)
- apps/web/app/(auth)/onboarding/page.tsx
- apps/desktop/src/renderer/src/components/onboarding-gate.tsx (+test)
- OnboardingGate wrapper in desktop-layout.tsx
- OnboardingRoute + /onboarding route in desktop routes.tsx
- paths.onboarding() builder + /onboarding from GLOBAL_PREFIXES
- packages/views/package.json onboarding export
- /onboarding from navigation store's EXCLUDED_PREFIXES

Retained (intentional):
- 'onboarding' in RESERVED_SLUGS (both FE + BE) — kept for FE/BE sync
  and future-proofing if /onboarding is ever revived

Also drops 4 demo issues that onboarding used to create on the new
workspace ('Say hello', 'Set up repo', etc.). New workspaces are now
fully empty; all list views already render empty-state UI correctly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 18:51:51 +08:00
Naiyuan Qing
2f40b7655d refactor: redirect zero-workspace users to /new-workspace instead of /onboarding
Switches 8 call sites and the CLI:
- Web: login, auth callback, landing redirect-if-authenticated
- Desktop: routes.tsx IndexRedirect
- Shared: dashboard guard, invite page fallback, workspace-tab on delete,
  realtime sync on workspace loss
- CLI: cmd_login.go waitForOnboarding now opens /new-workspace

Also adds /new-workspace to navigation store's lastPath exclusion list
so it doesn't get persisted as a 'last visited' page.

Adds a desktop App.tsx effect that restarts the daemon when workspace
count transitions 0 → ≥1, so first-workspace creation triggers
immediate daemon pickup rather than waiting up to 30s for the daemon's
workspaceSyncLoop.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 18:51:51 +08:00
Naiyuan Qing
ccd6146b79 feat: show NoAccessPage on unknown workspace slug, hold null during active removal
Layouts render NoAccessPage when the URL slug doesn't resolve to an
accessible workspace — except when the slug previously resolved during
this layout instance's lifetime.

URL and cache are two asynchronous signals: there will always be a
short window where the URL still points at the old workspace but the
cache has already been invalidated (e.g. just after a delete/leave
mutation, or a realtime workspace:deleted event). Rendering
NoAccessPage during that window would flash "Workspace not available"
with recovery buttons in front of a user who just deleted the
workspace themselves — jarring and wrong.

useWorkspaceSeen classifies the two cases:
 - slug was seen before, now gone → user's intent is changing (caller
   is navigating away); render null, no flash
 - slug never seen → user is genuinely looking at an inaccessible
   workspace (stale bookmark, revoked access, link from a former
   teammate); render NoAccessPage with recovery options

NoAccessPage deliberately does not distinguish 404 vs 403 to avoid
letting attackers enumerate workspace slugs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 18:51:47 +08:00
Naiyuan Qing
21d2245ecf feat: add /new-workspace route on web and desktop
Renders the CreateWorkspaceForm as a full-page workspace creation flow,
used as the destination for first-time users with zero workspaces.
Replaces the 4-step onboarding wizard with a single form.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 18:22:23 +08:00
Naiyuan Qing
9e752aafa8 chore(migrations): audit no existing workspace uses 'new-workspace' slug
Migration 046 blocks deploy if any workspace in the DB has slug =
'new-workspace', which would shadow the new global workspace creation
route at /new-workspace.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 18:20:58 +08:00
Naiyuan Qing
599235ef88 feat(paths): add /new-workspace route and reserve slug on both sides
Adds paths.newWorkspace() builder, registers /new-workspace as a global
(pre-workspace) prefix, and reserves the "new-workspace" slug on both
frontend and backend (kept in sync per convention). Existing
"onboarding" reservation retained — removing it would desync FE/BE
and leaves no future fallback if an onboarding route is revived.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 18:20:36 +08:00
Naiyuan Qing
00a9d6377c feat(views): add NoAccessPage for unknown or inaccessible workspace slugs
Rendered when the URL slug doesn't resolve to a workspace the user has
access to. Deliberately doesn't distinguish 404 vs 403 to avoid letting
attackers enumerate workspace slugs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 18:19:26 +08:00
Naiyuan Qing
b57092d594 refactor(views): extract CreateWorkspaceForm for reuse
Modal and the upcoming /new-workspace page share the same form +
mutation + slug validation. Extract to a shared component so they
can't drift.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 18:19:05 +08:00
Naiyuan Qing
874ae30e5d fix(daemon): allow startup with zero workspaces
The daemon used to fail fast with "no runtimes registered" when the
initial workspace sync returned zero workspaces. This masked a latent
bug: a newly-signed-up user has no workspaces yet, so the daemon would
crash immediately after login instead of waiting for the first
workspace to be created.

workspaceSyncLoop already polls every 30s (daemon.go:107, 365) to
discover new workspaces — the fail-fast check at startup was bypassing
this dynamic discovery. Remove the check so the daemon stays resident
and picks up the first workspace whenever it appears.

PR #1001 partially addressed this for the "server has workspaces but
local CLI config is empty" case. This finishes the job for the true
zero-workspace state, which until now was masked by the onboarding
wizard always creating a workspace before the daemon started.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 18:16:51 +08:00
51 changed files with 1868 additions and 1568 deletions

View File

@@ -151,7 +151,7 @@ if (!gotTheLock) {
});
// IPC: toggle immersive mode — hides the macOS traffic lights so full-screen
// modals (create-workspace, onboarding) can place UI in the top-left corner
// modals (e.g. create-workspace) can place UI in the top-left corner
// without fighting the native window controls' hit-test.
ipcMain.handle("window:setImmersive", (_event, immersive: boolean) => {
if (process.platform !== "darwin") return;

View File

@@ -1,8 +1,8 @@
import { useEffect, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useEffect, useRef, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { CoreProvider } from "@multica/core/platform";
import { useAuthStore } from "@multica/core/auth";
import { workspaceKeys } from "@multica/core/workspace/queries";
import { workspaceKeys, workspaceListOptions } from "@multica/core/workspace/queries";
import { api } from "@multica/core/api";
import { ThemeProvider } from "@multica/ui/components/common/theme-provider";
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
@@ -20,8 +20,8 @@ function AppContent() {
// as soon as getMe resolves, which would cause DesktopShell to mount
// before the workspace list is hydrated and briefly see `!workspace`.
// This local flag keeps the loading screen up until the whole chain
// finishes, so the shell's "needs onboarding?" check gets a definitive
// workspace state on first render.
// finishes, so IndexRedirect gets a definitive workspace state on
// first render.
const [bootstrapping, setBootstrapping] = useState(false);
// Tell the main process which backend URL we talk to, so daemon-manager
@@ -69,6 +69,38 @@ function AppContent() {
})();
}, [user]);
// When a user who started the session with zero workspaces creates their
// first one, restart the daemon so it picks up the new workspace
// immediately (otherwise workspaceSyncLoop's next 30s tick would be the
// earliest pickup point). Specifically scoped to "started empty" because
// account switches (user A logout → user B login) should not trigger a
// daemon restart here — daemon-manager already restarts on user change
// via syncToken.
const { data: workspaces, isFetched: workspaceListFetched } = useQuery({
...workspaceListOptions(),
enabled: !!user,
});
const wsCount = workspaces?.length ?? 0;
// null = undecided (pre-login or list hasn't settled yet)
// true = session started with zero workspaces; next transition to >=1 triggers restart
// false = session started with >=1 workspace, OR we've already restarted; skip
const sessionStartedEmptyRef = useRef<boolean | null>(null);
useEffect(() => {
if (!user) {
sessionStartedEmptyRef.current = null;
return;
}
if (!workspaceListFetched) return;
if (sessionStartedEmptyRef.current === null) {
sessionStartedEmptyRef.current = wsCount === 0;
return;
}
if (sessionStartedEmptyRef.current && wsCount >= 1) {
void window.daemonAPI.restart();
sessionStartedEmptyRef.current = false;
}
}, [user, workspaceListFetched, wsCount]);
if (isLoading || bootstrapping) {
return (
<div className="flex h-screen items-center justify-center">

View File

@@ -13,11 +13,9 @@ import { ModalRegistry } from "@multica/views/modals/registry";
import { AppSidebar } from "@multica/views/layout";
import { SearchCommand, SearchTrigger } from "@multica/views/search";
import { ChatFab, ChatWindow } from "@multica/views/chat";
import { StepWorkspace } from "@multica/views/onboarding";
import { WorkspaceSlugProvider } from "@multica/core/paths";
import { getCurrentSlug, subscribeToCurrentSlug } from "@multica/core/platform";
import { DesktopNavigationProvider } from "@/platform/navigation";
import { OnboardingGate } from "./onboarding-gate";
import { TabBar } from "./tab-bar";
import { TabContent } from "./tab-content";
@@ -109,39 +107,32 @@ export function DesktopShell() {
return (
<DesktopNavigationProvider>
<OnboardingGate
onboarding={(onComplete) => (
<div className="flex min-h-screen items-center justify-center overflow-auto bg-background px-6 py-12">
<StepWorkspace onNext={onComplete} />
</div>
)}
>
{/* WorkspaceSlugProvider accepts null — components that need slug
use useWorkspaceSlug() (nullable) or useRequiredWorkspaceSlug()
(throws). TabContent MUST always render so the tab router can
mount WorkspaceRouteLayout, which calls setCurrentWorkspace()
to populate the slug. The sidebar gates on slug being present
to avoid the useRequiredWorkspaceSlug throw. */}
<WorkspaceSlugProvider slug={slug}>
<div className="flex h-screen">
<SidebarProvider className="flex-1">
{slug && <AppSidebar topSlot={<SidebarTopBar />} searchSlot={<SearchTrigger />} />}
{/* Right side: header + content container */}
<div className="flex flex-1 min-w-0 flex-col">
<MainTopBar />
{/* Content area with inset styling — relative so ChatWindow/ChatFab are constrained here */}
<div className="relative flex flex-1 min-h-0 flex-col overflow-hidden mr-2 mb-2 ml-0.5 rounded-xl shadow-sm bg-background">
<TabContent />
{slug && <ChatWindow />}
{slug && <ChatFab />}
</div>
{/* WorkspaceSlugProvider accepts null — components that need slug
use useWorkspaceSlug() (nullable) or useRequiredWorkspaceSlug()
(throws). TabContent MUST always render so the tab router can
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 /new-workspace by IndexRedirect. */}
<WorkspaceSlugProvider slug={slug}>
<div className="flex h-screen">
<SidebarProvider className="flex-1">
{slug && <AppSidebar topSlot={<SidebarTopBar />} searchSlot={<SearchTrigger />} />}
{/* Right side: header + content container */}
<div className="flex flex-1 min-w-0 flex-col">
<MainTopBar />
{/* Content area with inset styling — relative so ChatWindow/ChatFab are constrained here */}
<div className="relative flex flex-1 min-h-0 flex-col overflow-hidden mr-2 mb-2 ml-0.5 rounded-xl shadow-sm bg-background">
<TabContent />
{slug && <ChatWindow />}
{slug && <ChatFab />}
</div>
</SidebarProvider>
</div>
{slug && <ModalRegistry />}
{slug && <SearchCommand />}
</WorkspaceSlugProvider>
</OnboardingGate>
</div>
</SidebarProvider>
</div>
{slug && <ModalRegistry />}
{slug && <SearchCommand />}
</WorkspaceSlugProvider>
</DesktopNavigationProvider>
);
}

View File

@@ -1,99 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, act } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { workspaceKeys } from "@multica/core/workspace/queries";
import { OnboardingGate } from "./onboarding-gate";
// Prevent actual API calls — the tests seed data via setQueryData.
vi.mock("@multica/core/api", () => ({
api: {
listWorkspaces: vi.fn().mockResolvedValue([]),
},
}));
function createTestQueryClient(
workspaces: Array<{ id: string; slug: string }> = [],
) {
const qc = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
// Seed the workspace list so the gate can read it synchronously.
qc.setQueryData(workspaceKeys.list(), workspaces);
return qc;
}
function renderGate(
qc: QueryClient,
onboarding?: (onComplete: () => void) => React.ReactNode,
) {
return render(
<QueryClientProvider client={qc}>
<OnboardingGate
onboarding={
onboarding ??
((onComplete) => (
<button type="button" data-testid="finish" onClick={onComplete}>
wizard
</button>
))
}
>
<div data-testid="main">main shell</div>
</OnboardingGate>
</QueryClientProvider>,
);
}
describe("OnboardingGate", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders children when workspaces exist in cache", () => {
const qc = createTestQueryClient([{ id: "ws-1", slug: "my-team" }]);
renderGate(qc);
expect(screen.getByTestId("main")).toBeInTheDocument();
expect(screen.queryByText("wizard")).not.toBeInTheDocument();
});
it("renders onboarding when workspace list is empty", () => {
const qc = createTestQueryClient([]);
renderGate(qc);
expect(screen.getByText("wizard")).toBeInTheDocument();
expect(screen.queryByTestId("main")).not.toBeInTheDocument();
});
it("keeps the wizard mounted even after workspaces appear in cache mid-flow", () => {
const qc = createTestQueryClient([]);
renderGate(qc);
expect(screen.getByText("wizard")).toBeInTheDocument();
// Simulate the onboarding wizard creating a workspace mid-flow.
act(() => {
qc.setQueryData(workspaceKeys.list(), [
{ id: "ws-new", slug: "new-team" },
]);
});
// Wizard should still be visible — only onComplete dismisses it.
expect(screen.getByText("wizard")).toBeInTheDocument();
expect(screen.queryByTestId("main")).not.toBeInTheDocument();
});
it("transitions to children after the wizard calls onComplete", () => {
const qc = createTestQueryClient([]);
renderGate(qc);
expect(screen.getByTestId("finish")).toBeInTheDocument();
act(() => {
screen.getByTestId("finish").click();
});
expect(screen.getByTestId("main")).toBeInTheDocument();
expect(screen.queryByTestId("finish")).not.toBeInTheDocument();
});
});

View File

@@ -1,40 +0,0 @@
import { useState, type ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import { workspaceListOptions } from "@multica/core/workspace/queries";
/**
* Renders `onboarding` as a full-screen takeover when the user has no
* workspaces, otherwise renders `children`.
*
* Reads the workspace list directly from React Query — this works regardless
* of whether a WorkspaceSlugProvider is mounted, unlike useCurrentWorkspace()
* which depends on slug context from the router tree.
*
* The onboarding decision is frozen at first mount via the lazy useState
* initializer: this way the onboarding wizard controls its own exit by
* calling the `onComplete` callback, instead of being unmounted the moment
* the workspace list updates mid-flow (e.g. after the user creates their
* first workspace in step 1 but still has steps 2-3 to complete).
*
* The frozen decision only triggers when the initial query has settled AND
* the list is empty. While the list is loading, children are rendered
* (the shell shows its own loading state).
*/
export function OnboardingGate({
onboarding,
children,
}: {
onboarding: (onComplete: () => void) => ReactNode;
children: ReactNode;
}) {
const { data: workspaces, isFetched } = useQuery(workspaceListOptions());
const hasWorkspaces = !isFetched || (workspaces?.length ?? 0) > 0;
const [initialNeedsOnboarding] = useState(() => !hasWorkspaces);
const [onboardingDone, setOnboardingDone] = useState(false);
if (initialNeedsOnboarding && !onboardingDone) {
return <>{onboarding(() => setOnboardingDone(true))}</>;
}
return <>{children}</>;
}

View File

@@ -5,6 +5,8 @@ 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";
/**
* Desktop equivalent of apps/web/app/[workspaceSlug]/layout.tsx.
@@ -16,9 +18,8 @@ import { useAuthStore } from "@multica/core/auth";
* 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 redirect to `/` so IndexRedirect can pick the first valid workspace
* (more forgiving than bouncing to onboarding, which is only right for
* zero-workspace users).
* we render NoAccessPage instead of silently redirecting — users get
* explicit feedback for stale bookmarks or revoked access.
*/
export function WorkspaceRouteLayout() {
const { workspaceSlug } = useParams<{ workspaceSlug: string }>();
@@ -26,6 +27,14 @@ export function WorkspaceRouteLayout() {
const user = useAuthStore((s) => s.user);
const isAuthLoading = useAuthStore((s) => s.isLoading);
// Workspace routes require auth. If user is unauthenticated (token
// expired, logged out from another tab, etc.), bounce to /login.
// Without this, the layout renders null and the user sees a blank page
// stuck on /{slug}/...
useEffect(() => {
if (!isAuthLoading && !user) navigate(paths.login(), { replace: true });
}, [isAuthLoading, user, navigate]);
const { data: workspace, isFetched: listFetched } = useQuery({
...workspaceBySlugOptions(workspaceSlug ?? ""),
enabled: !!user && !!workspaceSlug,
@@ -40,24 +49,10 @@ export function WorkspaceRouteLayout() {
setCurrentWorkspace(workspaceSlug, workspace.id);
}
// Double-write legacy localStorage key for rollback compatibility — a
// pre-refactor build reads it to pick the initial workspace. Placed in
// an effect so repeated renders don't hammer localStorage.
useEffect(() => {
if (!workspace) return;
try {
localStorage.setItem("multica_workspace_id", workspace.id);
} catch {
// non-critical
}
}, [workspace]);
// Slug can't be resolved → bounce to `/` (IndexRedirect picks first
// valid workspace; falls to onboarding only if the list is truly empty).
useEffect(() => {
if (!user) return;
if (listFetched && !workspace) navigate(paths.root(), { replace: true });
}, [user, listFetched, workspace, navigate]);
// 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.
const hasBeenSeen = useWorkspaceSeen(workspaceSlug, !!workspace);
if (isAuthLoading) return null;
if (!workspaceSlug) return null;
@@ -66,7 +61,15 @@ 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) 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 />;
}
return (
<WorkspaceSlugProvider slug={workspaceSlug}>

View File

@@ -20,7 +20,7 @@ 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 { OnboardingWizard } from "@multica/views/onboarding";
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";
@@ -59,11 +59,11 @@ function PageShell() {
);
}
function OnboardingRoute() {
function NewWorkspaceRoute() {
const nav = useNavigation();
return (
<OnboardingWizard
onComplete={(ws) => nav.push(paths.workspace(ws.slug).issues())}
<NewWorkspacePage
onSuccess={(ws) => nav.push(paths.workspace(ws.slug).issues())}
/>
);
}
@@ -76,22 +76,23 @@ function OnboardingRoute() {
* 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 onboarding, everyone
* else to their first workspace's issues page. Persisted tab paths that
* already carry a workspace slug bypass this component entirely.
* Sends first-time users without any workspace to /new-workspace,
* everyone else to their first workspace's issues page. Persisted tab
* paths that already carry a workspace slug bypass this component
* entirely.
*/
function IndexRedirect() {
const { data: wsList, isFetched } = useQuery(workspaceListOptions());
// Wait for the query to settle so we don't redirect to onboarding on
// the initial render before the seeded/fetched data arrives.
// Wait for the query to settle so we don't redirect to /new-workspace
// on the initial render before the seeded/fetched data arrives.
if (!isFetched) return null;
const firstWorkspace = wsList?.[0];
if (firstWorkspace) {
return <Navigate to={paths.workspace(firstWorkspace.slug).issues()} replace />;
}
return <Navigate to={paths.onboarding()} replace />;
return <Navigate to={paths.newWorkspace()} replace />;
}
function InviteRoute() {
@@ -107,7 +108,7 @@ function InviteRoute() {
* 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 — onboarding and invite —
* Zustand mirror). Global (pre-workspace) routes — new-workspace and invite —
* sit at the top level alongside the workspace wrapper.
*/
export const appRoutes: RouteObject[] = [
@@ -117,12 +118,12 @@ 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 onboarding if the user has none.
// workspace's issues page — or /new-workspace if the user has none.
{ index: true, element: <IndexRedirect /> },
{
path: "onboarding",
element: <OnboardingRoute />,
handle: { title: "Get Started" },
path: "new-workspace",
element: <NewWorkspaceRoute />,
handle: { title: "Create Workspace" },
},
{
path: "invite/:id",

View File

@@ -63,7 +63,7 @@ const ROUTE_ICONS: Record<string, string> = {
*
* Path shape after the workspace URL refactor:
* - workspace-scoped: `/{workspaceSlug}/{route}/...` → use segment index 1
* - global (onboarding/invite/auth/login): `/{route}/...` → use segment index 0
* - global (new-workspace/invite/auth/login): `/{route}/...` → use segment index 0
*
* `isGlobalPath` is the single source of truth for which prefixes are global.
*/

View File

@@ -28,8 +28,9 @@ function LoginPageContent() {
// the user's workspace list.
const nextUrl = searchParams.get("next");
// Already authenticated — honor ?next= or fall back to first workspace /
// onboarding. Skip this entire path when the user arrived to authorize the CLI.
// Already authenticated — honor ?next= or fall back to first workspace
// (or /new-workspace if the user has none). Skip this entire path when
// the user arrived to authorize the CLI.
useEffect(() => {
if (isLoading || !user || cliCallbackRaw) return;
if (nextUrl) {
@@ -39,7 +40,7 @@ function LoginPageContent() {
const list = qc.getQueryData<Workspace[]>(workspaceKeys.list()) ?? [];
const [first] = list;
router.replace(
first ? paths.workspace(first.slug).issues() : paths.onboarding(),
first ? paths.workspace(first.slug).issues() : paths.newWorkspace(),
);
}, [isLoading, user, router, nextUrl, cliCallbackRaw, qc]);
@@ -53,7 +54,7 @@ function LoginPageContent() {
const list = qc.getQueryData<Workspace[]>(workspaceKeys.list()) ?? [];
const [first] = list;
router.push(
first ? paths.workspace(first.slug).issues() : paths.onboarding(),
first ? paths.workspace(first.slug).issues() : paths.newWorkspace(),
);
};

View File

@@ -1,17 +1,16 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { useAuthStore } from "@multica/core/auth";
import { paths } from "@multica/core/paths";
import { OnboardingWizard } from "@multica/views/onboarding";
import { NewWorkspacePage } from "@multica/views/workspace/new-workspace-page";
export default function OnboardingPage() {
export default function Page() {
const router = useRouter();
const user = useAuthStore((s) => s.user);
const isLoading = useAuthStore((s) => s.isLoading);
// Redirect to login if not authenticated
useEffect(() => {
if (!isLoading && !user) router.replace(paths.login());
}, [isLoading, user, router]);
@@ -19,8 +18,8 @@ export default function OnboardingPage() {
if (isLoading || !user) return null;
return (
<OnboardingWizard
onComplete={(ws) => router.push(paths.workspace(ws.slug).issues())}
<NewWorkspacePage
onSuccess={(ws) => router.push(paths.workspace(ws.slug).issues())}
/>
);
}

View File

@@ -7,6 +7,8 @@ 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";
export default function WorkspaceLayout({
children,
@@ -20,6 +22,14 @@ export default function WorkspaceLayout({
const isAuthLoading = useAuthStore((s) => s.isLoading);
const router = useRouter();
// Workspace routes require auth. If user is unauthenticated (initial visit
// without a session, token expired, another tab logged out, etc.), bounce
// to /login. Without this, the layout renders null and the user sees a
// blank page stuck on /{slug}/...
useEffect(() => {
if (!isAuthLoading && !user) router.replace(paths.login());
}, [isAuthLoading, user, router]);
// Resolve workspace by slug from the React Query list cache.
// Enabled only when user is authenticated — otherwise the list query isn't seeded.
const { data: workspace, isFetched: listFetched } = useQuery({
@@ -35,39 +45,36 @@ export default function WorkspaceLayout({
setCurrentWorkspace(workspaceSlug, workspace.id);
}
// Cookie write (last_workspace_slug) — proxy reads it on next page load.
// ALSO write legacy localStorage["multica_workspace_id"] for forward/back
// compatibility: if this version ever gets reverted to the pre-refactor
// build, the legacy code reads that localStorage key to know which
// workspace to attach to API requests. Without double-writing, a rollback
// would leave returning users with empty data (API calls would have no
// X-Workspace-ID header). Forward compatible — new code ignores this key.
// Cookie write (last_workspace_slug) — proxy reads it on next page load
// to redirect unauthenticated-URL hits to the user's last workspace.
useEffect(() => {
if (!workspace || typeof document === "undefined") return;
const oneYear = 60 * 60 * 24 * 365;
const secure = location.protocol === "https:" ? "; Secure" : "";
document.cookie = `last_workspace_slug=${encodeURIComponent(workspaceSlug)}; path=/; max-age=${oneYear}; SameSite=Lax${secure}`;
try {
localStorage.setItem("multica_workspace_id", workspace.id);
} catch {
// localStorage may be unavailable in restricted contexts; non-critical.
}
}, [workspace, workspaceSlug]);
// Slug doesn't match any workspace the user has access to → bounce to `/`
// and let the root IndexRedirect pick the first valid workspace (falls to
// onboarding only when the list is truly empty).
useEffect(() => {
if (!user) return;
if (listFetched && !workspace) router.replace(paths.root());
}, [user, listFetched, workspace, router]);
// Remember whether this slug has resolved before. Used below to avoid
// flashing NoAccessPage during active workspace removal (delete, leave,
// or realtime eviction) — in those cases the caller is navigating away
// and we just need to hold null briefly.
const hasBeenSeen = useWorkspaceSeen(workspaceSlug, !!workspace);
if (isAuthLoading) return null;
// Don't render children until workspace is resolved. useWorkspaceId()
// throws when the list hasn't populated or the slug is unknown — gating
// here makes that invariant hold for every descendant.
if (!listFetched) return null;
if (!workspace) return null;
if (!workspace) {
// If we've resolved this slug before in this session, it was just
// removed from our list (deleted/left/evicted). A navigate is almost
// certainly in flight — render null to avoid a NoAccessPage flash.
if (hasBeenSeen) return null;
// Otherwise: the URL points at a workspace the user never had access
// to. Show explicit feedback instead of silently redirecting. Doesn't
// distinguish 404 vs 403 to avoid letting attackers enumerate slugs.
return <NoAccessPage />;
}
return (
<WorkspaceSlugProvider slug={workspaceSlug}>

View File

@@ -66,11 +66,11 @@ function CallbackContent() {
// URL is now the source of truth for the current workspace — the
// [workspaceSlug]/layout syncs stores + cookie once we navigate.
// Honor ?next= first (e.g. came from /invite/{id}), otherwise land
// in the first workspace's issues, or /onboarding if the user has none.
// in the first workspace's issues, or /new-workspace for zero-workspace users.
const [first] = wsList;
const defaultDest = first
? paths.workspace(first.slug).issues()
: paths.onboarding();
: paths.newWorkspace();
router.push(nextUrl || defaultDest);
})
.catch((err) => {

View File

@@ -16,7 +16,7 @@ import { paths } from "@multica/core/paths";
* login* — before the user has ever visited a workspace — the cookie is
* absent, so the proxy falls through to the landing page. This component
* covers that gap: once auth is resolved and the workspace list has loaded,
* push the user into their workspace (or onboarding if they have none).
* push the user into their workspace (or /new-workspace if they have none).
*
* Renders nothing. Uses `router.replace` so the landing page never enters
* browser history for authenticated users.
@@ -35,7 +35,7 @@ export function RedirectIfAuthenticated() {
if (isLoading || !user || !list) return;
const [first] = list;
if (!first) {
router.replace(paths.onboarding());
router.replace(paths.newWorkspace());
return;
}
router.replace(paths.workspace(first.slug).issues());

File diff suppressed because it is too large Load Diff

View File

@@ -5,13 +5,13 @@ import { describe, it, expect } from "vitest";
// persistence — otherwise lastPath could contain /login etc, and on next
// app load we'd "restore" a user to the login page.
describe("useNavigationStore.lastPath excludes global paths", () => {
it("does not persist /login, /onboarding, /invite/, /auth/, /logout, /signup", async () => {
it("does not persist /login, /new-workspace, /invite/, /auth/, /logout, /signup", async () => {
const { useNavigationStore } = await import("./store");
const globalPrefixes = [
"/login",
"/logout",
"/signup",
"/onboarding",
"/new-workspace",
"/invite/abc",
"/auth/callback",
];

View File

@@ -10,13 +10,13 @@ import { defaultStorage } from "../platform/storage";
// Paths that should not be persisted as "last visited":
// - Auth flows (/login, /signup, /logout)
// - Pre-workspace routes (/onboarding, /auth/, /invite/)
// - Pre-workspace routes (/new-workspace, /auth/, /invite/)
// - Pair flow (/pair/)
const EXCLUDED_PREFIXES = [
"/login",
"/signup",
"/logout",
"/onboarding",
"/new-workspace",
"/auth/",
"/invite/",
"/pair/",

View File

@@ -66,7 +66,7 @@ describe("global path / reserved slug consistency", () => {
"/login",
"/logout",
"/signup",
"/onboarding",
"/new-workspace",
"/invite/",
"/auth/",
];

View File

@@ -27,7 +27,7 @@ describe("paths.workspace(slug)", () => {
describe("paths (global)", () => {
it("builds global paths without slug", () => {
expect(paths.login()).toBe("/login");
expect(paths.onboarding()).toBe("/onboarding");
expect(paths.newWorkspace()).toBe("/new-workspace");
expect(paths.invite("inv-1")).toBe("/invite/inv-1");
expect(paths.authCallback()).toBe("/auth/callback");
});
@@ -36,7 +36,7 @@ describe("paths (global)", () => {
describe("isGlobalPath", () => {
it("returns true for pre-workspace routes", () => {
expect(isGlobalPath("/login")).toBe(true);
expect(isGlobalPath("/onboarding")).toBe(true);
expect(isGlobalPath("/new-workspace")).toBe(true);
expect(isGlobalPath("/invite/abc")).toBe(true);
expect(isGlobalPath("/auth/callback")).toBe(true);
});

View File

@@ -4,7 +4,7 @@
*
* Two kinds of paths:
* - workspace-scoped: paths.workspace(slug).xxx() — carry workspace in URL
* - global: paths.login(), paths.onboarding(), paths.invite(id) — pre-workspace routes
* - global: paths.login(), paths.newWorkspace(), paths.invite(id) — pre-workspace routes
*
* Why pure functions + builder pattern:
* - Changing a route shape (e.g. adding workspace slug prefix) becomes a single-file edit
@@ -38,7 +38,7 @@ export const paths = {
// Global (pre-workspace) routes
login: () => "/login",
onboarding: () => "/onboarding",
newWorkspace: () => "/new-workspace",
invite: (id: string) => `/invite/${encode(id)}`,
authCallback: () => "/auth/callback",
root: () => "/",
@@ -48,7 +48,7 @@ export type WorkspacePaths = ReturnType<typeof workspaceScoped>;
// Prefixes — not slug names — because we match against full URL paths.
// A path is global if it equals or begins with any of these.
const GLOBAL_PREFIXES = ["/login", "/onboarding", "/invite/", "/auth/", "/logout", "/signup"];
const GLOBAL_PREFIXES = ["/login", "/new-workspace", "/invite/", "/auth/", "/logout", "/signup"];
export function isGlobalPath(path: string): boolean {
return GLOBAL_PREFIXES.some((p) => path === p || path.startsWith(p));

View File

@@ -8,6 +8,7 @@ export const RESERVED_SLUGS = new Set([
"logout",
"signup",
"onboarding",
"new-workspace",
"invite",
"auth",

View File

@@ -261,17 +261,18 @@ export function useRealtimeSync(
// --- Side-effect handlers (toast, navigation) ---
// After the current workspace disappears (deleted or we were kicked out),
// navigate to another workspace the user still has access to, or to
// onboarding. We use a full-page navigation: this reliably tears down any
// in-flight queries / subscriptions tied to the dead workspace without
// relying on framework-specific routers from here in core.
// navigate to another workspace the user still has access to, or to the
// create-workspace page. We use a full-page navigation: this reliably
// tears down any in-flight queries / subscriptions tied to the dead
// workspace without relying on framework-specific routers from here in
// core.
const relocateAfterWorkspaceLoss = async (lostWsId: string) => {
const wsList = await qc.fetchQuery({
...workspaceListOptions(),
staleTime: 0,
});
const next = wsList.find((w) => w.id !== lostWsId);
const target = next ? paths.workspace(next.slug).issues() : paths.onboarding();
const target = next ? paths.workspace(next.slug).issues() : paths.newWorkspace();
if (typeof window !== "undefined") {
window.location.assign(target);
}

View File

@@ -199,7 +199,8 @@ export function LoginPage({
// Normal path: seed the workspace list into the Query cache so the
// caller's onSuccess can read it synchronously to compute a destination
// URL (first workspace's slug, or onboarding).
// URL (first workspace's slug, or /new-workspace for zero-workspace
// users).
await useAuthStore.getState().verifyCode(email, value);
const wsList = await api.listWorkspaces();
qc.setQueryData(workspaceKeys.list(), wsList);

View File

@@ -34,7 +34,7 @@ export function InvitePage({ invitationId }: InvitePageProps) {
// page is a pre-workspace global route so we can't rely on WorkspaceSlugProvider.
const { data: wsList = [] } = useQuery(workspaceListOptions());
const fallbackDest =
wsList[0] ? paths.workspace(wsList[0].slug).issues() : paths.onboarding();
wsList[0] ? paths.workspace(wsList[0].slug).issues() : paths.newWorkspace();
const handleAccept = async () => {
setAccepting(true);

View File

@@ -273,7 +273,7 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
}
// Clear the last-workspace-slug cookie. Otherwise on a shared device the
// next user gets redirected by the proxy to the previous user's last
// workspace (then bounced to /onboarding by the layout — flash + confusing).
// workspace (then bounced to NoAccessPage by the layout — confusing).
if (typeof document !== "undefined") {
document.cookie = "last_workspace_slug=; path=/; max-age=0; SameSite=Lax";
}
@@ -283,6 +283,11 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
defaultStorage.removeItem("multica_tabs");
queryClient.clear();
authLogout();
// Navigate to /login explicitly. authLogout() clears state but doesn't
// move the URL, and the current URL is a workspace-scoped path that
// means nothing without auth — without this redirect the user stays on
// /{slug}/... and the layout renders null (blank screen).
push(paths.login());
};
// Global "C" shortcut to open create-issue modal (like Linear)

View File

@@ -14,13 +14,13 @@ import { useNavigation } from "../navigation";
* Redirect logic:
* - Auth still loading → wait
* - Not logged in → /login
* - Logged in but workspace list not yet loaded → wait (don't bounce to /onboarding)
* - Logged in but URL slug doesn't resolve to any workspace → /onboarding
* - Logged in but workspace list not yet loaded → wait (don't bounce prematurely)
* - Logged in but URL slug doesn't resolve to any workspace → /new-workspace
*
* We read the workspace list query state directly (rather than relying on
* useCurrentWorkspace's null return) so we can distinguish "list loading"
* from "slug not found". Otherwise users could see a transient redirect
* to /onboarding before their workspace list arrives.
* to /new-workspace before their workspace list arrives.
*/
export function useDashboardGuard() {
const { pathname, replace } = useNavigation();
@@ -41,7 +41,7 @@ export function useDashboardGuard() {
// Wait for workspace list to settle before deciding "no workspace".
if (!workspaceListFetched) return;
if (!workspace) {
replace(paths.onboarding());
replace(paths.newWorkspace());
}
}, [user, isLoading, workspaceListFetched, workspace, replace]);

View File

@@ -1,12 +1,8 @@
"use client";
import { useRef, useState } from "react";
import { useNavigation } from "../navigation";
import { useImmersiveMode } from "../platform";
import { toast } from "sonner";
import { ArrowLeft } from "lucide-react";
import { Input } from "@multica/ui/components/ui/input";
import { Label } from "@multica/ui/components/ui/label";
import { Button } from "@multica/ui/components/ui/button";
import {
Dialog,
@@ -14,84 +10,15 @@ import {
DialogTitle,
DialogDescription,
} from "@multica/ui/components/ui/dialog";
import { Card, CardContent } from "@multica/ui/components/ui/card";
import { useCreateWorkspace } from "@multica/core/workspace/mutations";
import { paths } from "@multica/core/paths";
import {
WORKSPACE_SLUG_CONFLICT_ERROR,
WORKSPACE_SLUG_FORMAT_ERROR,
WORKSPACE_SLUG_REGEX,
isWorkspaceSlugConflict,
nameToWorkspaceSlug,
} from "../workspace/slug";
import { CreateWorkspaceForm } from "../workspace/create-workspace-form";
export function CreateWorkspaceModal({ onClose }: { onClose: () => void }) {
// This modal is full-screen, so it covers the app titlebar. On macOS desktop
// we hide the traffic lights for its lifetime so the Back button in the top-
// left corner isn't stolen by the native controls' hit-test. No-op elsewhere.
useImmersiveMode();
const router = useNavigation();
const createWorkspace = useCreateWorkspace();
const [name, setName] = useState("");
const [slug, setSlug] = useState("");
const [slugServerError, setSlugServerError] = useState<string | null>(null);
const slugTouched = useRef(false);
const slugValidationError =
slug.length > 0 && !WORKSPACE_SLUG_REGEX.test(slug)
? WORKSPACE_SLUG_FORMAT_ERROR
: null;
const slugError = slugValidationError ?? slugServerError;
const canSubmit =
name.trim().length > 0 && slug.trim().length > 0 && !slugError;
const handleNameChange = (value: string) => {
setName(value);
if (!slugTouched.current) {
setSlug(nameToWorkspaceSlug(value));
setSlugServerError(null);
}
};
const handleSlugChange = (value: string) => {
slugTouched.current = true;
setSlug(value);
setSlugServerError(null);
};
const handleCreate = () => {
if (!canSubmit) return;
// The modal is only reachable from an authenticated workspace context
// (via the global modal registry). After creating a new workspace the
// user should land INSIDE it at its issues page, not in /onboarding —
// onboarding exists only for users with zero workspaces. Navigation is the
// only way to switch workspaces now (URL is the source of truth), so the
// push below is sufficient — no imperative store writes needed.
createWorkspace.mutate(
{ name: name.trim(), slug: slug.trim() },
{
onSuccess: (newWs) => {
onClose();
// Navigate INTO the new workspace. The mutation's own onSuccess
// (in core/workspace/mutations.ts) runs before this callback and
// has already seeded the workspace list cache, so the destination
// [workspaceSlug]/layout will resolve newWs.slug → workspace
// synchronously without a loading flash.
router.push(paths.workspace(newWs.slug).issues());
},
onError: (error) => {
if (isWorkspaceSlugConflict(error)) {
setSlugServerError(WORKSPACE_SLUG_CONFLICT_ERROR);
toast.error("Choose a different workspace URL");
return;
}
toast.error("Failed to create workspace");
},
},
);
};
return (
<Dialog
@@ -135,49 +62,17 @@ export function CreateWorkspaceModal({ onClose }: { onClose: () => void }) {
projects and issues.
</DialogDescription>
</div>
<Card className="w-full">
<CardContent className="space-y-4 pt-6">
<div className="space-y-1.5">
<Label>Workspace Name</Label>
<Input
autoFocus
type="text"
value={name}
onChange={(e) => handleNameChange(e.target.value)}
placeholder="My Workspace"
/>
</div>
<div className="space-y-1.5">
<Label>Workspace URL</Label>
<div className="flex items-center gap-0 rounded-md border bg-background focus-within:ring-2 focus-within:ring-ring">
<span className="pl-3 text-sm text-muted-foreground select-none">
multica.ai/
</span>
<Input
type="text"
value={slug}
onChange={(e) => handleSlugChange(e.target.value)}
placeholder="my-workspace"
className="border-0 shadow-none focus-visible:ring-0"
onKeyDown={(e) => e.key === "Enter" && handleCreate()}
/>
</div>
{slugError && (
<p className="text-xs text-destructive">{slugError}</p>
)}
</div>
</CardContent>
</Card>
<Button
className="w-full"
size="lg"
onClick={handleCreate}
disabled={createWorkspace.isPending || !canSubmit}
>
{createWorkspace.isPending ? "Creating..." : "Create workspace"}
</Button>
<CreateWorkspaceForm
onSuccess={(newWs) => {
onClose();
// Navigate INTO the new workspace. The mutation's own onSuccess
// (in core/workspace/mutations.ts) runs before this callback and
// has already seeded the workspace list cache, so the destination
// [workspaceSlug]/layout will resolve newWs.slug → workspace
// synchronously without a loading flash.
router.push(paths.workspace(newWs.slug).issues());
}}
/>
</div>
</DialogContent>
</Dialog>

View File

@@ -1,3 +0,0 @@
export { OnboardingWizard } from "./onboarding-wizard";
export type { OnboardingWizardProps } from "./onboarding-wizard";
export { StepWorkspace } from "./step-workspace";

View File

@@ -1,131 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import {
QueryClient,
QueryClientProvider,
type QueryClient as QC,
} from "@tanstack/react-query";
import type { ReactNode } from "react";
import type { Workspace } from "@multica/core/types";
import { workspaceKeys } from "@multica/core/workspace/queries";
vi.mock("./step-workspace", () => ({
StepWorkspace: ({ onNext }: { onNext: () => void }) => (
<button type="button" onClick={onNext}>
Finish workspace
</button>
),
}));
vi.mock("./step-runtime", () => ({
StepRuntime: ({ wsId }: { wsId: string }) => (
<div>Runtime step for {wsId}</div>
),
}));
vi.mock("./step-agent", () => ({
StepAgent: () => <div>Agent step</div>,
}));
vi.mock("./step-complete", () => ({
StepComplete: () => <div>Complete step</div>,
}));
// Stub the list query so the wizard reads whatever we seeded in the cache.
// `listWorkspaces` returns a promise that never resolves so the seeded cache
// data isn't overwritten by a background refetch during the test.
vi.mock("@multica/core/api", () => ({
api: {
listWorkspaces: vi.fn(() => new Promise(() => {})),
},
}));
import { OnboardingWizard } from "./onboarding-wizard";
function makeWorkspace(id: string, slug = id): Workspace {
return {
id,
name: id,
slug,
created_at: "",
updated_at: "",
} as Workspace;
}
function renderWithCache(
wsList: Workspace[],
onComplete = vi.fn(),
): { qc: QC } {
const qc = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
qc.setQueryData(workspaceKeys.list(), wsList);
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
);
render(<OnboardingWizard onComplete={onComplete} />, { wrapper });
return { qc };
}
describe("OnboardingWizard", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("starts at workspace creation when no workspace exists", () => {
renderWithCache([]);
expect(
screen.getByRole("button", { name: "Finish workspace" }),
).toBeInTheDocument();
});
it("continues setup when a workspace already exists", () => {
renderWithCache([makeWorkspace("ws-123")]);
expect(screen.getByText("Runtime step for ws-123")).toBeInTheDocument();
});
it("continues setup when the workspace becomes available after mount", async () => {
const qc = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
qc.setQueryData(workspaceKeys.list(), []);
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
);
render(<OnboardingWizard onComplete={vi.fn()} />, { wrapper });
expect(
screen.getByRole("button", { name: "Finish workspace" }),
).toBeInTheDocument();
// Simulate useCreateWorkspace adding the new workspace to the list cache.
qc.setQueryData(workspaceKeys.list(), [makeWorkspace("ws-456")]);
expect(
await screen.findByText("Runtime step for ws-456"),
).toBeInTheDocument();
});
it("does not skip runtime when workspace creation also advances step", () => {
const qc = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
qc.setQueryData(workspaceKeys.list(), []);
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
);
render(<OnboardingWizard onComplete={vi.fn()} />, { wrapper });
// Mutation's onSuccess populates the cache first, then the step-workspace
// mock calls onNext — we should land on step 1 (runtime), never step 2.
qc.setQueryData(workspaceKeys.list(), [makeWorkspace("ws-789")]);
fireEvent.click(screen.getByRole("button", { name: "Finish workspace" }));
expect(screen.getByText("Runtime step for ws-789")).toBeInTheDocument();
expect(screen.queryByText("Agent step")).not.toBeInTheDocument();
});
});

View File

@@ -1,130 +0,0 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { workspaceListOptions } from "@multica/core/workspace/queries";
import type { Agent, Workspace } from "@multica/core/types";
import { StepWorkspace } from "./step-workspace";
import { StepRuntime } from "./step-runtime";
import { StepAgent } from "./step-agent";
import { StepComplete } from "./step-complete";
const STEPS = [
{ label: "Workspace" },
{ label: "Runtime" },
{ label: "Agent" },
{ label: "Get Started" },
] as const;
export interface OnboardingWizardProps {
/**
* Called when the user finishes the wizard. The just-configured workspace is
* passed so the caller can navigate into it (/{slug}/issues). Onboarding is a
* pre-workspace global route, so the URL has no slug while it runs.
*/
onComplete: (workspace: Workspace) => void;
}
export function OnboardingWizard({ onComplete }: OnboardingWizardProps) {
// Canonical source for workspace existence: the React Query list cache. The
// onboarding route itself is global (no slug in URL), so useCurrentWorkspace
// can't help here — we read the list directly. `useCreateWorkspace` adds the
// new workspace to this cache in its onSuccess, so step 0 → step 1 happens
// once the list query is populated.
const { data: wsList = [] } = useQuery(workspaceListOptions());
// A user arriving at /onboarding normally has 0 workspaces. After the first
// step they have exactly one. In the rare case the list already has entries
// (e.g. the user manually navigated to /onboarding), pick the most recent —
// that's the one the onboarding flow should configure.
const workspace: Workspace | null = wsList[wsList.length - 1] ?? null;
const wsId = workspace?.id ?? null;
const [step, setStep] = useState(() => (workspace ? 1 : 0));
const [createdAgent, setCreatedAgent] = useState<Agent | null>(null);
useEffect(() => {
if (step === 0 && wsId) {
setStep(1);
}
}, [step, wsId]);
const startWorkspaceSetup = useCallback(() => setStep(1), []);
const next = useCallback(
() => setStep((s) => Math.min(s + 1, STEPS.length - 1)),
[],
);
return (
<div className="flex min-h-svh flex-col bg-background">
{/* Progress bar */}
<div className="flex items-center justify-center gap-2 px-6 pt-8">
{STEPS.map((s, i) => (
<div key={s.label} className="flex items-center gap-2">
<div className="flex items-center gap-1.5">
<div
className={`flex h-6 w-6 items-center justify-center rounded-full text-xs font-medium transition-colors ${
i <= step
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground"
}`}
>
{i < step ? (
<svg
className="h-3.5 w-3.5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="20 6 9 17 4 12" />
</svg>
) : (
i + 1
)}
</div>
<span
className={`text-sm ${
i <= step
? "text-foreground font-medium"
: "text-muted-foreground"
}`}
>
{s.label}
</span>
</div>
{i < STEPS.length - 1 && (
<div
className={`h-px w-8 ${i < step ? "bg-primary" : "bg-border"}`}
/>
)}
</div>
))}
</div>
{/* Step content */}
<div className="flex flex-1 items-center justify-center px-6 py-12">
{step === 0 && <StepWorkspace onNext={startWorkspaceSetup} />}
{step === 1 && wsId && (
<StepRuntime wsId={wsId} onNext={next} />
)}
{step === 2 && wsId && (
<StepAgent
wsId={wsId}
onNext={next}
onAgentCreated={setCreatedAgent}
/>
)}
{step === 3 && workspace && (
<StepComplete
wsId={workspace.id}
agent={createdAgent}
onEnter={() => onComplete(workspace)}
/>
)}
</div>
</div>
);
}

View File

@@ -1,367 +0,0 @@
"use client";
import { useState, useEffect } from "react";
import { useQuery } from "@tanstack/react-query";
import {
ChevronDown,
Globe,
Lock,
AlertCircle,
Crown,
Code,
} from "lucide-react";
import { toast } from "sonner";
import { Button } from "@multica/ui/components/ui/button";
import { Card } from "@multica/ui/components/ui/card";
import { Input } from "@multica/ui/components/ui/input";
import { Label } from "@multica/ui/components/ui/label";
import {
Popover,
PopoverTrigger,
PopoverContent,
} from "@multica/ui/components/ui/popover";
import { api } from "@multica/core/api";
import { runtimeListOptions } from "@multica/core/runtimes/queries";
import { ProviderLogo } from "../runtimes/components/provider-logo";
import type {
Agent,
AgentVisibility,
CreateAgentRequest,
} from "@multica/core/types";
interface AgentTemplate {
id: string;
name: string;
description: string;
instructions: string;
icon: typeof Crown;
}
const AGENT_TEMPLATES: AgentTemplate[] = [
{
id: "master",
name: "Master Agent",
description: "Manages workspace, assigns tasks, and coordinates work",
instructions:
"You are a Master Agent for this workspace. Your role is to manage and coordinate tasks, triage incoming issues, and ensure work is distributed effectively across the team.",
icon: Crown,
},
{
id: "coding",
name: "Coding Agent",
description: "Checks out code, implements features, and submits PRs",
instructions:
"You are a Coding Agent. Your role is to check out code repositories, implement features and bug fixes based on issue descriptions, write tests, and submit pull requests.",
icon: Code,
},
];
export function StepAgent({
wsId,
onNext,
onAgentCreated,
}: {
wsId: string;
onNext: () => void;
onAgentCreated: (agent: Agent) => void;
}) {
const { data: runtimes = [] } = useQuery(runtimeListOptions(wsId));
const hasRuntime = runtimes.length > 0;
// Template selection
const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(
null,
);
// Form state — populated from template, editable
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [selectedRuntimeId, setSelectedRuntimeId] = useState("");
const [visibility, setVisibility] = useState<AgentVisibility>("workspace");
const [creating, setCreating] = useState(false);
const [runtimeOpen, setRuntimeOpen] = useState(false);
const [showForm, setShowForm] = useState(false);
// Auto-select first runtime
useEffect(() => {
if (!selectedRuntimeId && runtimes[0]) {
setSelectedRuntimeId(runtimes[0].id);
}
}, [runtimes, selectedRuntimeId]);
const selectedRuntime =
runtimes.find((r) => r.id === selectedRuntimeId) ?? null;
const handleSelectTemplate = (template: AgentTemplate) => {
setSelectedTemplateId(template.id);
setName(template.name);
setDescription(template.description);
setShowForm(true);
};
const handleCreate = async () => {
if (!name.trim() || !selectedRuntime) return;
const template = AGENT_TEMPLATES.find((t) => t.id === selectedTemplateId);
setCreating(true);
try {
const req: CreateAgentRequest = {
name: name.trim(),
description: description.trim() || undefined,
instructions: template?.instructions,
runtime_id: selectedRuntime.id,
visibility,
};
const agent = await api.createAgent(req);
onAgentCreated(agent);
onNext();
} catch (err) {
toast.error(
err instanceof Error ? err.message : "Failed to create agent",
);
setCreating(false);
}
};
return (
<div className="flex w-full max-w-lg flex-col items-center gap-8">
<div className="text-center">
<h1 className="text-3xl font-semibold tracking-tight">
Create Your First Agent
</h1>
<p className="mt-2 text-muted-foreground">
Choose a template to get started, then customize your agent.
</p>
</div>
{/* No runtime warning */}
{!hasRuntime && (
<div className="flex w-full items-start gap-2 rounded-lg border border-warning/30 bg-warning/5 px-4 py-3 text-sm text-warning">
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
<p>
No runtime connected. Go back to connect a runtime, or skip and set
one up later.
</p>
</div>
)}
{/* Template cards */}
{!showForm && (
<div className="grid w-full grid-cols-2 gap-4">
{AGENT_TEMPLATES.map((template) => {
const Icon = template.icon;
return (
<Card
key={template.id}
role="button"
tabIndex={0}
onClick={() => handleSelectTemplate(template)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleSelectTemplate(template);
}
}}
className="cursor-pointer p-5 transition-all hover:border-foreground/20"
>
<div className="mb-3 flex h-10 w-10 items-center justify-center rounded-lg bg-muted text-muted-foreground">
<Icon className="h-5 w-5" />
</div>
<h3 className="font-semibold">{template.name}</h3>
<p className="mt-1 text-sm text-muted-foreground">
{template.description}
</p>
</Card>
);
})}
</div>
)}
{/* Agent configuration form */}
{showForm && (
<Card className="w-full p-5 space-y-4">
{/* Name */}
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground">Agent Name</Label>
<Input
autoFocus
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Coding Agent"
/>
</div>
{/* Description */}
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground">Description</Label>
<Input
type="text"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="What does this agent do?"
/>
</div>
{/* Runtime selector */}
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground">Runtime</Label>
<Popover open={runtimeOpen} onOpenChange={setRuntimeOpen}>
<PopoverTrigger
disabled={!hasRuntime}
className="flex w-full min-w-0 items-center gap-3 rounded-lg border border-border bg-background px-3 py-2.5 text-left text-sm transition-colors hover:bg-muted disabled:pointer-events-none disabled:opacity-50"
>
{selectedRuntime ? (
<ProviderLogo
provider={selectedRuntime.provider}
className="h-4 w-4 shrink-0"
/>
) : (
<div className="h-4 w-4 shrink-0 rounded-full bg-muted-foreground/30" />
)}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate font-medium">
{selectedRuntime?.name ?? "No runtime available"}
</span>
{selectedRuntime?.runtime_mode === "cloud" && (
<span className="shrink-0 rounded bg-info/10 px-1.5 py-0.5 text-xs font-medium text-info">
Cloud
</span>
)}
</div>
<div className="truncate text-xs text-muted-foreground">
{selectedRuntime
? `${selectedRuntime.provider} · ${selectedRuntime.device_info}`
: "Connect a runtime first"}
</div>
</div>
<ChevronDown
className={`h-4 w-4 shrink-0 text-muted-foreground transition-transform ${runtimeOpen ? "rotate-180" : ""}`}
/>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-[var(--anchor-width)] max-h-60 overflow-y-auto p-1"
>
{runtimes.map((rt) => (
<button
key={rt.id}
onClick={() => {
setSelectedRuntimeId(rt.id);
setRuntimeOpen(false);
}}
className={`flex w-full items-center gap-3 rounded-md px-3 py-2.5 text-left text-sm transition-colors ${
rt.id === selectedRuntimeId
? "bg-accent"
: "hover:bg-accent/50"
}`}
>
<ProviderLogo
provider={rt.provider}
className="h-4 w-4 shrink-0"
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate font-medium">{rt.name}</span>
{rt.runtime_mode === "cloud" && (
<span className="shrink-0 rounded bg-info/10 px-1.5 py-0.5 text-xs font-medium text-info">
Cloud
</span>
)}
</div>
<div className="truncate text-xs text-muted-foreground">
{rt.provider} · {rt.device_info}
</div>
</div>
<span
className={`h-2 w-2 shrink-0 rounded-full ${
rt.status === "online"
? "bg-success"
: "bg-muted-foreground/40"
}`}
/>
</button>
))}
</PopoverContent>
</Popover>
</div>
{/* Visibility */}
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground">Visibility</Label>
<div className="flex gap-2">
<button
type="button"
onClick={() => setVisibility("workspace")}
className={`flex flex-1 items-center gap-2 rounded-lg border px-3 py-2 text-sm transition-colors ${
visibility === "workspace"
? "border-primary bg-primary/5"
: "border-border hover:bg-muted"
}`}
>
<Globe className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="text-left">
<div className="font-medium">Workspace</div>
<div className="text-xs text-muted-foreground">
All members can assign
</div>
</div>
</button>
<button
type="button"
onClick={() => setVisibility("private")}
className={`flex flex-1 items-center gap-2 rounded-lg border px-3 py-2 text-sm transition-colors ${
visibility === "private"
? "border-primary bg-primary/5"
: "border-border hover:bg-muted"
}`}
>
<Lock className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="text-left">
<div className="font-medium">Private</div>
<div className="text-xs text-muted-foreground">
Only you can assign
</div>
</div>
</button>
</div>
</div>
</Card>
)}
{/* Actions */}
<div className="flex w-full flex-col items-center gap-3">
{showForm ? (
<>
<Button
className="w-full"
size="lg"
onClick={handleCreate}
disabled={creating || !name.trim() || !selectedRuntime}
>
{creating ? "Creating..." : "Create Agent"}
</Button>
<button
type="button"
onClick={() => {
setShowForm(false);
setSelectedTemplateId(null);
}}
className="text-sm text-muted-foreground underline-offset-4 hover:underline"
>
Back to templates
</button>
</>
) : (
<button
type="button"
onClick={onNext}
className="text-sm text-muted-foreground underline-offset-4 hover:underline"
>
Skip for now
</button>
)}
</div>
</div>
);
}

View File

@@ -1,203 +0,0 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Check, ArrowRight, Loader2, Bot } from "lucide-react";
import { Button } from "@multica/ui/components/ui/button";
import { Card } from "@multica/ui/components/ui/card";
import { api } from "@multica/core/api";
import type { Agent, Issue, CreateIssueRequest } from "@multica/core/types";
interface OnboardingIssueDef {
title: string;
description: string;
/** If true, assigned to the agent with status "todo" so it gets picked up */
assignToAgent: boolean;
status: "todo" | "backlog";
}
function getOnboardingIssues(): OnboardingIssueDef[] {
return [
{
title: "Say hello to the team!",
description: [
"Welcome! This is your first automated task.",
"",
"Please introduce yourself to the team:",
"- What's your name and role in this workspace?",
"- What kinds of tasks can you help with?",
"- Give 23 concrete examples of things the team can ask you to do",
"",
"---",
"",
"**Try it out!** After the agent responds, reply with one of these to see it in action:",
'- "Review this function for bugs: `function add(a, b) { return a - b; }`"',
'- "Draft a short description for a new onboarding feature"',
'- "What are some best practices for writing clean commit messages?"',
"",
"This issue was automatically assigned to verify your agent is working end-to-end.",
].join("\n"),
assignToAgent: true,
status: "todo",
},
{
title: "Set up your repository connection",
description: [
"Connect a code repository so agents can check out code and submit pull requests.",
"",
"**Steps:**",
"1. Go to **Settings** in the sidebar",
"2. Under **Repositories**, add a Git repository URL",
"3. The agent daemon will sync the repo locally",
"",
"Once connected, your agents can clone, branch, and push code as part of any task.",
].join("\n"),
assignToAgent: false,
status: "backlog",
},
{
title: "Create a skill for your agent",
description: [
"Skills are reusable instructions that make agents better at recurring tasks — deployments, code reviews, migrations, etc.",
"",
"**Note:** Skills already installed in your local runtime (e.g., `.claude/skills/`) are automatically available to agents — no need to re-upload them. Workspace skills here are for sharing knowledge across your team.",
"",
"**Steps:**",
"1. Go to **Skills** in the sidebar",
"2. Click **New Skill**",
"3. Write a description and instructions (e.g., \"Code Review\" with your team's style guide)",
"4. Assign the skill to an agent in the agent's settings",
"",
"Every skill you create compounds your team's capabilities over time.",
].join("\n"),
assignToAgent: false,
status: "backlog",
},
{
title: "Invite a teammate",
description: [
"Multica works best with a team. Invite a colleague to your workspace so you can collaborate on issues and share agents.",
"",
"**Steps:**",
"1. Go to **Settings → Members**",
"2. Click **Invite** and enter their email",
"3. They'll get access to the workspace, all agents, and the issue board",
].join("\n"),
assignToAgent: false,
status: "backlog",
},
];
}
export function StepComplete({
wsId,
agent,
onEnter,
}: {
wsId: string;
agent: Agent | null;
onEnter: () => void;
}) {
const [createdIssues, setCreatedIssues] = useState<Issue[]>([]);
const [loading, setLoading] = useState(true);
const didCreate = useRef(false);
useEffect(() => {
if (didCreate.current) return;
didCreate.current = true;
async function createOnboardingIssues() {
const defs = getOnboardingIssues();
const issues: Issue[] = [];
for (const def of defs) {
try {
const req: CreateIssueRequest = {
title: def.title,
description: def.description,
status: def.status,
};
if (def.assignToAgent && agent) {
req.assignee_type = "agent";
req.assignee_id = agent.id;
}
const issue = await api.createIssue(req);
issues.push(issue);
} catch {
// Best-effort — continue with remaining issues
}
}
setCreatedIssues(issues);
setLoading(false);
}
createOnboardingIssues();
}, [agent, wsId]);
return (
<div className="flex w-full max-w-md flex-col items-center gap-8">
{/* Success icon */}
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-success/10">
<Check className="h-8 w-8 text-success" />
</div>
<div className="text-center">
<h1 className="text-3xl font-semibold tracking-tight">
You&apos;re all set!
</h1>
<p className="mt-2 text-muted-foreground">
{agent
? `Your workspace is ready and ${agent.name} is picking up its first task.`
: "Your workspace is ready. Create issues and assign them to agents to get started."}
</p>
</div>
{/* Created issues */}
{loading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
<span>Setting up your workspace...</span>
</div>
) : (
createdIssues.length > 0 && (
<Card className="w-full divide-y">
{createdIssues.map((issue) => (
<div
key={issue.id}
className="flex items-center gap-3 px-4 py-3"
>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">
{issue.identifier} {issue.title}
</div>
<div className="truncate text-xs text-muted-foreground">
{issue.assignee_id && agent
? `Assigned to ${agent.name}`
: issue.status === "todo"
? "To do"
: "Backlog"}
</div>
</div>
{issue.assignee_id && agent && (
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-violet-100 dark:bg-violet-900/30">
<Bot className="h-3.5 w-3.5 text-violet-600 dark:text-violet-400" />
</div>
)}
</div>
))}
</Card>
)
)}
<Button
className="w-full"
size="lg"
onClick={onEnter}
disabled={loading}
>
Go to Workspace
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
);
}

View File

@@ -1,204 +0,0 @@
"use client";
import { useState, useCallback, useMemo } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, Copy, Terminal, Loader2 } from "lucide-react";
import { Button } from "@multica/ui/components/ui/button";
import { Card, CardContent } from "@multica/ui/components/ui/card";
import { useWSEvent } from "@multica/core/realtime";
import { api } from "@multica/core/api";
import { ProviderLogo } from "../runtimes/components/provider-logo";
import {
runtimeListOptions,
runtimeKeys,
} from "@multica/core/runtimes/queries";
const CLOUD_HOST = "multica.ai";
const INSTALL_STEP = {
label: "Install the Multica CLI",
cmd: "curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash",
};
function isCloudEnvironment(): boolean {
if (typeof window === "undefined") return true;
return window.location.hostname.endsWith(CLOUD_HOST);
}
function buildSetupCommand(): string {
if (isCloudEnvironment()) return "multica setup";
const appUrl = typeof window !== "undefined" ? window.location.origin : "";
const apiBaseUrl = api.getBaseUrl?.() ?? "";
const serverUrl = apiBaseUrl || appUrl;
if (!serverUrl || serverUrl === "http://localhost:8080") {
// Default self-host — no flags needed
return "multica setup self-host";
}
const parts = ["multica setup self-host"];
parts.push(`--server-url ${serverUrl}`);
if (appUrl && appUrl !== serverUrl) {
parts.push(`--app-url ${appUrl}`);
}
return parts.join(" ");
}
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = () => {
navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<button
type="button"
onClick={handleCopy}
className="shrink-0 rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
>
{copied ? (
<Check className="h-3.5 w-3.5 text-success" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
</button>
);
}
export function StepRuntime({
wsId,
onNext,
}: {
wsId: string;
onNext: () => void;
}) {
const qc = useQueryClient();
const setupSteps = useMemo(
() => [
INSTALL_STEP,
{ label: "Set up and start the daemon", cmd: buildSetupCommand() },
],
[],
);
const { data: runtimes = [] } = useQuery(runtimeListOptions(wsId));
const handleDaemonEvent = useCallback(() => {
qc.invalidateQueries({ queryKey: runtimeKeys.all(wsId) });
}, [qc, wsId]);
useWSEvent("daemon:register", handleDaemonEvent);
const hasRuntimes = runtimes.length > 0;
return (
<div className="flex w-full max-w-xl flex-col items-center gap-8">
<div className="text-center">
<h1 className="text-3xl font-semibold tracking-tight">
Connect a Runtime
</h1>
<p className="mt-2 text-muted-foreground">
Install the CLI and run the setup command below to connect your
machine. The daemon auto-detects agent CLIs (Claude Code, Codex,
etc.) on your PATH.
</p>
</div>
{/* Commands */}
<Card className="w-full">
<CardContent className="space-y-3 pt-4">
{setupSteps.map((step, i) => (
<div key={i}>
<p className="mb-1.5 text-xs text-muted-foreground">
{i + 1}. {step.label}
</p>
<div className="flex items-start gap-2 rounded-lg bg-muted px-3 py-2.5 font-mono text-sm">
<Terminal className="mt-0.5 h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<code className="min-w-0 flex-1 break-all whitespace-pre-wrap">
{step.cmd}
</code>
<CopyButton text={step.cmd} />
</div>
</div>
))}
<p className="pt-1 text-xs text-muted-foreground">
The setup command handles authentication, configuration, and daemon
startup all in one step.
</p>
</CardContent>
</Card>
{/* Connected runtimes */}
<div className="w-full space-y-3">
<div className="flex items-center gap-2 text-sm">
{hasRuntimes ? (
<>
<div className="h-2 w-2 rounded-full bg-success" />
<span className="font-medium">
{runtimes.length} runtime{runtimes.length > 1 ? "s" : ""}{" "}
connected
</span>
</>
) : (
<>
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
<span className="text-muted-foreground">
Waiting for connection...
</span>
</>
)}
</div>
{hasRuntimes && (
<Card className="w-full">
<CardContent className="divide-y pt-0">
{runtimes.map((rt) => (
<div
key={rt.id}
className="flex items-center gap-3 py-3 first:pt-4 last:pb-4"
>
<span
className={`h-2 w-2 shrink-0 rounded-full ${
rt.status === "online"
? "bg-success"
: "bg-muted-foreground/40"
}`}
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-medium">
{rt.name}
</span>
{rt.runtime_mode === "cloud" && (
<span className="shrink-0 rounded bg-info/10 px-1.5 py-0.5 text-xs font-medium text-info">
Cloud
</span>
)}
</div>
<div className="truncate text-xs text-muted-foreground">
{rt.provider} · {rt.device_info}
</div>
</div>
<ProviderLogo
provider={rt.provider}
className="h-5 w-5 shrink-0"
/>
</div>
))}
</CardContent>
</Card>
)}
</div>
{/* Actions */}
<Button className="w-full" size="lg" onClick={onNext}>
{hasRuntimes ? "Continue" : "Skip for now"}
</Button>
</div>
);
}

View File

@@ -1,58 +0,0 @@
import { describe, expect, it, beforeEach, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
const mockCreateWorkspaceMutate = vi.hoisted(() => vi.fn());
const mockToastError = vi.hoisted(() => vi.fn());
vi.mock("@multica/core/workspace/mutations", () => ({
useCreateWorkspace: () => ({
mutate: mockCreateWorkspaceMutate,
isPending: false,
}),
}));
vi.mock("sonner", () => ({
toast: {
error: mockToastError,
},
}));
import { StepWorkspace } from "./step-workspace";
describe("StepWorkspace", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("asks the user to change the slug on conflict", async () => {
const user = userEvent.setup();
mockCreateWorkspaceMutate.mockImplementation(
(
_data: unknown,
options: { onError: (error: unknown) => void },
) => {
options.onError({ status: 409 });
},
);
render(<StepWorkspace onNext={vi.fn()} />);
await user.type(screen.getByPlaceholderText("My Team"), "My Team");
await user.click(screen.getByRole("button", { name: "Create Workspace" }));
await waitFor(() => {
expect(
screen.getByText("That workspace URL is already taken."),
).toBeInTheDocument();
});
expect(mockToastError).toHaveBeenCalledWith(
"Choose a different workspace URL",
);
expect(mockCreateWorkspaceMutate).toHaveBeenCalledWith(
{ name: "My Team", slug: "my-team" },
expect.any(Object),
);
});
});

View File

@@ -28,12 +28,15 @@
"./inbox": "./inbox/index.ts",
"./runtimes": "./runtimes/index.ts",
"./workspace/workspace-avatar": "./workspace/workspace-avatar.tsx",
"./workspace/create-workspace-form": "./workspace/create-workspace-form.tsx",
"./workspace/no-access-page": "./workspace/no-access-page.tsx",
"./workspace/new-workspace-page": "./workspace/new-workspace-page.tsx",
"./workspace/use-workspace-seen": "./workspace/use-workspace-seen.ts",
"./layout": "./layout/index.ts",
"./auth": "./auth/index.ts",
"./search": "./search/index.ts",
"./chat": "./chat/index.ts",
"./settings": "./settings/index.ts",
"./onboarding": "./onboarding/index.ts",
"./invite": "./invite/index.ts",
"./platform": "./platform/index.ts"
},

View File

@@ -13,9 +13,9 @@ function getDesktopAPI(): ImmersiveCapableAPI | undefined {
* Enter "immersive" mode for the lifetime of the component that calls it.
*
* On macOS desktop this hides the traffic-light window controls so full-screen
* modals (create-workspace, onboarding, etc.) can place UI in the top-left
* corner without fighting the native controls' hit-test. On web or non-macOS
* desktop this is a no-op.
* modals (e.g. create-workspace) can place UI in the top-left corner without
* fighting the native controls' hit-test. On web or non-macOS desktop this
* is a no-op.
*/
export function useImmersiveMode(): void {
useEffect(() => {

View File

@@ -45,9 +45,10 @@ export function WorkspaceTab() {
/**
* After leaving/deleting the current workspace, send the user to a safe URL:
* another workspace they still have access to, or onboarding if they have none.
* The list is freshly fetched (staleTime: 0) because the cache still contains
* the just-removed workspace until the background invalidation resolves.
* another workspace they still have access to, or /new-workspace if none
* remain. The list is freshly fetched (staleTime: 0) because the cache still
* contains the just-removed workspace until the background invalidation
* resolves.
*/
const navigateAwayFromCurrentWorkspace = async () => {
const wsList = await qc.fetchQuery({
@@ -57,7 +58,7 @@ export function WorkspaceTab() {
const remaining = wsList.filter((w) => w.id !== workspace?.id);
const next = remaining[0];
navigation.push(
next ? paths.workspace(next.slug).issues() : paths.onboarding(),
next ? paths.workspace(next.slug).issues() : paths.newWorkspace(),
);
};

View File

@@ -0,0 +1,85 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { CreateWorkspaceForm } from "./create-workspace-form";
const mockMutate = vi.fn();
vi.mock("@multica/core/workspace/mutations", () => ({
useCreateWorkspace: () => ({ mutate: mockMutate, isPending: false }),
}));
function renderForm(onSuccess = vi.fn()) {
const qc = new QueryClient();
return render(
<QueryClientProvider client={qc}>
<CreateWorkspaceForm onSuccess={onSuccess} />
</QueryClientProvider>,
);
}
describe("CreateWorkspaceForm", () => {
beforeEach(() => mockMutate.mockReset());
it("auto-generates slug from name until user edits slug", () => {
renderForm();
fireEvent.change(screen.getByLabelText(/workspace name/i), {
target: { value: "Acme Corp" },
});
expect(screen.getByDisplayValue("acme-corp")).toBeInTheDocument();
});
it("stops auto-generating slug once user edits slug directly", () => {
renderForm();
fireEvent.change(screen.getByLabelText(/workspace url/i), {
target: { value: "custom" },
});
fireEvent.change(screen.getByLabelText(/workspace name/i), {
target: { value: "Different Name" },
});
expect(screen.getByDisplayValue("custom")).toBeInTheDocument();
});
it("calls onSuccess with the created workspace", async () => {
const onSuccess = vi.fn();
mockMutate.mockImplementation((_args, opts) => {
opts?.onSuccess?.({ id: "ws-1", slug: "acme", name: "Acme" });
});
renderForm(onSuccess);
fireEvent.change(screen.getByLabelText(/workspace name/i), {
target: { value: "Acme" },
});
fireEvent.click(screen.getByRole("button", { name: /create workspace/i }));
await waitFor(() =>
expect(onSuccess).toHaveBeenCalledWith(
expect.objectContaining({ slug: "acme" }),
),
);
});
it("shows slug-conflict error inline on 409", async () => {
mockMutate.mockImplementation((_args, opts) => {
opts?.onError?.({ status: 409 });
});
renderForm();
fireEvent.change(screen.getByLabelText(/workspace name/i), {
target: { value: "Taken" },
});
fireEvent.click(screen.getByRole("button", { name: /create workspace/i }));
await waitFor(() =>
expect(screen.getByText(/already taken/i)).toBeInTheDocument(),
);
});
it("disables submit when slug has invalid format", () => {
renderForm();
fireEvent.change(screen.getByLabelText(/workspace name/i), {
target: { value: "Valid Name" },
});
fireEvent.change(screen.getByLabelText(/workspace url/i), {
target: { value: "Invalid Slug!" },
});
expect(
screen.getByRole("button", { name: /create workspace/i }),
).toBeDisabled();
});
});

View File

@@ -7,20 +7,24 @@ import { Label } from "@multica/ui/components/ui/label";
import { Button } from "@multica/ui/components/ui/button";
import { Card, CardContent } from "@multica/ui/components/ui/card";
import { useCreateWorkspace } from "@multica/core/workspace/mutations";
import type { Workspace } from "@multica/core/types";
import {
WORKSPACE_SLUG_CONFLICT_ERROR,
WORKSPACE_SLUG_FORMAT_ERROR,
WORKSPACE_SLUG_REGEX,
isWorkspaceSlugConflict,
nameToWorkspaceSlug,
} from "../workspace/slug";
} from "./slug";
export function StepWorkspace({ onNext }: { onNext: () => void }) {
export interface CreateWorkspaceFormProps {
onSuccess: (workspace: Workspace) => void;
}
export function CreateWorkspaceForm({ onSuccess }: CreateWorkspaceFormProps) {
const createWorkspace = useCreateWorkspace();
const [name, setName] = useState("");
const [slug, setSlug] = useState("");
const [slugServerError, setSlugServerError] = useState<string | null>(null);
// Track whether the user has manually edited the slug field.
const slugTouched = useRef(false);
const slugValidationError =
@@ -28,7 +32,6 @@ export function StepWorkspace({ onNext }: { onNext: () => void }) {
? WORKSPACE_SLUG_FORMAT_ERROR
: null;
const slugError = slugValidationError ?? slugServerError;
const canSubmit =
name.trim().length > 0 && slug.trim().length > 0 && !slugError;
@@ -51,7 +54,7 @@ export function StepWorkspace({ onNext }: { onNext: () => void }) {
createWorkspace.mutate(
{ name: name.trim(), slug: slug.trim() },
{
onSuccess: () => onNext(),
onSuccess,
onError: (error) => {
if (isWorkspaceSlugConflict(error)) {
setSlugServerError(WORKSPACE_SLUG_CONFLICT_ERROR);
@@ -65,59 +68,49 @@ export function StepWorkspace({ onNext }: { onNext: () => void }) {
};
return (
<div className="flex w-full max-w-md flex-col items-center gap-8">
<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 start building with AI agents.
</p>
</div>
<Card className="w-full">
<CardContent className="space-y-4 pt-6">
<div className="space-y-1.5">
<Label>Workspace Name</Label>
<Card className="w-full">
<CardContent className="space-y-4 pt-6">
<div className="space-y-1.5">
<Label htmlFor="ws-name">Workspace Name</Label>
<Input
id="ws-name"
autoFocus
type="text"
value={name}
onChange={(e) => handleNameChange(e.target.value)}
placeholder="My Workspace"
onKeyDown={(e) => e.key === "Enter" && handleCreate()}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="ws-slug">Workspace URL</Label>
<div className="flex items-center gap-0 rounded-md border bg-background focus-within:ring-2 focus-within:ring-ring">
<span className="pl-3 text-sm text-muted-foreground select-none">
multica.ai/
</span>
<Input
autoFocus
id="ws-slug"
type="text"
value={name}
onChange={(e) => handleNameChange(e.target.value)}
placeholder="My Team"
value={slug}
onChange={(e) => handleSlugChange(e.target.value)}
placeholder="my-workspace"
className="border-0 shadow-none focus-visible:ring-0"
onKeyDown={(e) => e.key === "Enter" && handleCreate()}
/>
</div>
<div className="space-y-1.5">
<Label>Workspace URL</Label>
<div className="flex items-center gap-0 rounded-md border bg-background focus-within:ring-2 focus-within:ring-ring">
<span className="pl-3 text-sm text-muted-foreground select-none">
multica.ai/
</span>
<Input
type="text"
value={slug}
onChange={(e) => handleSlugChange(e.target.value)}
placeholder="my-team"
className="border-0 shadow-none focus-visible:ring-0"
onKeyDown={(e) => e.key === "Enter" && handleCreate()}
/>
</div>
{slugError && (
<p className="text-xs text-destructive">{slugError}</p>
)}
</div>
</CardContent>
</Card>
<Button
className="w-full"
size="lg"
onClick={handleCreate}
disabled={createWorkspace.isPending || !canSubmit}
>
{createWorkspace.isPending ? "Creating..." : "Create Workspace"}
</Button>
</div>
{slugError && (
<p className="text-xs text-destructive">{slugError}</p>
)}
</div>
<Button
className="w-full"
size="lg"
onClick={handleCreate}
disabled={createWorkspace.isPending || !canSubmit}
>
{createWorkspace.isPending ? "Creating..." : "Create workspace"}
</Button>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,32 @@
"use client";
import type { Workspace } from "@multica/core/types";
import { CreateWorkspaceForm } from "./create-workspace-form";
/**
* Full-page shell for the /new-workspace 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.
*/
export function NewWorkspacePage({
onSuccess,
}: {
onSuccess: (workspace: Workspace) => void;
}) {
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>
<CreateWorkspaceForm onSuccess={onSuccess} />
</div>
</div>
);
}

View File

@@ -0,0 +1,33 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { NoAccessPage } from "./no-access-page";
const navigate = vi.fn();
vi.mock("../navigation", () => ({
useNavigation: () => ({ push: navigate, replace: navigate }),
}));
describe("NoAccessPage", () => {
beforeEach(() => navigate.mockReset());
it("renders generic message that doesn't leak existence", () => {
render(<NoAccessPage />);
expect(
screen.getByText(/doesn't exist or you don't have access/i),
).toBeInTheDocument();
});
it("navigates to root on 'Go to my workspaces'", () => {
render(<NoAccessPage />);
fireEvent.click(screen.getByRole("button", { name: /go to my workspaces/i }));
expect(navigate).toHaveBeenCalledWith("/");
});
it("navigates to login on 'Sign in as a different user'", () => {
render(<NoAccessPage />);
fireEvent.click(
screen.getByRole("button", { name: /sign in as a different user/i }),
);
expect(navigate).toHaveBeenCalledWith("/login");
});
});

View File

@@ -0,0 +1,35 @@
"use client";
import { Button } from "@multica/ui/components/ui/button";
import { paths } from "@multica/core/paths";
import { useNavigation } from "../navigation";
/**
* Rendered when the workspace slug in the URL does not resolve to a workspace
* the current user can access. Deliberately doesn't distinguish "workspace
* doesn't exist" from "workspace exists but I'm not a member" — showing
* either would let attackers enumerate workspace slugs.
*/
export function NoAccessPage() {
const nav = useNavigation();
return (
<div className="flex min-h-svh flex-col items-center justify-center gap-6 px-6 text-center">
<div className="space-y-2">
<h1 className="text-2xl font-semibold tracking-tight">
Workspace not available
</h1>
<p className="max-w-md text-muted-foreground">
This workspace doesn't exist or you don't have access.
</p>
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<Button onClick={() => nav.push(paths.root())}>
Go to my workspaces
</Button>
<Button variant="outline" onClick={() => nav.push(paths.login())}>
Sign in as a different user
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import { renderHook } from "@testing-library/react";
import { useWorkspaceSeen } from "./use-workspace-seen";
describe("useWorkspaceSeen", () => {
it("returns false when slug has never resolved", () => {
const { result } = renderHook(() => useWorkspaceSeen("acme", false));
expect(result.current).toBe(false);
});
it("returns true after slug resolved at least once", () => {
const { result, rerender } = renderHook(
({ slug, resolved }) => useWorkspaceSeen(slug, resolved),
{ initialProps: { slug: "acme", resolved: true } },
);
expect(result.current).toBe(true);
// Workspace disappears (e.g. just deleted) — hook still reports "seen"
rerender({ slug: "acme", resolved: false });
expect(result.current).toBe(true);
});
it("remembers multiple slugs independently", () => {
const { result, rerender } = renderHook(
({ slug, resolved }) => useWorkspaceSeen(slug, resolved),
{ initialProps: { slug: "acme", resolved: true } },
);
// Switch to a different resolved slug
rerender({ slug: "beta", resolved: true });
expect(result.current).toBe(true);
// Now check a never-seen slug — should not leak positive
rerender({ slug: "gamma", resolved: false });
expect(result.current).toBe(false);
// Back to "acme" (which we saw first) — still seen
rerender({ slug: "acme", resolved: false });
expect(result.current).toBe(true);
});
it("returns false for undefined slug", () => {
const { result } = renderHook(() => useWorkspaceSeen(undefined, true));
expect(result.current).toBe(false);
});
});

View File

@@ -0,0 +1,29 @@
import { useRef } from "react";
/**
* Tracks workspace slugs that have successfully resolved to a workspace at
* least once during this layout instance's lifetime. Used to distinguish:
*
* - "Active workspace was just removed" (slug seen before, now gone) —
* the caller is typically navigating away (delete/leave mutation, or
* realtime workspace:deleted event). Rendering NoAccessPage during
* that window causes a jarring flash of "Workspace not available"
* before the navigate completes. Return `true` so the layout can
* render null while the navigate resolves.
*
* - "URL points to a workspace I've never had access to" (slug never
* seen) — genuine access-denial case. Return `false` so the layout
* can render NoAccessPage with its recovery buttons.
*
* Scope: one Set per layout instance. If the workspace layout unmounts
* (e.g. desktop tab close), the memory is discarded — correct, since the
* user lost that view anyway.
*/
export function useWorkspaceSeen(
slug: string | undefined,
resolved: boolean,
): boolean {
const seenRef = useRef<Set<string>>(new Set());
if (resolved && slug) seenRef.current.add(slug);
return slug ? seenRef.current.has(slug) : false;
}

View File

@@ -77,7 +77,7 @@ func autoWatchWorkspaces(cmd *cobra.Command) error {
if len(workspaces) == 0 {
var err error
workspaces, err = waitForOnboarding(cmd, client)
workspaces, err = waitForWorkspaceCreation(cmd, client)
if err != nil {
return err
}
@@ -110,9 +110,9 @@ func autoWatchWorkspaces(cmd *cobra.Command) error {
return nil
}
// waitForOnboarding opens the web onboarding page and polls until the user
// creates a workspace, returning the new workspace list.
func waitForOnboarding(cmd *cobra.Command, client *cli.APIClient) ([]struct {
// waitForWorkspaceCreation opens the web workspace-creation page and polls
// until the user creates a workspace, returning the new workspace list.
func waitForWorkspaceCreation(cmd *cobra.Command, client *cli.APIClient) ([]struct {
ID string `json:"id"`
Name string `json:"name"`
}, error) {
@@ -125,13 +125,13 @@ func waitForOnboarding(cmd *cobra.Command, client *cli.APIClient) ([]struct {
return nil, nil
}
onboardingURL := appURL + "/onboarding"
createWorkspaceURL := appURL + "/new-workspace"
fmt.Fprintln(os.Stderr, "\nNo workspaces found. Opening onboarding in your browser...")
if err := openBrowser(onboardingURL); err != nil {
fmt.Fprintln(os.Stderr, "\nNo workspaces found. Opening workspace creation in your browser...")
if err := openBrowser(createWorkspaceURL); err != nil {
fmt.Fprintf(os.Stderr, "Could not open browser automatically.\n")
}
fmt.Fprintf(os.Stderr, "If the browser didn't open, visit:\n %s\n", onboardingURL)
fmt.Fprintf(os.Stderr, "If the browser didn't open, visit:\n %s\n", createWorkspaceURL)
fmt.Fprintln(os.Stderr, "\nWaiting for workspace creation...")
// Poll until a workspace appears or timeout (5 minutes).

View File

@@ -347,7 +347,7 @@ func TestVerifyCodeNewUserHasNoWorkspace(t *testing.T) {
}
readJSON(t, resp, &loginResp)
// New users should have no workspaces (onboarding creates one)
// New users should have no workspaces (/new-workspace creates one)
req, _ := http.NewRequest("GET", testServer.URL+"/api/workspaces", nil)
req.Header.Set("Authorization", "Bearer "+loginResp.Token)
workspacesResp, err := http.DefaultClient.Do(req)

View File

@@ -92,13 +92,15 @@ func (d *Daemon) Run(ctx context.Context) error {
return err
}
// Fetch all user workspaces from the API and register runtimes.
// Fetch all user workspaces from the API and register runtimes for any
// that exist. Zero workspaces is a valid state — a newly-signed-up user
// may start the daemon before creating their first workspace. The
// workspaceSyncLoop below polls every 30s and will register runtimes
// when a workspace appears, so the daemon stays useful as a long-lived
// background process rather than crashing at startup.
if err := d.syncWorkspacesFromAPI(ctx); err != nil {
return err
}
if len(d.allRuntimeIDs()) == 0 {
return fmt.Errorf("no runtimes registered")
}
// Deregister runtimes on shutdown (uses a fresh context since ctx will be cancelled).
defer d.deregisterRuntimes()

View File

@@ -854,7 +854,7 @@ func TestVerifyCodeNewUserHasNoWorkspace(t *testing.T) {
t.Fatalf("GetUserByEmail: %v", err)
}
// New users should have no workspaces (onboarding creates one)
// New users should have no workspaces (/new-workspace creates one)
workspaces, err := testHandler.Queries.ListWorkspaces(ctx, user.ID)
if err != nil {
t.Fatalf("ListWorkspaces: %v", err)

View File

@@ -7,12 +7,13 @@ package handler
// Keep this list in sync with packages/core/paths/reserved-slugs.ts.
var reservedSlugs = map[string]bool{
// Auth + onboarding routes
"login": true,
"logout": true,
"signup": true,
"onboarding": true,
"invite": true,
"auth": true,
"login": true,
"logout": true,
"signup": true,
"onboarding": true,
"new-workspace": true,
"invite": true,
"auth": true,
// Reserved for future platform routes
"api": true,

View File

@@ -12,6 +12,7 @@ func TestCreateWorkspace_RejectsReservedSlug(t *testing.T) {
// Auth + onboarding (covered by migration 043 audit)
"login",
"onboarding",
"new-workspace",
"invite",
"api",
"settings",

View File

@@ -0,0 +1 @@
-- No-op: 046 is an audit-only migration. Nothing to roll back.

View File

@@ -0,0 +1,22 @@
-- Audit existing workspace slugs against the newly-added "new-workspace"
-- reserved slug. The frontend wires /new-workspace as the global workspace
-- creation page (replacing the old /onboarding flow); reserving the slug
-- prevents a workspace from being created with slug = "new-workspace" that
-- would shadow that route.
--
-- Keep this slug in sync with:
-- - server/internal/handler/workspace_reserved_slugs.go
-- - packages/core/paths/reserved-slugs.ts
DO $$
DECLARE
conflict_count INT;
BEGIN
SELECT COUNT(*) INTO conflict_count
FROM workspace
WHERE slug = 'new-workspace';
IF conflict_count > 0 THEN
RAISE EXCEPTION 'Found % workspace(s) with slug "new-workspace" that collides with the global route. Rename or delete before deploying.', conflict_count;
END IF;
END $$;