mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-30 02:51:19 +02:00
Compare commits
9 Commits
agent/lamb
...
feat/works
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0316fda81 | ||
|
|
133080197b | ||
|
|
c2f7dc49f8 | ||
|
|
0a1c82730f | ||
|
|
7dc37e87df | ||
|
|
cf8a9647bb | ||
|
|
d7a8e9041e | ||
|
|
3b7abae5b4 | ||
|
|
7843da0315 |
@@ -48,6 +48,19 @@ function handleDeepLink(url: string): void {
|
||||
if (token && mainWindow) {
|
||||
mainWindow.webContents.send("auth:token", token);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// multica://invite/<invitationId>
|
||||
// Dispatched from the web invite page when the user chooses "Open in
|
||||
// desktop app". The renderer opens the invite overlay — no tab, no
|
||||
// route persistence, so deep-linking the same invite twice stays safe.
|
||||
if (parsed.hostname === "invite") {
|
||||
const id = parsed.pathname.replace(/^\//, "");
|
||||
if (id && mainWindow) {
|
||||
mainWindow.webContents.send("invite:open", decodeURIComponent(id));
|
||||
}
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed URLs
|
||||
|
||||
2
apps/desktop/src/preload/index.d.ts
vendored
2
apps/desktop/src/preload/index.d.ts
vendored
@@ -3,6 +3,8 @@ import { ElectronAPI } from "@electron-toolkit/preload";
|
||||
interface DesktopAPI {
|
||||
/** Listen for auth token delivered via deep link. Returns an unsubscribe function. */
|
||||
onAuthToken: (callback: (token: string) => void) => () => void;
|
||||
/** Listen for invitation IDs delivered via deep link. Returns an unsubscribe function. */
|
||||
onInviteOpen: (callback: (invitationId: string) => void) => () => void;
|
||||
/** Open a URL in the default browser. */
|
||||
openExternal: (url: string) => Promise<void>;
|
||||
/** Hide macOS traffic lights for full-screen modals; restore when false. */
|
||||
|
||||
@@ -11,6 +11,15 @@ const desktopAPI = {
|
||||
ipcRenderer.removeListener("auth:token", handler);
|
||||
};
|
||||
},
|
||||
/** Listen for invitation IDs delivered via deep link */
|
||||
onInviteOpen: (callback: (invitationId: string) => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, invitationId: string) =>
|
||||
callback(invitationId);
|
||||
ipcRenderer.on("invite:open", handler);
|
||||
return () => {
|
||||
ipcRenderer.removeListener("invite:open", handler);
|
||||
};
|
||||
},
|
||||
/** Open a URL in the default browser */
|
||||
openExternal: (url: string) => ipcRenderer.invoke("shell:openExternal", url),
|
||||
/** Toggle immersive mode — hide macOS traffic lights for full-screen modals */
|
||||
|
||||
@@ -11,6 +11,7 @@ import { DesktopLoginPage } from "./pages/login";
|
||||
import { DesktopShell } from "./components/desktop-layout";
|
||||
import { UpdateNotification } from "./components/update-notification";
|
||||
import { useTabStore } from "./stores/tab-store";
|
||||
import { useWindowOverlayStore } from "./stores/window-overlay-store";
|
||||
|
||||
function AppContent() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
@@ -31,6 +32,17 @@ function AppContent() {
|
||||
window.daemonAPI.setTargetApiUrl(DAEMON_TARGET_API_URL);
|
||||
}, []);
|
||||
|
||||
// Listen for invite IDs delivered via deep link (multica://invite/<id>).
|
||||
// We open the overlay regardless of login state — if the user isn't logged
|
||||
// in, InvitePage's queries will fail and render the "not found" state,
|
||||
// which is acceptable; the expected pre-flight happens in the web app
|
||||
// (login + next=/invite/... dance) before the deep link is ever dispatched.
|
||||
useEffect(() => {
|
||||
return window.desktopAPI.onInviteOpen((invitationId) => {
|
||||
useWindowOverlayStore.getState().open({ type: "invite", invitationId });
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Listen for auth token delivered via deep link (multica://auth/callback?token=...).
|
||||
// daemonAPI.syncToken is handled separately by the [user] effect below, which
|
||||
// fires whenever a user logs in (deep link, session restore, account switch).
|
||||
@@ -135,9 +147,14 @@ function AppContent() {
|
||||
const DAEMON_TARGET_API_URL =
|
||||
import.meta.env.VITE_API_URL || "http://localhost:8080";
|
||||
|
||||
// On logout, clear any cached PAT and stop the daemon so that a subsequent
|
||||
// login as a different user never inherits the previous user's credentials.
|
||||
// On logout, wipe desktop-only in-memory state and stop the daemon so that
|
||||
// a subsequent login as a different user never inherits the previous user's
|
||||
// tabs, overlay, or credentials. Zustand persist only writes to localStorage;
|
||||
// useLogout clears the storage key, but the live stores stay populated until
|
||||
// we explicitly reset them here.
|
||||
async function handleDaemonLogout() {
|
||||
useTabStore.getState().reset();
|
||||
useWindowOverlayStore.getState().close();
|
||||
try {
|
||||
await window.daemonAPI.clearToken();
|
||||
} catch {
|
||||
|
||||
@@ -18,6 +18,7 @@ import { getCurrentSlug, subscribeToCurrentSlug } from "@multica/core/platform";
|
||||
import { DesktopNavigationProvider } from "@/platform/navigation";
|
||||
import { TabBar } from "./tab-bar";
|
||||
import { TabContent } from "./tab-content";
|
||||
import { WindowOverlay } from "./window-overlay";
|
||||
|
||||
function SidebarTopBar() {
|
||||
const { canGoBack, canGoForward, goBack, goForward } = useTabHistory();
|
||||
@@ -113,7 +114,8 @@ export function DesktopShell() {
|
||||
mount WorkspaceRouteLayout, which calls setCurrentWorkspace()
|
||||
to populate the slug. The sidebar gates on slug being present
|
||||
to avoid the useRequiredWorkspaceSlug throw. Zero-workspace
|
||||
users are routed to /workspaces/new by IndexRedirect. */}
|
||||
users see the window-level overlay (new-workspace flow)
|
||||
triggered by IndexRedirect, not a route. */}
|
||||
<WorkspaceSlugProvider slug={slug}>
|
||||
<div className="flex h-screen">
|
||||
<SidebarProvider className="flex-1">
|
||||
@@ -132,6 +134,7 @@ export function DesktopShell() {
|
||||
</div>
|
||||
{slug && <ModalRegistry />}
|
||||
{slug && <SearchCommand />}
|
||||
<WindowOverlay />
|
||||
</WorkspaceSlugProvider>
|
||||
</DesktopNavigationProvider>
|
||||
);
|
||||
|
||||
76
apps/desktop/src/renderer/src/components/window-overlay.tsx
Normal file
76
apps/desktop/src/renderer/src/components/window-overlay.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useImmersiveMode } from "@multica/views/platform";
|
||||
import { NewWorkspacePage } from "@multica/views/workspace/new-workspace-page";
|
||||
import { InvitePage } from "@multica/views/invite";
|
||||
import { useNavigation } from "@multica/views/navigation";
|
||||
import { paths } from "@multica/core/paths";
|
||||
import { workspaceListOptions } from "@multica/core/workspace/queries";
|
||||
import { useWindowOverlayStore } from "@/stores/window-overlay-store";
|
||||
|
||||
/**
|
||||
* Window-level transition overlay: renders above the tab system when the
|
||||
* user is in a pre-workspace flow (create workspace, accept invite).
|
||||
*
|
||||
* This component is a thin **platform shell**:
|
||||
* - Hands the window-drag strip and macOS traffic-light hiding
|
||||
* (`useImmersiveMode`) — both are platform-specific, web has neither
|
||||
* - Covers the tab system (fixed inset, z-50) so the Shell's own TabBar
|
||||
* doesn't leak through
|
||||
*
|
||||
* All UX affordances (Back button, Log out button, welcome copy, invite
|
||||
* card) live inside the shared `NewWorkspacePage` / `InvitePage`
|
||||
* components under `packages/views/`, so web and desktop render identical
|
||||
* content. The platform split is: UX in shared code, chrome here.
|
||||
*/
|
||||
export function WindowOverlay() {
|
||||
const overlay = useWindowOverlayStore((s) => s.overlay);
|
||||
if (!overlay) return null;
|
||||
return <WindowOverlayInner />;
|
||||
}
|
||||
|
||||
function WindowOverlayInner() {
|
||||
const overlay = useWindowOverlayStore((s) => s.overlay);
|
||||
const close = useWindowOverlayStore((s) => s.close);
|
||||
const { push } = useNavigation();
|
||||
const { data: wsList = [] } = useQuery(workspaceListOptions());
|
||||
|
||||
useImmersiveMode();
|
||||
|
||||
if (!overlay) return null;
|
||||
|
||||
// Back is only meaningful when there's somewhere to go — i.e. the user
|
||||
// has at least one workspace. Zero-workspace users can only Log out or
|
||||
// complete the flow.
|
||||
const onBack = wsList.length > 0 ? close : undefined;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex flex-col bg-background">
|
||||
{/* Window-drag strip — OS-level. Transparent; covers the area
|
||||
traffic lights would normally sit (they're hidden by
|
||||
useImmersiveMode on macOS). */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute inset-x-0 top-0 h-10 z-10"
|
||||
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="flex-1 min-h-0 overflow-auto"
|
||||
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
|
||||
>
|
||||
{overlay.type === "new-workspace" && (
|
||||
<NewWorkspacePage
|
||||
onSuccess={(ws) => push(paths.workspace(ws.slug).issues())}
|
||||
onBack={onBack}
|
||||
/>
|
||||
)}
|
||||
{overlay.type === "invite" && (
|
||||
<InvitePage
|
||||
invitationId={overlay.invitationId}
|
||||
onBack={onBack}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import { WorkspaceSlugProvider, paths } from "@multica/core/paths";
|
||||
import { workspaceBySlugOptions } from "@multica/core/workspace";
|
||||
import { setCurrentWorkspace } from "@multica/core/platform";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { NoAccessPage } from "@multica/views/workspace/no-access-page";
|
||||
import { useWorkspaceSeen } from "@multica/views/workspace/use-workspace-seen";
|
||||
|
||||
/**
|
||||
@@ -17,9 +16,13 @@ import { useWorkspaceSeen } from "@multica/views/workspace/use-workspace-seen";
|
||||
* guaranteed non-null when called. Two industry-standard identities are
|
||||
* kept distinct: slug (URL / browser) and UUID (API / cache keys).
|
||||
*
|
||||
* If the slug doesn't resolve to any workspace the user has access to,
|
||||
* we render NoAccessPage instead of silently redirecting — users get
|
||||
* explicit feedback for stale bookmarks or revoked access.
|
||||
* Unlike web, desktop never renders a "workspace not available" page: the
|
||||
* app has no URL bar and no clickable links from outside the session, so
|
||||
* landing on an inaccessible slug can only mean stale state (persisted tab
|
||||
* from a previous account) or active eviction (admin removal, realtime
|
||||
* delete). Both cases resolve by bouncing to `/`, where IndexRedirect
|
||||
* picks a valid destination — the next workspace, or the new-workspace
|
||||
* overlay if the user has none.
|
||||
*/
|
||||
export function WorkspaceRouteLayout() {
|
||||
const { workspaceSlug } = useParams<{ workspaceSlug: string }>();
|
||||
@@ -49,11 +52,22 @@ export function WorkspaceRouteLayout() {
|
||||
setCurrentWorkspace(workspaceSlug, workspace.id);
|
||||
}
|
||||
|
||||
// Remember whether this slug has resolved before (see hook docs). Gates
|
||||
// the NoAccessPage render below so active workspace removal doesn't
|
||||
// flash "Workspace not available" before the navigate lands.
|
||||
// Remember whether this slug has resolved before. `useWorkspaceSeen`
|
||||
// gates the auto-heal below so the mid-flight frame of an active-removal
|
||||
// navigation doesn't double-bounce.
|
||||
const hasBeenSeen = useWorkspaceSeen(workspaceSlug, !!workspace);
|
||||
|
||||
// Stale slug (tab persisted from a previous account, or revoked access
|
||||
// that hasn't yet been cleaned up by validateWorkspaceSlugs): auto-heal
|
||||
// to `/`. IndexRedirect takes it from there.
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
if (!listFetched) return;
|
||||
if (workspace) return;
|
||||
if (hasBeenSeen) return; // active eviction in flight — let the other path win
|
||||
navigate("/", { replace: true });
|
||||
}, [user, listFetched, workspace, hasBeenSeen, navigate]);
|
||||
|
||||
if (isAuthLoading) return null;
|
||||
if (!workspaceSlug) return null;
|
||||
// Don't render children until workspace is resolved. useWorkspaceId()
|
||||
@@ -61,15 +75,7 @@ export function WorkspaceRouteLayout() {
|
||||
// unknown — gating here is the single point where that invariant is
|
||||
// enforced, so every descendant can call useWorkspaceId() safely.
|
||||
if (!listFetched) return null;
|
||||
if (!workspace) {
|
||||
// Active workspace just removed (delete/leave/realtime eviction) —
|
||||
// navigate is in flight; hold null briefly instead of flashing
|
||||
// NoAccessPage.
|
||||
if (hasBeenSeen) return null;
|
||||
// Genuinely inaccessible slug (stale bookmark, revoked access, or a
|
||||
// link from a former teammate's workspace) → explicit feedback.
|
||||
return <NoAccessPage />;
|
||||
}
|
||||
if (!workspace) return null; // auto-heal effect above handles the navigation
|
||||
|
||||
return (
|
||||
<WorkspaceSlugProvider slug={workspaceSlug}>
|
||||
|
||||
@@ -6,15 +6,67 @@ import {
|
||||
} from "@multica/views/navigation";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { useTabStore, resolveRouteIcon } from "@/stores/tab-store";
|
||||
import { useWindowOverlayStore } from "@/stores/window-overlay-store";
|
||||
|
||||
const ROOT_PATH = "/";
|
||||
|
||||
// Public web app URL — injected at build time via .env.production. Falls
|
||||
// back to the production host for dev builds so "Copy link" yields a URL
|
||||
// that actually points somewhere a teammate can open.
|
||||
const APP_URL = import.meta.env.VITE_APP_URL || "https://multica.ai";
|
||||
|
||||
/**
|
||||
* Intercept navigation to "transition" paths — pre-workspace flows that on
|
||||
* desktop are rendered as a window-level overlay instead of a tab route.
|
||||
* Returns `true` if the navigation was handled (caller should NOT proceed
|
||||
* with its own router navigation).
|
||||
*
|
||||
* Side effect: when opening the new-workspace overlay, the tab router is
|
||||
* ALSO reset to "/". Rationale — the only way a push lands on
|
||||
* /workspaces/new is that the workspace context is gone (fresh install,
|
||||
* delete-last, leave-last). Leaving the tab parked on a workspace-scoped
|
||||
* path like /acme/settings keeps those components mounted under the
|
||||
* overlay; the next render after the list cache updates would then throw
|
||||
* (useWorkspaceId, useCurrentWorkspace, etc) because the slug no longer
|
||||
* resolves. Resetting to "/" unmounts them synchronously. For invite
|
||||
* overlay we intentionally do NOT reset — invite is orthogonal and the
|
||||
* user's workspace context may still be valid.
|
||||
*
|
||||
* Navigations to any other path implicitly close an open overlay, so the
|
||||
* overlay's lifetime is tied to the user staying in a transition state.
|
||||
*/
|
||||
function tryRouteToOverlay(path: string, router?: DataRouter): boolean {
|
||||
const overlay = useWindowOverlayStore.getState();
|
||||
if (path === "/workspaces/new") {
|
||||
overlay.open({ type: "new-workspace" });
|
||||
if (router && router.state.location.pathname !== ROOT_PATH) {
|
||||
router.navigate(ROOT_PATH, { replace: true });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (path.startsWith("/invite/")) {
|
||||
let id = "";
|
||||
try {
|
||||
id = decodeURIComponent(path.slice("/invite/".length));
|
||||
} catch {
|
||||
// Malformed percent-encoding — treat as no-op so the caller's push
|
||||
// doesn't attempt to navigate into the (now-removed) invite route
|
||||
// and leave the tab stuck on a 404-shaped path.
|
||||
return true;
|
||||
}
|
||||
if (id) {
|
||||
overlay.open({ type: "invite", invitationId: id });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// Any other navigation cancels a live overlay.
|
||||
if (overlay.overlay) overlay.close();
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Root-level navigation provider for components outside the per-tab RouterProviders
|
||||
* (sidebar, search dialog, modals, etc.).
|
||||
* (sidebar, search dialog, modals, WindowOverlay contents, etc.).
|
||||
*
|
||||
* Reads from the active tab's memory router via router.subscribe().
|
||||
* Does NOT use any react-router hooks — it's above all RouterProviders.
|
||||
@@ -25,7 +77,7 @@ export function DesktopNavigationProvider({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const activeTab = useTabStore((s) => s.tabs.find((t) => t.id === s.activeTabId));
|
||||
const [pathname, setPathname] = useState(activeTab?.path ?? "/issues");
|
||||
const [pathname, setPathname] = useState(activeTab?.path ?? "/");
|
||||
|
||||
// Subscribe to the active tab's router for pathname updates
|
||||
useEffect(() => {
|
||||
@@ -47,12 +99,14 @@ export function DesktopNavigationProvider({
|
||||
const tab = useTabStore.getState().tabs.find(
|
||||
(t) => t.id === useTabStore.getState().activeTabId,
|
||||
);
|
||||
if (tryRouteToOverlay(path, tab?.router)) return;
|
||||
tab?.router.navigate(path);
|
||||
},
|
||||
replace: (path: string) => {
|
||||
const tab = useTabStore.getState().tabs.find(
|
||||
(t) => t.id === useTabStore.getState().activeTabId,
|
||||
);
|
||||
if (tryRouteToOverlay(path, tab?.router)) return;
|
||||
tab?.router.navigate(path, { replace: true });
|
||||
},
|
||||
back: () => {
|
||||
@@ -101,8 +155,14 @@ export function TabNavigationProvider({
|
||||
|
||||
const adapter: NavigationAdapter = useMemo(
|
||||
() => ({
|
||||
push: (path: string) => router.navigate(path),
|
||||
replace: (path: string) => router.navigate(path, { replace: true }),
|
||||
push: (path: string) => {
|
||||
if (tryRouteToOverlay(path, router)) return;
|
||||
router.navigate(path);
|
||||
},
|
||||
replace: (path: string) => {
|
||||
if (tryRouteToOverlay(path, router)) return;
|
||||
router.navigate(path, { replace: true });
|
||||
},
|
||||
back: () => router.navigate(-1),
|
||||
pathname: location.pathname,
|
||||
searchParams: new URLSearchParams(location.search),
|
||||
|
||||
@@ -20,14 +20,12 @@ import { DaemonRuntimeCard } from "./components/daemon-runtime-card";
|
||||
import { AgentsPage } from "@multica/views/agents";
|
||||
import { InboxPage } from "@multica/views/inbox";
|
||||
import { SettingsPage } from "@multica/views/settings";
|
||||
import { NewWorkspacePage } from "@multica/views/workspace/new-workspace-page";
|
||||
import { InvitePage } from "@multica/views/invite";
|
||||
import { useNavigation } from "@multica/views/navigation";
|
||||
import { paths } from "@multica/core/paths";
|
||||
import { workspaceListOptions } from "@multica/core/workspace/queries";
|
||||
import { Server } from "lucide-react";
|
||||
import { DaemonSettingsTab } from "./components/daemon-settings-tab";
|
||||
import { WorkspaceRouteLayout } from "./components/workspace-route-layout";
|
||||
import { useWindowOverlayStore } from "./stores/window-overlay-store";
|
||||
|
||||
/**
|
||||
* Sets document.title from the deepest matched route's handle.title.
|
||||
@@ -59,15 +57,6 @@ function PageShell() {
|
||||
);
|
||||
}
|
||||
|
||||
function NewWorkspaceRoute() {
|
||||
const nav = useNavigation();
|
||||
return (
|
||||
<NewWorkspacePage
|
||||
onSuccess={(ws) => nav.push(paths.workspace(ws.slug).issues())}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Root index route: resolves the URL-less `/` path to a concrete destination.
|
||||
*
|
||||
@@ -76,15 +65,32 @@ function NewWorkspaceRoute() {
|
||||
* duplicate fetches across tabs — each tab's memory router hits this
|
||||
* component independently but the query is deduped.
|
||||
*
|
||||
* Sends first-time users without any workspace to /workspaces/new,
|
||||
* everyone else to their first workspace's issues page. Persisted tab
|
||||
* paths that already carry a workspace slug bypass this component
|
||||
* entirely.
|
||||
* Sends users with workspaces to the first workspace's issues page.
|
||||
* Users with zero workspaces get the window-level new-workspace overlay —
|
||||
* desktop treats pre-workspace flows as application state, not tab routes,
|
||||
* so there's no URL to navigate to.
|
||||
*/
|
||||
function IndexRedirect() {
|
||||
const { data: wsList, isFetched } = useQuery(workspaceListOptions());
|
||||
|
||||
// Wait for the query to settle so we don't redirect to /workspaces/new
|
||||
// Bidirectional overlay lifecycle: open the new-workspace overlay when
|
||||
// the user has zero workspaces AND no other overlay is already showing,
|
||||
// and close it when the list becomes non-empty (e.g. a realtime workspace
|
||||
// event arrived while the overlay was open on a different code path).
|
||||
// Only touches the new-workspace type — an active invite overlay is the
|
||||
// user's in-flight task and must not be interrupted.
|
||||
useEffect(() => {
|
||||
if (!isFetched) return;
|
||||
const { overlay, open, close } = useWindowOverlayStore.getState();
|
||||
const isEmpty = !wsList || wsList.length === 0;
|
||||
if (isEmpty) {
|
||||
if (!overlay) open({ type: "new-workspace" });
|
||||
} else if (overlay?.type === "new-workspace") {
|
||||
close();
|
||||
}
|
||||
}, [isFetched, wsList]);
|
||||
|
||||
// Wait for the query to settle so we don't flash the empty-state overlay
|
||||
// on the initial render before the seeded/fetched data arrives.
|
||||
if (!isFetched) return null;
|
||||
|
||||
@@ -92,24 +98,21 @@ function IndexRedirect() {
|
||||
if (firstWorkspace) {
|
||||
return <Navigate to={paths.workspace(firstWorkspace.slug).issues()} replace />;
|
||||
}
|
||||
return <Navigate to={paths.newWorkspace()} replace />;
|
||||
}
|
||||
|
||||
function InviteRoute() {
|
||||
const matches = useMatches();
|
||||
const match = matches.find((m) => (m.params as { id?: string }).id);
|
||||
const id = (match?.params as { id?: string })?.id ?? "";
|
||||
return <InvitePage invitationId={id} />;
|
||||
// Zero workspaces — overlay is opened via the effect above. Tab stays on
|
||||
// `/`; the overlay covers the window. When the user creates a workspace,
|
||||
// onSuccess navigates to the new workspace path, closing the overlay.
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Route definitions shared by all tabs.
|
||||
*
|
||||
* Structure mirrors the web app's [workspaceSlug]/... layout: all dashboard
|
||||
* pages live under /:workspaceSlug, with WorkspaceRouteLayout resolving the
|
||||
* slug to a workspace and syncing side-effects (api client, persist namespace,
|
||||
* Zustand mirror). Global (pre-workspace) routes — workspaces/new and invite —
|
||||
* sit at the top level alongside the workspace wrapper.
|
||||
* Only workspace-scoped ("session") routes live here. Pre-workspace
|
||||
* transitions (create workspace, accept invite) are NOT routes on desktop —
|
||||
* they render as a window-level overlay via WindowOverlay, dispatched by
|
||||
* the navigation adapter's transition-path interception. See
|
||||
* `platform/navigation.tsx` and `stores/window-overlay-store.ts`.
|
||||
*/
|
||||
export const appRoutes: RouteObject[] = [
|
||||
{
|
||||
@@ -118,18 +121,9 @@ export const appRoutes: RouteObject[] = [
|
||||
// Top-level index: no slug yet. `IndexRedirect` reads the workspace
|
||||
// list from React Query cache (seeded by AuthInitializer on reopen
|
||||
// or App.tsx on deep-link login) and bounces to the first
|
||||
// workspace's issues page — or /workspaces/new if the user has none.
|
||||
// workspace's issues page — or opens the new-workspace overlay if
|
||||
// the user has none.
|
||||
{ index: true, element: <IndexRedirect /> },
|
||||
{
|
||||
path: "workspaces/new",
|
||||
element: <NewWorkspaceRoute />,
|
||||
handle: { title: "Create Workspace" },
|
||||
},
|
||||
{
|
||||
path: "invite/:id",
|
||||
element: <InviteRoute />,
|
||||
handle: { title: "Accept Invite" },
|
||||
},
|
||||
{
|
||||
path: ":workspaceSlug",
|
||||
element: <WorkspaceRouteLayout />,
|
||||
|
||||
@@ -13,11 +13,14 @@ describe("sanitizeTabPath", () => {
|
||||
expect(sanitizeTabPath("/")).toBe("/");
|
||||
});
|
||||
|
||||
it("passes through global paths", () => {
|
||||
expect(sanitizeTabPath("/login")).toBe("/login");
|
||||
expect(sanitizeTabPath("/workspaces/new")).toBe("/workspaces/new");
|
||||
expect(sanitizeTabPath("/invite/abc")).toBe("/invite/abc");
|
||||
expect(sanitizeTabPath("/auth/callback")).toBe("/auth/callback");
|
||||
it("rejects transition paths — these are overlay state, not tabs", () => {
|
||||
// Silently rewritten (no warn) since they're legitimate inputs that
|
||||
// the navigation adapter has already redirected to the overlay.
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
expect(sanitizeTabPath("/workspaces/new")).toBe("/");
|
||||
expect(sanitizeTabPath("/invite/abc")).toBe("/");
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
it("passes through valid workspace-scoped paths", () => {
|
||||
@@ -25,7 +28,7 @@ describe("sanitizeTabPath", () => {
|
||||
expect(sanitizeTabPath("/my-team/projects/abc")).toBe("/my-team/projects/abc");
|
||||
});
|
||||
|
||||
it("rejects paths whose first segment is a reserved slug", () => {
|
||||
it("rejects paths whose first segment is a reserved slug (missing workspace prefix)", () => {
|
||||
// A stray "/issues" (pre-refactor leftover, missing workspace prefix)
|
||||
// would be interpreted as workspaceSlug="issues" → NoAccessPage.
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createJSONStorage, persist } from "zustand/middleware";
|
||||
import { arrayMove } from "@dnd-kit/sortable";
|
||||
import { createPersistStorage, defaultStorage } from "@multica/core/platform";
|
||||
import { createSafeId } from "@multica/core/utils";
|
||||
import { isGlobalPath, isReservedSlug } from "@multica/core/paths";
|
||||
import { isReservedSlug } from "@multica/core/paths";
|
||||
import type { DataRouter } from "react-router-dom";
|
||||
import { createTabRouter } from "../routes";
|
||||
|
||||
@@ -44,10 +44,17 @@ interface TabStore {
|
||||
* current user doesn't have access to. Called after login + workspace list
|
||||
* is populated (and on every subsequent list change, e.g. realtime
|
||||
* workspace:deleted). Stale tabs get reset to `/` so IndexRedirect picks
|
||||
* a valid workspace; tabs on global paths (/login, /workspaces/new, etc.)
|
||||
* are untouched.
|
||||
* a valid workspace (or opens the new-workspace overlay if the user now
|
||||
* has none).
|
||||
*/
|
||||
validateWorkspaceSlugs: (validSlugs: Set<string>) => void;
|
||||
/**
|
||||
* Wipe all in-memory tabs and reseed with a single default tab at `/`.
|
||||
* Called on logout so the next user doesn't inherit the previous user's
|
||||
* tab layout — Zustand persist only writes to localStorage; clearing
|
||||
* localStorage alone leaves the live store untouched until app restart.
|
||||
*/
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -67,21 +74,17 @@ const ROUTE_ICONS: Record<string, string> = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve a route icon from a pathname. Title is NOT determined here — it
|
||||
* comes from document.title.
|
||||
* Resolve a route icon from a pathname.
|
||||
*
|
||||
* Path shape after the workspace URL refactor:
|
||||
* - workspace-scoped: `/{workspaceSlug}/{route}/...` → use segment index 1
|
||||
* - global (workspaces/new, invite, auth, login): `/{route}/...` → use segment index 0
|
||||
* Tab paths are always workspace-scoped on desktop: `/{slug}/{route}/...`,
|
||||
* so the route segment lives at index 1. Pre-workspace flows (create,
|
||||
* invite) are rendered by the window overlay, never as tabs.
|
||||
*
|
||||
* `isGlobalPath` is the single source of truth for which prefixes are global.
|
||||
* Title is NOT determined here — it comes from document.title.
|
||||
*/
|
||||
export function resolveRouteIcon(pathname: string): string {
|
||||
const segments = pathname.split("/").filter(Boolean);
|
||||
const routeSegment = isGlobalPath(pathname)
|
||||
? (segments[0] ?? "")
|
||||
: (segments[1] ?? "");
|
||||
return ROUTE_ICONS[routeSegment] ?? "ListTodo";
|
||||
return ROUTE_ICONS[segments[1] ?? ""] ?? "ListTodo";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -105,30 +108,37 @@ function createId(): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Defensive: catch tab paths that were constructed without a workspace slug
|
||||
* (e.g. a hardcoded "/issues" leftover from before the URL refactor). Such
|
||||
* paths would get matched as `workspaceSlug="issues"` by the router and
|
||||
* render NoAccessPage. Sanitize by falling back to "/" (IndexRedirect picks
|
||||
* a valid workspace).
|
||||
* Defensive: catch paths that don't belong in the tab store.
|
||||
*
|
||||
* Passes through:
|
||||
* - "/" and global paths (/login, /workspaces/new, /invite/..., /auth/...)
|
||||
* - workspace-scoped paths whose first segment is not a reserved word
|
||||
* Two kinds of rejects:
|
||||
* 1. **Transition paths** (`/workspaces/new`, `/invite/...`). These are
|
||||
* pre-workspace flows rendered by the window overlay on desktop, not
|
||||
* tab routes. They must never persist as tab state — otherwise the user
|
||||
* reopens the app into a stale form/invite. Navigation to these paths
|
||||
* is intercepted by the navigation adapter, so they shouldn't reach
|
||||
* tab-store in normal flow; this guard catches older persisted state.
|
||||
* 2. **Malformed workspace-scoped paths** like a stray `/issues/abc` that
|
||||
* was constructed without the workspace prefix. Router would interpret
|
||||
* `issues` as a workspace slug → NoAccessPage.
|
||||
*
|
||||
* Rejects (and rewrites to "/"):
|
||||
* - Paths whose first segment is a reserved slug (=/=workspace slug), which
|
||||
* means the caller forgot to prefix the workspace. Logs a warning so the
|
||||
* buggy call site is easy to find.
|
||||
* Both rewrite to `/` so `IndexRedirect` picks a valid destination (or
|
||||
* opens the new-workspace overlay if the user has none).
|
||||
*/
|
||||
export function sanitizeTabPath(path: string): string {
|
||||
if (path === DEFAULT_PATH || isGlobalPath(path)) return path;
|
||||
if (path === DEFAULT_PATH) return path;
|
||||
const firstSegment = path.split("/").filter(Boolean)[0] ?? "";
|
||||
if (isReservedSlug(firstSegment)) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
`[tab-store] tab path "${path}" starts with reserved slug "${firstSegment}" — ` +
|
||||
`caller likely forgot the workspace prefix. Falling back to "/".`,
|
||||
);
|
||||
// Don't log for known transition paths — these are legitimate inputs
|
||||
// at the interception boundary (older persisted state or stale callers).
|
||||
// Log only for truly buggy cases where a view forgot the workspace prefix.
|
||||
const isTransition = path === "/workspaces/new" || path.startsWith("/invite/");
|
||||
if (!isTransition) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
`[tab-store] tab path "${path}" starts with reserved slug "${firstSegment}" — ` +
|
||||
`caller likely forgot the workspace prefix. Falling back to "/".`,
|
||||
);
|
||||
}
|
||||
return DEFAULT_PATH;
|
||||
}
|
||||
return path;
|
||||
@@ -227,15 +237,15 @@ export const useTabStore = create<TabStore>()(
|
||||
const { tabs } = get();
|
||||
let changed = false;
|
||||
const nextTabs = tabs.map((t) => {
|
||||
// Skip tabs on non-workspace-scoped paths — nothing to validate.
|
||||
if (t.path === "/" || isGlobalPath(t.path)) return t;
|
||||
// Root sentinel doesn't carry a slug — nothing to validate.
|
||||
if (t.path === "/") return t;
|
||||
|
||||
const firstSegment = t.path.split("/").filter(Boolean)[0] ?? "";
|
||||
if (validSlugs.has(firstSegment)) return t;
|
||||
|
||||
// Stale slug: dispose the old router and replace with a fresh one
|
||||
// pointing at `/`. IndexRedirect will send the tab to a valid
|
||||
// workspace (or /workspaces/new if the user now has none).
|
||||
// workspace, or open the new-workspace overlay if none remain.
|
||||
changed = true;
|
||||
t.router.dispose();
|
||||
return {
|
||||
@@ -252,6 +262,13 @@ export const useTabStore = create<TabStore>()(
|
||||
if (!changed) return;
|
||||
set({ tabs: nextTabs });
|
||||
},
|
||||
|
||||
reset() {
|
||||
const { tabs } = get();
|
||||
for (const t of tabs) t.router.dispose();
|
||||
const fresh = makeTab(DEFAULT_PATH, "Issues", resolveRouteIcon(DEFAULT_PATH));
|
||||
set({ tabs: [fresh], activeTabId: fresh.id });
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "multica_tabs",
|
||||
|
||||
29
apps/desktop/src/renderer/src/stores/window-overlay-store.ts
Normal file
29
apps/desktop/src/renderer/src/stores/window-overlay-store.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
/**
|
||||
* Window-level transition overlay: pre-workspace flows that are NOT pages
|
||||
* inside a tab. Triggered by navigation-adapter interception, zero-workspace
|
||||
* auto-redirect, or deep link; rendered above the tab system as a full-window
|
||||
* takeover.
|
||||
*
|
||||
* These flows used to be routes (`/workspaces/new`, `/invite/:id`) but on
|
||||
* desktop the URL is invisible to users — routes are an implementation detail
|
||||
* of the tab system. Representing transitions as routes meant tabs tried to
|
||||
* persist them, TabBar rendered on top, and invite deep-linking had no clean
|
||||
* dispatch target. Modeling them as application state removes all three.
|
||||
*/
|
||||
export type WindowOverlay =
|
||||
| { type: "new-workspace" }
|
||||
| { type: "invite"; invitationId: string };
|
||||
|
||||
interface WindowOverlayStore {
|
||||
overlay: WindowOverlay | null;
|
||||
open: (overlay: WindowOverlay) => void;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
export const useWindowOverlayStore = create<WindowOverlayStore>((set) => ({
|
||||
overlay: null,
|
||||
open: (overlay) => set({ overlay }),
|
||||
close: () => set({ overlay: null }),
|
||||
}));
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { paths } from "@multica/core/paths";
|
||||
import { workspaceListOptions } from "@multica/core/workspace/queries";
|
||||
import { InvitePage } from "@multica/views/invite";
|
||||
|
||||
export default function InviteAcceptPage() {
|
||||
@@ -11,6 +13,10 @@ export default function InviteAcceptPage() {
|
||||
const params = useParams<{ id: string }>();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const isLoading = useAuthStore((s) => s.isLoading);
|
||||
const { data: wsList = [] } = useQuery({
|
||||
...workspaceListOptions(),
|
||||
enabled: !!user,
|
||||
});
|
||||
|
||||
// Redirect to login if not authenticated, with a redirect back to this page.
|
||||
useEffect(() => {
|
||||
@@ -23,5 +29,8 @@ export default function InviteAcceptPage() {
|
||||
|
||||
if (isLoading || !user) return null;
|
||||
|
||||
return <InvitePage invitationId={params.id} />;
|
||||
const onBack =
|
||||
wsList.length > 0 ? () => router.push(paths.root()) : undefined;
|
||||
|
||||
return <InvitePage invitationId={params.id} onBack={onBack} />;
|
||||
}
|
||||
|
||||
@@ -2,14 +2,20 @@
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { paths } from "@multica/core/paths";
|
||||
import { workspaceListOptions } from "@multica/core/workspace/queries";
|
||||
import { NewWorkspacePage } from "@multica/views/workspace/new-workspace-page";
|
||||
|
||||
export default function Page() {
|
||||
const router = useRouter();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const isLoading = useAuthStore((s) => s.isLoading);
|
||||
const { data: wsList = [] } = useQuery({
|
||||
...workspaceListOptions(),
|
||||
enabled: !!user,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !user) router.replace(paths.login());
|
||||
@@ -17,9 +23,16 @@ export default function Page() {
|
||||
|
||||
if (isLoading || !user) return null;
|
||||
|
||||
// Back goes to the root path — the workspace layout redirects from
|
||||
// there to the user's default workspace. Only show Back when there's
|
||||
// somewhere to go back to (user already has at least one workspace).
|
||||
const onBack =
|
||||
wsList.length > 0 ? () => router.push(paths.root()) : undefined;
|
||||
|
||||
return (
|
||||
<NewWorkspacePage
|
||||
onSuccess={(ws) => router.push(paths.workspace(ws.slug).issues())}
|
||||
onBack={onBack}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
@@ -11,8 +10,6 @@ import {
|
||||
GeminiCliLogo,
|
||||
OpenClawLogo,
|
||||
OpenCodeLogo,
|
||||
GitHubMark,
|
||||
githubUrl,
|
||||
heroButtonClassName,
|
||||
} from "./shared";
|
||||
|
||||
@@ -66,25 +63,14 @@ export function LandingHero() {
|
||||
</svg>
|
||||
{t.hero.downloadDesktop}
|
||||
</Link>
|
||||
<Link
|
||||
href={githubUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={heroButtonClassName("ghost")}
|
||||
>
|
||||
<GitHubMark className="size-4" />
|
||||
GitHub
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<InstallCommand />
|
||||
</div>
|
||||
|
||||
<div className="mt-10 flex items-center justify-center gap-8">
|
||||
<div className="mt-10 flex flex-wrap items-center justify-center gap-x-6 gap-y-3">
|
||||
<span className="text-[15px] text-white/50">
|
||||
{t.hero.worksWith}
|
||||
</span>
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="flex flex-wrap items-center justify-center gap-x-5 gap-y-3">
|
||||
<div className="flex items-center gap-2.5 text-white/80">
|
||||
<ClaudeCodeLogo className="size-5" />
|
||||
<span className="text-[15px] font-medium">Claude Code</span>
|
||||
@@ -117,64 +103,6 @@ export function LandingHero() {
|
||||
);
|
||||
}
|
||||
|
||||
const INSTALL_COMMAND =
|
||||
"curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash";
|
||||
|
||||
function InstallCommand() {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(INSTALL_COMMAND);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="mx-auto mt-6 max-w-fit">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className="group flex items-center gap-3 rounded-lg border border-white/10 bg-white/5 px-4 py-2.5 font-mono text-[13px] text-white/70 backdrop-blur-sm transition-colors hover:border-white/20 hover:bg-white/8 hover:text-white/90"
|
||||
>
|
||||
<span className="text-white/40">$</span>
|
||||
<span className="select-all">{INSTALL_COMMAND}</span>
|
||||
<span className="ml-1 flex size-5 shrink-0 items-center justify-center text-white/40 transition-colors group-hover:text-white/70">
|
||||
{copied ? (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="size-3.5 text-green-400"
|
||||
>
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="size-3.5"
|
||||
>
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LandingBackdrop() {
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-0">
|
||||
@@ -182,7 +110,6 @@ function LandingBackdrop() {
|
||||
src="/images/landing-bg.jpg"
|
||||
alt=""
|
||||
fill
|
||||
priority
|
||||
className="object-cover object-center"
|
||||
/>
|
||||
</div>
|
||||
@@ -198,6 +125,7 @@ function ProductImage({ alt }: { alt: string }) {
|
||||
alt={alt}
|
||||
width={3532}
|
||||
height={2382}
|
||||
priority
|
||||
className="block h-auto w-full"
|
||||
sizes="(max-width: 1320px) 100vw, 1320px"
|
||||
quality={85}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Zap, Play, Pause, Clock, Plus, Trash2, CheckCircle2, XCircle, Loader2, Pencil } from "lucide-react";
|
||||
import { Zap, Play, Clock, Plus, Trash2, CheckCircle2, XCircle, Loader2, Pencil } from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { autopilotDetailOptions, autopilotRunsOptions } from "@multica/core/autopilots/queries";
|
||||
import {
|
||||
@@ -20,6 +20,7 @@ import { PageHeader } from "../../layout/page-header";
|
||||
import { ActorAvatar } from "../../common/actor-avatar";
|
||||
import { Skeleton } from "@multica/ui/components/ui/skeleton";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { Switch } from "@multica/ui/components/ui/switch";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
@@ -420,9 +421,8 @@ export function AutopilotDetailPage({ autopilotId }: { autopilotId: string }) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleStatus = () => {
|
||||
const newStatus = autopilot.status === "active" ? "paused" : "active";
|
||||
updateAutopilot.mutate({ id: autopilotId, status: newStatus });
|
||||
const handleToggleStatus = (checked: boolean) => {
|
||||
updateAutopilot.mutate({ id: autopilotId, status: checked ? "active" : "paused" });
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -435,27 +435,29 @@ export function AutopilotDetailPage({ autopilotId }: { autopilotId: string }) {
|
||||
</AppLink>
|
||||
<span className="text-muted-foreground">/</span>
|
||||
<h1 className="text-sm font-medium truncate">{autopilot.title}</h1>
|
||||
<span className={cn(
|
||||
"ml-1 inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs font-medium",
|
||||
autopilot.status === "active" ? "bg-emerald-500/10 text-emerald-500" :
|
||||
autopilot.status === "paused" ? "bg-amber-500/10 text-amber-500" :
|
||||
"bg-muted text-muted-foreground",
|
||||
)}>
|
||||
{autopilot.status}
|
||||
</span>
|
||||
<div className="ml-1 flex items-center gap-1.5">
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={autopilot.status === "active"}
|
||||
onCheckedChange={handleToggleStatus}
|
||||
disabled={autopilot.status === "archived"}
|
||||
aria-label={autopilot.status === "active" ? "Pause autopilot" : "Activate autopilot"}
|
||||
/>
|
||||
<span className={cn(
|
||||
"text-xs font-medium capitalize",
|
||||
autopilot.status === "active" ? "text-emerald-500" :
|
||||
autopilot.status === "paused" ? "text-amber-500" :
|
||||
"text-muted-foreground",
|
||||
)}>
|
||||
{autopilot.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="outline" onClick={() => setEditDialogOpen(true)}>
|
||||
<Pencil className="h-3.5 w-3.5 mr-1" />
|
||||
Edit
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={handleToggleStatus}>
|
||||
{autopilot.status === "active" ? (
|
||||
<><Pause className="h-3.5 w-3.5 mr-1" /> Pause</>
|
||||
) : (
|
||||
<><Play className="h-3.5 w-3.5 mr-1" /> Activate</>
|
||||
)}
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleRunNow} disabled={autopilot.status !== "active" || triggerAutopilot.isPending}>
|
||||
<Play className="h-3.5 w-3.5 mr-1" />
|
||||
{triggerAutopilot.isPending ? "Running..." : "Run now"}
|
||||
|
||||
@@ -132,6 +132,18 @@ export async function uploadAndInsertFile(
|
||||
}
|
||||
}
|
||||
|
||||
/** Deduplicate files from the same paste/drop event.
|
||||
* macOS/Chrome can put the same file in the FileList twice. */
|
||||
function dedupFiles(files: FileList): File[] {
|
||||
const seen = new Set<string>();
|
||||
return Array.from(files).filter((file) => {
|
||||
const key = `${file.name}\0${file.size}\0${file.type}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function createFileUploadExtension(
|
||||
onUploadFileRef: React.RefObject<((file: File) => Promise<UploadResult | null>) | undefined>,
|
||||
) {
|
||||
@@ -143,7 +155,7 @@ export function createFileUploadExtension(
|
||||
const handleFiles = async (files: FileList) => {
|
||||
const handler = onUploadFileRef.current;
|
||||
if (!handler) return false;
|
||||
for (const file of Array.from(files)) {
|
||||
for (const file of dedupFiles(files)) {
|
||||
await uploadAndInsertFile(editor, file, handler);
|
||||
}
|
||||
return true;
|
||||
@@ -170,10 +182,10 @@ export function createFileUploadExtension(
|
||||
// Only the first file uses the drop position; subsequent files
|
||||
// append to the end to avoid stale position issues.
|
||||
const dropPos = view.posAtCoords({ left: dragEvent.clientX, top: dragEvent.clientY });
|
||||
const fileArray = Array.from(files);
|
||||
for (let i = 0; i < fileArray.length; i++) {
|
||||
const unique = dedupFiles(files);
|
||||
for (let i = 0; i < unique.length; i++) {
|
||||
const insertPos = i === 0 ? dropPos?.pos : undefined;
|
||||
uploadAndInsertFile(editor, fileArray[i]!, handler, insertPos);
|
||||
uploadAndInsertFile(editor, unique[i]!, handler, insertPos);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "@multica/core/api";
|
||||
import {
|
||||
@@ -9,15 +9,30 @@ import {
|
||||
} from "@multica/core/workspace/queries";
|
||||
import { paths } from "@multica/core/paths";
|
||||
import { useNavigation } from "../navigation";
|
||||
import { useLogout } from "../auth";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { Card, CardContent } from "@multica/ui/components/ui/card";
|
||||
import { Users, Check, X } from "lucide-react";
|
||||
import { ArrowLeft, LogOut, Users, Check, X } from "lucide-react";
|
||||
|
||||
export interface InvitePageProps {
|
||||
invitationId: string;
|
||||
/**
|
||||
* Optional "go back" handler. Caller passes it only when there's a
|
||||
* sensible destination (user has at least one workspace, or arrived
|
||||
* from an in-app flow). Omitted on first-invite/zero-workspace paths
|
||||
* where Back would have nowhere to go — Log out is then the only exit.
|
||||
*/
|
||||
onBack?: () => void;
|
||||
}
|
||||
|
||||
export function InvitePage({ invitationId }: InvitePageProps) {
|
||||
/**
|
||||
* Full-page shell for the "accept invitation" transition. Shared between
|
||||
* web (Next.js route `/invite/[id]`) and desktop (window-overlay).
|
||||
* Top-bar affordances (Back, Log out) live here so both platforms get
|
||||
* identical UX. Platform chrome (window drag region, immersive mode) is
|
||||
* layered on by the desktop overlay; web just renders the page directly.
|
||||
*/
|
||||
export function InvitePage({ invitationId, onBack }: InvitePageProps) {
|
||||
const { push } = useNavigation();
|
||||
const qc = useQueryClient();
|
||||
const [accepting, setAccepting] = useState(false);
|
||||
@@ -78,15 +93,15 @@ export function InvitePage({ invitationId }: InvitePageProps) {
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<InviteShell onBack={onBack}>
|
||||
<div className="text-sm text-muted-foreground">Loading invitation...</div>
|
||||
</div>
|
||||
</InviteShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (fetchError || !invitation) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<InviteShell onBack={onBack}>
|
||||
<Card className="w-full max-w-md">
|
||||
<CardContent className="flex flex-col items-center gap-4 py-12">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-muted">
|
||||
@@ -101,13 +116,13 @@ export function InvitePage({ invitationId }: InvitePageProps) {
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</InviteShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (done === "accepted") {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<InviteShell onBack={onBack}>
|
||||
<Card className="w-full max-w-md">
|
||||
<CardContent className="flex flex-col items-center gap-4 py-12">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/10">
|
||||
@@ -117,13 +132,13 @@ export function InvitePage({ invitationId }: InvitePageProps) {
|
||||
<p className="text-sm text-muted-foreground">Redirecting to workspace...</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</InviteShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (done === "declined") {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<InviteShell onBack={onBack}>
|
||||
<Card className="w-full max-w-md">
|
||||
<CardContent className="flex flex-col items-center gap-4 py-12">
|
||||
<h2 className="text-lg font-semibold">Invitation declined</h2>
|
||||
@@ -133,7 +148,7 @@ export function InvitePage({ invitationId }: InvitePageProps) {
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</InviteShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -141,7 +156,7 @@ export function InvitePage({ invitationId }: InvitePageProps) {
|
||||
const isAlreadyHandled = invitation.status === "accepted" || invitation.status === "declined";
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<InviteShell onBack={onBack}>
|
||||
<Card className="w-full max-w-md">
|
||||
<CardContent className="flex flex-col items-center gap-6 py-12">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-primary/10">
|
||||
@@ -191,6 +206,46 @@ export function InvitePage({ invitationId }: InvitePageProps) {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</InviteShell>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared chrome for every InvitePage render state (loading, error,
|
||||
* default, accepted, declined). Keeps Back + Log out buttons in a
|
||||
* consistent position across all branches and across platforms.
|
||||
*/
|
||||
function InviteShell({
|
||||
onBack,
|
||||
children,
|
||||
}: {
|
||||
onBack?: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const logout = useLogout();
|
||||
return (
|
||||
<div className="relative flex min-h-svh flex-col items-center justify-center bg-background px-6 py-12">
|
||||
{onBack && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute top-12 left-12 text-muted-foreground"
|
||||
onClick={onBack}
|
||||
>
|
||||
<ArrowLeft />
|
||||
Back
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute top-12 right-12 text-muted-foreground hover:text-destructive"
|
||||
onClick={logout}
|
||||
>
|
||||
<LogOut />
|
||||
Log out
|
||||
</Button>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -98,9 +98,24 @@ function DeleteCommentDialog({
|
||||
|
||||
function AttachmentList({ attachments, content, className }: { attachments?: Attachment[]; content?: string; className?: string }) {
|
||||
if (!attachments?.length) return null;
|
||||
// Skip attachments whose URL is already referenced in the markdown content
|
||||
// Skip attachments whose URL is already referenced in the markdown content,
|
||||
// and duplicates of the same file (same name/type/size) that are referenced.
|
||||
const standalone = content
|
||||
? attachments.filter((a) => !content.includes(a.url))
|
||||
? attachments.filter((a) => {
|
||||
if (content.includes(a.url)) return false;
|
||||
// Dedup: if another attachment with the same file identity is already
|
||||
// inline in the content, this is a duplicate upload — skip it.
|
||||
const hasSiblingInContent = attachments.some(
|
||||
(other) =>
|
||||
other.id !== a.id &&
|
||||
other.filename === a.filename &&
|
||||
other.content_type === a.content_type &&
|
||||
other.size_bytes === a.size_bytes &&
|
||||
content.includes(other.url),
|
||||
);
|
||||
if (hasSiblingInContent) return false;
|
||||
return true;
|
||||
})
|
||||
: attachments;
|
||||
if (!standalone.length) return null;
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { useRef, useState, useCallback } from "react";
|
||||
import { ArrowUp, Loader2 } from "lucide-react";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { ContentEditor, type ContentEditorRef, useFileDropZone, FileDropOverlay } from "../../editor";
|
||||
@@ -17,29 +17,34 @@ function CommentInput({ issueId, onSubmit }: CommentInputProps) {
|
||||
const editorRef = useRef<ContentEditorRef>(null);
|
||||
const [isEmpty, setIsEmpty] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [attachmentIds, setAttachmentIds] = useState<string[]>([]);
|
||||
const uploadMapRef = useRef<Map<string, string>>(new Map());
|
||||
const { uploadWithToast } = useFileUpload(api);
|
||||
const { isDragOver, dropZoneProps } = useFileDropZone({
|
||||
onDrop: (files) => files.forEach((f) => editorRef.current?.uploadFile(f)),
|
||||
});
|
||||
|
||||
const handleUpload = async (file: File) => {
|
||||
const handleUpload = useCallback(async (file: File) => {
|
||||
const result = await uploadWithToast(file, { issueId });
|
||||
if (result) {
|
||||
setAttachmentIds((prev) => [...prev, result.id]);
|
||||
uploadMapRef.current.set(result.link, result.id);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
}, [uploadWithToast, issueId]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const content = editorRef.current?.getMarkdown()?.replace(/(\n\s*)+$/, "").trim();
|
||||
if (!content || submitting) return;
|
||||
// Only send attachment IDs for uploads still present in the content.
|
||||
const activeIds: string[] = [];
|
||||
for (const [url, id] of uploadMapRef.current) {
|
||||
if (content.includes(url)) activeIds.push(id);
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await onSubmit(content, attachmentIds.length > 0 ? attachmentIds : undefined);
|
||||
await onSubmit(content, activeIds.length > 0 ? activeIds : undefined);
|
||||
editorRef.current?.clearContent();
|
||||
setIsEmpty(true);
|
||||
setAttachmentIds([]);
|
||||
uploadMapRef.current.clear();
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState, useEffect } from "react";
|
||||
import { useRef, useState, useEffect, useCallback } from "react";
|
||||
import { ArrowUp, Loader2 } from "lucide-react";
|
||||
import { ContentEditor, type ContentEditorRef, useFileDropZone, FileDropOverlay } from "../../editor";
|
||||
import { FileUploadButton } from "@multica/ui/components/common/file-upload-button";
|
||||
@@ -39,7 +39,7 @@ function ReplyInput({
|
||||
const [isEmpty, setIsEmpty] = useState(true);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [attachmentIds, setAttachmentIds] = useState<string[]>([]);
|
||||
const uploadMapRef = useRef<Map<string, string>>(new Map());
|
||||
const { uploadWithToast } = useFileUpload(api);
|
||||
const { isDragOver, dropZoneProps } = useFileDropZone({
|
||||
onDrop: (files) => files.forEach((f) => editorRef.current?.uploadFile(f)),
|
||||
@@ -56,23 +56,28 @@ function ReplyInput({
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
const handleUpload = async (file: File) => {
|
||||
const handleUpload = useCallback(async (file: File) => {
|
||||
const result = await uploadWithToast(file, { issueId });
|
||||
if (result) {
|
||||
setAttachmentIds((prev) => [...prev, result.id]);
|
||||
uploadMapRef.current.set(result.link, result.id);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
}, [uploadWithToast, issueId]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const content = editorRef.current?.getMarkdown()?.replace(/(\n\s*)+$/, "").trim();
|
||||
if (!content || submitting) return;
|
||||
// Only send attachment IDs for uploads still present in the content.
|
||||
const activeIds: string[] = [];
|
||||
for (const [url, id] of uploadMapRef.current) {
|
||||
if (content.includes(url)) activeIds.push(id);
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await onSubmit(content, attachmentIds.length > 0 ? attachmentIds : undefined);
|
||||
await onSubmit(content, activeIds.length > 0 ? activeIds : undefined);
|
||||
editorRef.current?.clearContent();
|
||||
setIsEmpty(true);
|
||||
setAttachmentIds([]);
|
||||
uploadMapRef.current.clear();
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
|
||||
@@ -161,19 +161,21 @@ describe("SearchCommand", () => {
|
||||
expect(screen.queryByPlaceholderText("Type a command or search...")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows Commands by default but hides Pages and Switch Workspace until query", () => {
|
||||
it("shows only New Issue by default and hides Pages / Switch Workspace / low-frequency commands until query", () => {
|
||||
render(<SearchCommand />);
|
||||
|
||||
expect(screen.queryByText("Pages")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Switch Workspace")).not.toBeInTheDocument();
|
||||
// Commands surface by default for discoverability.
|
||||
// Only the primary creation action surfaces on empty query; everything
|
||||
// else (theme, copy, New Project) must be revealed by typing.
|
||||
expect(screen.getByText("Commands")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText((_, el) => el?.textContent === "New Issue" && el?.tagName === "SPAN"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText((_, el) => el?.textContent === "New Project" && el?.tagName === "SPAN"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText("New Project")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Switch to Light Theme")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Switch to Dark Theme")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Use System Theme")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("filters navigation pages by query", async () => {
|
||||
|
||||
@@ -281,9 +281,10 @@ export function SearchCommand() {
|
||||
|
||||
const filteredCommands = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
// No query: surface the whole Commands list so users can discover what's
|
||||
// available without having to guess keywords (Linear/Raycast pattern).
|
||||
if (!q) return commands;
|
||||
// No query: only surface the primary creation action. Other commands
|
||||
// (theme switches, copy actions, New Project) are revealed as the user
|
||||
// types, leaving the empty-state space to Recent.
|
||||
if (!q) return commands.filter((c) => c.key === "new-issue");
|
||||
return commands.filter(
|
||||
(c) =>
|
||||
c.label.toLowerCase().includes(q) ||
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { describe, expect, it, beforeEach, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
// The shared Dialog is a Base UI portal that's awkward to test — strip it to
|
||||
// simple pass-through wrappers. The typed-confirmation logic lives in the
|
||||
// dialog body, not in Base UI, so this doesn't reduce coverage.
|
||||
vi.mock("@multica/ui/components/ui/dialog", () => ({
|
||||
Dialog: ({ children, open }: { children: ReactNode; open: boolean }) =>
|
||||
open ? <div>{children}</div> : null,
|
||||
DialogContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
DialogHeader: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
DialogTitle: ({ children }: { children: ReactNode }) => <h1>{children}</h1>,
|
||||
DialogDescription: ({ children }: { children: ReactNode }) => <p>{children}</p>,
|
||||
DialogFooter: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
import { DeleteWorkspaceDialog } from "./delete-workspace-dialog";
|
||||
|
||||
describe("DeleteWorkspaceDialog", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("disables Delete when input is empty", () => {
|
||||
render(
|
||||
<DeleteWorkspaceDialog
|
||||
workspaceName="acme"
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
onConfirm={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "Delete workspace" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("keeps Delete disabled when input doesn't match (case-sensitive)", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<DeleteWorkspaceDialog
|
||||
workspaceName="acme"
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
onConfirm={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.type(screen.getByRole("textbox"), "ACME"); // wrong case
|
||||
expect(screen.getByRole("button", { name: "Delete workspace" })).toBeDisabled();
|
||||
|
||||
await user.clear(screen.getByRole("textbox"));
|
||||
await user.type(screen.getByRole("textbox"), "acme "); // trailing space
|
||||
expect(screen.getByRole("button", { name: "Delete workspace" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("enables Delete on exact match and calls onConfirm when clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onConfirm = vi.fn();
|
||||
render(
|
||||
<DeleteWorkspaceDialog
|
||||
workspaceName="acme"
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.type(screen.getByRole("textbox"), "acme");
|
||||
const deleteBtn = screen.getByRole("button", { name: "Delete workspace" });
|
||||
expect(deleteBtn).toBeEnabled();
|
||||
|
||||
await user.click(deleteBtn);
|
||||
expect(onConfirm).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("submits on Enter when matched; ignores Enter when not matched", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onConfirm = vi.fn();
|
||||
render(
|
||||
<DeleteWorkspaceDialog
|
||||
workspaceName="acme"
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByRole("textbox");
|
||||
await user.type(input, "acm{Enter}"); // not yet matched
|
||||
expect(onConfirm).not.toHaveBeenCalled();
|
||||
|
||||
await user.type(input, "e{Enter}"); // now matches "acme"
|
||||
expect(onConfirm).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("Cancel closes the dialog and does not call onConfirm", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onOpenChange = vi.fn();
|
||||
const onConfirm = vi.fn();
|
||||
render(
|
||||
<DeleteWorkspaceDialog
|
||||
workspaceName="acme"
|
||||
open
|
||||
onOpenChange={onOpenChange}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
expect(onConfirm).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows loading state and disables both buttons while pending", () => {
|
||||
render(
|
||||
<DeleteWorkspaceDialog
|
||||
workspaceName="acme"
|
||||
loading
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
onConfirm={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "Deleting..." })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Cancel" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("matches names with spaces, unicode, and other non-ASCII characters literally", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onConfirm = vi.fn();
|
||||
render(
|
||||
<DeleteWorkspaceDialog
|
||||
workspaceName="My 团队 🚀"
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
const input = screen.getByRole("textbox");
|
||||
await user.type(input, "My 团队 🚀");
|
||||
expect(screen.getByRole("button", { name: "Delete workspace" })).toBeEnabled();
|
||||
await user.click(screen.getByRole("button", { name: "Delete workspace" }));
|
||||
expect(onConfirm).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("resets the input when the workspace being deleted changes (e.g. rename mid-dialog)", () => {
|
||||
const { rerender } = render(
|
||||
<DeleteWorkspaceDialog
|
||||
workspaceName="old-name"
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
onConfirm={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
const input = screen.getByRole("textbox") as HTMLInputElement;
|
||||
// Simulate user typing (set value directly since userEvent.type would
|
||||
// lose focus across re-renders).
|
||||
input.value = "old-name";
|
||||
rerender(
|
||||
<DeleteWorkspaceDialog
|
||||
workspaceName="new-name"
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
onConfirm={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("textbox")).toHaveValue("");
|
||||
});
|
||||
|
||||
it("clears the input when reopened so prior attempts don't leak", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { rerender } = render(
|
||||
<DeleteWorkspaceDialog
|
||||
workspaceName="acme"
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
onConfirm={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.type(screen.getByRole("textbox"), "partial");
|
||||
expect(screen.getByRole("textbox")).toHaveValue("partial");
|
||||
|
||||
// Simulate close → reopen (e.g. user canceled, then clicked Delete again)
|
||||
rerender(
|
||||
<DeleteWorkspaceDialog
|
||||
workspaceName="acme"
|
||||
open={false}
|
||||
onOpenChange={vi.fn()}
|
||||
onConfirm={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
rerender(
|
||||
<DeleteWorkspaceDialog
|
||||
workspaceName="acme"
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
onConfirm={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("textbox")).toHaveValue("");
|
||||
});
|
||||
});
|
||||
121
packages/views/settings/components/delete-workspace-dialog.tsx
Normal file
121
packages/views/settings/components/delete-workspace-dialog.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@multica/ui/components/ui/dialog";
|
||||
import { Input } from "@multica/ui/components/ui/input";
|
||||
import { Label } from "@multica/ui/components/ui/label";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
|
||||
/**
|
||||
* Typed-confirmation dialog for workspace deletion — GitHub's repo-delete
|
||||
* pattern. The destructive button stays disabled until the user types
|
||||
* the workspace name exactly (case-sensitive, no trimming). The friction
|
||||
* is deliberate: deleting a workspace cascades into every issue, agent,
|
||||
* skill, and run under it, and the backend has no soft-delete.
|
||||
*
|
||||
* Case-sensitive match matches GitHub's pattern and catches the "I
|
||||
* remember the gist of the name but not the casing" misfire. No trim —
|
||||
* leading/trailing whitespace indicates a typo, and silently accepting
|
||||
* it would weaken the whole point of the gate.
|
||||
*
|
||||
* Input value resets whenever the dialog closes so reopening doesn't
|
||||
* leak the previous attempt (which might have been for a different
|
||||
* workspace after a swap).
|
||||
*/
|
||||
export function DeleteWorkspaceDialog({
|
||||
workspaceName,
|
||||
loading = false,
|
||||
open,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
}: {
|
||||
workspaceName: string;
|
||||
loading?: boolean;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onConfirm: () => void;
|
||||
}) {
|
||||
const [typed, setTyped] = useState("");
|
||||
const matched = typed === workspaceName;
|
||||
|
||||
// Reset on close (so reopening for a different workspace doesn't leak
|
||||
// the prior attempt) AND on workspaceName change (if another owner
|
||||
// renames the workspace while the dialog is open, the already-typed
|
||||
// string stops matching and there'd be no feedback explaining why).
|
||||
useEffect(() => {
|
||||
setTyped("");
|
||||
}, [open, workspaceName]);
|
||||
|
||||
const submit = () => {
|
||||
if (!matched || loading) return;
|
||||
onConfirm();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete workspace</DialogTitle>
|
||||
<DialogDescription>
|
||||
This cannot be undone. All issues, agents, and data will be
|
||||
permanently removed.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="delete-workspace-confirm" className="text-xs">
|
||||
To confirm, type{" "}
|
||||
<code className="rounded bg-muted px-1 py-0.5 font-mono text-xs">
|
||||
{workspaceName}
|
||||
</code>{" "}
|
||||
below.
|
||||
</Label>
|
||||
<Input
|
||||
id="delete-workspace-confirm"
|
||||
value={typed}
|
||||
onChange={(e) => setTyped(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}
|
||||
}}
|
||||
placeholder={workspaceName}
|
||||
autoFocus
|
||||
disabled={loading}
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={loading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={submit}
|
||||
disabled={!matched || loading}
|
||||
>
|
||||
{loading ? "Deleting..." : "Delete workspace"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -32,12 +32,13 @@ import { api } from "@multica/core/api";
|
||||
import { paths } from "@multica/core/paths";
|
||||
import type { Workspace } from "@multica/core/types";
|
||||
import { useNavigation } from "../../navigation";
|
||||
import { DeleteWorkspaceDialog } from "./delete-workspace-dialog";
|
||||
|
||||
export function WorkspaceTab() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const workspace = useCurrentWorkspace();
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: members = [] } = useQuery(memberListOptions(wsId));
|
||||
const { data: members = [], isFetched: membersFetched } = useQuery(memberListOptions(wsId));
|
||||
const qc = useQueryClient();
|
||||
const leaveWorkspace = useLeaveWorkspace();
|
||||
const deleteWorkspace = useDeleteWorkspace();
|
||||
@@ -73,10 +74,18 @@ export function WorkspaceTab() {
|
||||
variant?: "destructive";
|
||||
onConfirm: () => Promise<void>;
|
||||
} | null>(null);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
|
||||
const currentMember = members.find((m) => m.user_id === user?.id) ?? null;
|
||||
const canManageWorkspace = currentMember?.role === "owner" || currentMember?.role === "admin";
|
||||
const isOwner = currentMember?.role === "owner";
|
||||
// Mirror the backend invariant (server/internal/handler/workspace.go:569):
|
||||
// a workspace must always have at least one owner, so the sole owner can't
|
||||
// leave. Pre-flight here instead of letting the 400 round-trip become a
|
||||
// confusing toast — disable Leave and tell the user what they need to do.
|
||||
const ownerCount = members.filter((m) => m.role === "owner").length;
|
||||
const isSoleOwner = isOwner && ownerCount <= 1;
|
||||
const isSoleMember = members.length <= 1;
|
||||
|
||||
useEffect(() => {
|
||||
setName(workspace?.name ?? "");
|
||||
@@ -124,24 +133,18 @@ export function WorkspaceTab() {
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteWorkspace = () => {
|
||||
const handleConfirmDelete = async () => {
|
||||
if (!workspace) return;
|
||||
setConfirmAction({
|
||||
title: "Delete workspace",
|
||||
description: `Delete ${workspace.name}? This cannot be undone. All issues, agents, and data will be permanently removed.`,
|
||||
variant: "destructive",
|
||||
onConfirm: async () => {
|
||||
setActionId("delete-workspace");
|
||||
try {
|
||||
await deleteWorkspace.mutateAsync(workspace.id);
|
||||
await navigateAwayFromCurrentWorkspace();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to delete workspace");
|
||||
} finally {
|
||||
setActionId(null);
|
||||
}
|
||||
},
|
||||
});
|
||||
setActionId("delete-workspace");
|
||||
try {
|
||||
await deleteWorkspace.mutateAsync(workspace.id);
|
||||
setDeleteDialogOpen(false);
|
||||
await navigateAwayFromCurrentWorkspace();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to delete workspace");
|
||||
} finally {
|
||||
setActionId(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (!workspace) return null;
|
||||
@@ -211,7 +214,10 @@ export function WorkspaceTab() {
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* Danger Zone */}
|
||||
{/* Danger Zone — gated on the member query settling so the owner-only
|
||||
Delete button and the sole-owner Leave guidance don't flash in
|
||||
after mount. */}
|
||||
{membersFetched && (
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<LogOut className="h-4 w-4 text-muted-foreground" />
|
||||
@@ -224,14 +230,18 @@ export function WorkspaceTab() {
|
||||
<div>
|
||||
<p className="text-sm font-medium">Leave workspace</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Remove yourself from this workspace.
|
||||
{isSoleOwner
|
||||
? isSoleMember
|
||||
? "You're the only member. Delete the workspace to leave."
|
||||
: "You're the only owner. Promote another member to owner first, or delete the workspace."
|
||||
: "Remove yourself from this workspace."}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleLeaveWorkspace}
|
||||
disabled={actionId === "leave"}
|
||||
disabled={actionId === "leave" || isSoleOwner}
|
||||
>
|
||||
{actionId === "leave" ? "Leaving..." : "Leave workspace"}
|
||||
</Button>
|
||||
@@ -248,7 +258,7 @@ export function WorkspaceTab() {
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={handleDeleteWorkspace}
|
||||
onClick={() => setDeleteDialogOpen(true)}
|
||||
disabled={actionId === "delete-workspace"}
|
||||
>
|
||||
{actionId === "delete-workspace" ? "Deleting..." : "Delete workspace"}
|
||||
@@ -258,6 +268,7 @@ export function WorkspaceTab() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<AlertDialog open={!!confirmAction} onOpenChange={(v) => { if (!v) setConfirmAction(null); }}>
|
||||
<AlertDialogContent>
|
||||
@@ -279,6 +290,19 @@ export function WorkspaceTab() {
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<DeleteWorkspaceDialog
|
||||
workspaceName={workspace.name}
|
||||
loading={actionId === "delete-workspace"}
|
||||
open={deleteDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
// Ignore close requests while the delete mutation is in flight
|
||||
// so the user can't accidentally dismiss mid-operation.
|
||||
if (actionId === "delete-workspace" && !open) return;
|
||||
setDeleteDialogOpen(open);
|
||||
}}
|
||||
onConfirm={handleConfirmDelete}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,31 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowLeft, LogOut } from "lucide-react";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import type { Workspace } from "@multica/core/types";
|
||||
import { useLogout } from "../auth";
|
||||
import { CreateWorkspaceForm } from "./create-workspace-form";
|
||||
|
||||
/**
|
||||
* Full-page shell for the /workspaces/new route. Shared between web
|
||||
* (Next.js) and desktop (react-router) so the two apps can't drift.
|
||||
* Callers provide the onSuccess handler — that's the only app-specific
|
||||
* piece, because each app uses its own navigation primitive.
|
||||
* Full-page shell for the "create workspace" transition. Shared between web
|
||||
* (Next.js route `/workspaces/new`) and desktop (window-overlay). The
|
||||
* top-bar affordances — Back (when dismissable) and Log out — live here
|
||||
* so both platforms get identical UX; platform-specific concerns like
|
||||
* window-drag region and macOS traffic-light handling stay in each app's
|
||||
* shell.
|
||||
*
|
||||
* `onBack` is optional: caller passes it only when there's somewhere to go
|
||||
* back to (user has other workspaces, or the flow was entered from an
|
||||
* existing session). On the zero-workspace entry path it's omitted, which
|
||||
* hides Back — Log out is then the only escape.
|
||||
*/
|
||||
export function NewWorkspacePage({
|
||||
onSuccess,
|
||||
onBack,
|
||||
}: {
|
||||
onSuccess: (workspace: Workspace) => void;
|
||||
onBack?: () => void;
|
||||
}) {
|
||||
const logout = useLogout();
|
||||
|
||||
return (
|
||||
<div className="flex min-h-svh flex-col items-center justify-center bg-background px-6 py-12">
|
||||
<div className="flex w-full max-w-md flex-col items-center gap-6">
|
||||
<div className="text-center">
|
||||
<h1 className="text-3xl font-semibold tracking-tight">
|
||||
Welcome to Multica
|
||||
</h1>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
Create your workspace to get started.
|
||||
</p>
|
||||
<div className="relative flex min-h-svh flex-col bg-background px-6 py-12">
|
||||
{onBack && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute top-12 left-12 text-muted-foreground"
|
||||
onClick={onBack}
|
||||
>
|
||||
<ArrowLeft />
|
||||
Back
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute top-12 right-12 text-muted-foreground hover:text-destructive"
|
||||
onClick={logout}
|
||||
>
|
||||
<LogOut />
|
||||
Log out
|
||||
</Button>
|
||||
|
||||
<div className="flex flex-1 flex-col items-center justify-center">
|
||||
<div className="flex w-full max-w-md flex-col items-center gap-6">
|
||||
<div className="text-center">
|
||||
<h1 className="text-3xl font-semibold tracking-tight">
|
||||
Welcome to Multica
|
||||
</h1>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
Create your workspace to get started.
|
||||
</p>
|
||||
</div>
|
||||
<CreateWorkspaceForm onSuccess={onSuccess} />
|
||||
</div>
|
||||
<CreateWorkspaceForm onSuccess={onSuccess} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -20,7 +20,9 @@ func registerSubscriberListeners(bus *events.Bus, queries *db.Queries) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
issue, ok := payload["issue"].(handler.IssueResponse)
|
||||
// Issues created via handler use IssueResponse; autopilot-created issues
|
||||
// use map[string]any (see service/autopilot.go → issueToMap).
|
||||
issue, ok := extractIssueFields(payload["issue"])
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -48,7 +50,7 @@ func registerSubscriberListeners(bus *events.Bus, queries *db.Queries) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
issue, ok := payload["issue"].(handler.IssueResponse)
|
||||
issue, ok := extractIssueFields(payload["issue"])
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -107,6 +109,31 @@ func registerSubscriberListeners(bus *events.Bus, queries *db.Queries) {
|
||||
})
|
||||
}
|
||||
|
||||
// extractIssueFields normalizes an issue payload that may be either a
|
||||
// handler.IssueResponse struct (HTTP handler path) or a map[string]any
|
||||
// (autopilot service path) into a common shape.
|
||||
func extractIssueFields(v any) (handler.IssueResponse, bool) {
|
||||
if issue, ok := v.(handler.IssueResponse); ok {
|
||||
return issue, true
|
||||
}
|
||||
m, ok := v.(map[string]any)
|
||||
if !ok {
|
||||
return handler.IssueResponse{}, false
|
||||
}
|
||||
issue := handler.IssueResponse{}
|
||||
issue.ID, _ = m["id"].(string)
|
||||
issue.WorkspaceID, _ = m["workspace_id"].(string)
|
||||
issue.CreatorType, _ = m["creator_type"].(string)
|
||||
issue.CreatorID, _ = m["creator_id"].(string)
|
||||
issue.AssigneeType, _ = m["assignee_type"].(*string)
|
||||
issue.AssigneeID, _ = m["assignee_id"].(*string)
|
||||
issue.Description, _ = m["description"].(*string)
|
||||
if issue.ID == "" || issue.CreatorID == "" {
|
||||
return handler.IssueResponse{}, false
|
||||
}
|
||||
return issue, true
|
||||
}
|
||||
|
||||
// addSubscriber adds a user as an issue subscriber and publishes a
|
||||
// subscriber:added event for real-time frontend sync.
|
||||
func addSubscriber(bus *events.Bus, queries *db.Queries, workspaceID, issueID, userType, userID, reason string) {
|
||||
|
||||
@@ -357,6 +357,39 @@ func TestSubscriberAddedEventPublished(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Autopilot publishes EventIssueCreated with a map[string]any payload (not handler.IssueResponse).
|
||||
// The listener must still subscribe the creator.
|
||||
func TestSubscriberIssueCreated_AutopilotMapPayload(t *testing.T) {
|
||||
queries := db.New(testPool)
|
||||
bus := events.New()
|
||||
registerSubscriberListeners(bus, queries)
|
||||
|
||||
issueID := createTestIssue(t, testWorkspaceID, testUserID)
|
||||
t.Cleanup(func() { cleanupTestIssue(t, issueID) })
|
||||
|
||||
bus.Publish(events.Event{
|
||||
Type: protocol.EventIssueCreated,
|
||||
WorkspaceID: testWorkspaceID,
|
||||
ActorType: "member",
|
||||
ActorID: testUserID,
|
||||
Payload: map[string]any{
|
||||
"issue": map[string]any{
|
||||
"id": issueID,
|
||||
"workspace_id": testWorkspaceID,
|
||||
"title": "autopilot test issue",
|
||||
"status": "todo",
|
||||
"priority": "medium",
|
||||
"creator_type": "member",
|
||||
"creator_id": testUserID,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if !isSubscribed(t, queries, issueID, "member", testUserID) {
|
||||
t.Fatal("expected creator to be subscribed when autopilot publishes map payload")
|
||||
}
|
||||
}
|
||||
|
||||
// Verify parseUUID is consistent — pgtype.UUID from our local helper should match util.ParseUUID
|
||||
func TestParseUUIDConsistency(t *testing.T) {
|
||||
uuid := "550e8400-e29b-41d4-a716-446655440000"
|
||||
|
||||
Reference in New Issue
Block a user