mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-19 12:48:56 +02:00
Compare commits
13 Commits
agent/lamb
...
chore/remo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bcdddd895a | ||
|
|
1e05eee2dc | ||
|
|
ca8ef0e1e7 | ||
|
|
230f20df0b | ||
|
|
f3ace7951f | ||
|
|
2f40b7655d | ||
|
|
ccd6146b79 | ||
|
|
21d2245ecf | ||
|
|
9e752aafa8 | ||
|
|
599235ef88 | ||
|
|
00a9d6377c | ||
|
|
b57092d594 | ||
|
|
874ae30e5d |
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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}</>;
|
||||
}
|
||||
@@ -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}>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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())}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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}>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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());
|
||||
|
||||
1315
docs/plans/2026-04-16-remove-onboarding-and-fix-daemon-bootstrap.md
Normal file
1315
docs/plans/2026-04-16-remove-onboarding-and-fix-daemon-bootstrap.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
];
|
||||
|
||||
@@ -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/",
|
||||
|
||||
@@ -66,7 +66,7 @@ describe("global path / reserved slug consistency", () => {
|
||||
"/login",
|
||||
"/logout",
|
||||
"/signup",
|
||||
"/onboarding",
|
||||
"/new-workspace",
|
||||
"/invite/",
|
||||
"/auth/",
|
||||
];
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -8,6 +8,7 @@ export const RESERVED_SLUGS = new Set([
|
||||
"logout",
|
||||
"signup",
|
||||
"onboarding",
|
||||
"new-workspace",
|
||||
"invite",
|
||||
"auth",
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
export { OnboardingWizard } from "./onboarding-wizard";
|
||||
export type { OnboardingWizardProps } from "./onboarding-wizard";
|
||||
export { StepWorkspace } from "./step-workspace";
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 2–3 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'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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
85
packages/views/workspace/create-workspace-form.test.tsx
Normal file
85
packages/views/workspace/create-workspace-form.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
32
packages/views/workspace/new-workspace-page.tsx
Normal file
32
packages/views/workspace/new-workspace-page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
33
packages/views/workspace/no-access-page.test.tsx
Normal file
33
packages/views/workspace/no-access-page.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
35
packages/views/workspace/no-access-page.tsx
Normal file
35
packages/views/workspace/no-access-page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
45
packages/views/workspace/use-workspace-seen.test.ts
Normal file
45
packages/views/workspace/use-workspace-seen.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
29
packages/views/workspace/use-workspace-seen.ts
Normal file
29
packages/views/workspace/use-workspace-seen.ts
Normal 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;
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -12,6 +12,7 @@ func TestCreateWorkspace_RejectsReservedSlug(t *testing.T) {
|
||||
// Auth + onboarding (covered by migration 043 audit)
|
||||
"login",
|
||||
"onboarding",
|
||||
"new-workspace",
|
||||
"invite",
|
||||
"api",
|
||||
"settings",
|
||||
|
||||
1
server/migrations/046_audit_new_workspace_slug.down.sql
Normal file
1
server/migrations/046_audit_new_workspace_slug.down.sql
Normal file
@@ -0,0 +1 @@
|
||||
-- No-op: 046 is an audit-only migration. Nothing to roll back.
|
||||
22
server/migrations/046_audit_new_workspace_slug.up.sql
Normal file
22
server/migrations/046_audit_new_workspace_slug.up.sql
Normal 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 $$;
|
||||
Reference in New Issue
Block a user