Compare commits

..

9 Commits

Author SHA1 Message Date
Naiyuan Qing
e0316fda81 review: gate Danger Zone on members fetched + reset typed input on rename
Addresses self-review findings on #1238:

- Previously the Danger Zone rendered immediately with `members = []`, so
  the Delete workspace block (gated on `isOwner`, which is derived from
  an empty members list) would flash in once the query settled. Gate the
  whole section on `membersFetched` so it appears once with correct
  controls.
- Reset `typed` on `workspaceName` change too — if another owner renames
  the workspace while the dialog is open, the already-typed string stops
  matching silently; resetting surfaces the mismatch.
- Added two tests: unicode/special-char names match literally; rename
  mid-dialog clears the input.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 11:05:51 +08:00
Naiyuan Qing
133080197b feat(workspace): typed delete confirm + sole-owner leave preflight
Harden the Danger Zone on the Workspace settings tab.

**Delete workspace** now requires typing the workspace name exactly
(case-sensitive, no trimming) before the destructive button enables —
GitHub's repo-delete pattern. Deleting cascades into every issue,
agent, skill, and run under the workspace with no soft-delete, so the
friction is deliberate. Enter submits only when matched; the input
clears on close so reopening for a different workspace doesn't leak
the prior attempt.

**Leave workspace** now preflights the sole-owner case the backend
already blocks (server/internal/handler/workspace.go:569 — "workspace
must have at least one owner"). Previously the user clicked Confirm
and got an opaque 400 toast; now the Leave button is disabled upfront
with inline guidance that distinguishes:
 - sole member: "Delete the workspace to leave."
 - sole owner with other members: "Promote another member to owner
   first, or delete the workspace."

Both changes live in packages/views/, so web and desktop get the same
Danger Zone treatment automatically.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 10:59:37 +08:00
Naiyuan Qing
c2f7dc49f8 refactor(desktop): model pre-workspace flows as window overlays, not tab routes (#1237)
Previously /workspaces/new and /invite/:id were tab routes on desktop.
That meant the TabBar rendered on top of flows that conceptually aren't
"places" the user sits at — creating a workspace or accepting an invite
is a one-shot transition, not a session. The mismatch also produced
several downstream bugs: tab state persisted these paths, the invite
deep link had no clean dispatch target, and NoAccessPage leaked TabBar
chrome when a workspace slug went stale.

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

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

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

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 10:55:10 +08:00
Naiyuan Qing
0a1c82730f fix(editor): prevent duplicate image attachments showing as file cards (#1231)
Images pasted into comments could produce duplicate attachment records
(macOS/Chrome clipboard provides duplicate File entries), causing
AttachmentList to show a spurious file card below the inline image.

Three-layer fix:
- Dedup clipboard files by name+size+type in paste/drop handlers
- Track upload URL→ID mapping instead of accumulating IDs blindly;
  only send IDs for uploads still present in content on submit
- AttachmentList filters duplicate attachments (same file identity)
  where a sibling is already referenced inline — handles old data

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 10:49:29 +08:00
Jiayuan Zhang
7dc37e87df fix(autopilot): subscribe creator to autopilot-created issues (#1229)
The issue:created subscriber listener type-asserted payload["issue"] to
handler.IssueResponse, but autopilot publishes the issue as
map[string]any (via service.issueToMap). The assertion failed silently,
so no subscribers (including the creator) were ever added to autopilot
issues — meaning creators received no notifications when their
autopilot run produced comments or status changes.

Add an extractIssueFields helper that accepts either format and use it
in both the issue:created and issue:updated listeners. Mirrors the
dual-format pattern already used by the comment:created listener.
2026-04-17 10:05:43 +08:00
Jiayuan Zhang
cf8a9647bb refactor(autopilot): make status a toggle instead of a pause/activate button (#1228)
Replace the Pause/Activate button on the detail page with a Switch next
to the title, showing a colored status label. Flipping it toggles
between active and paused via the existing updateAutopilot mutation.
2026-04-17 10:05:10 +08:00
Jiayuan Zhang
d7a8e9041e refactor(landing): tighten hero — CTAs, install copy, works-with wrap, LCP priority (#1227)
* refactor(landing): tighten hero — CTAs, install copy, works-with wrap, LCP priority

- Drop GitHub button from hero CTAs (already in header) so the primary
  Start / Download Desktop pair is the clear path.
- Split InstallCommand: outer is no longer a <button>, so text selection
  no longer fights with copy. Mobile gets full-width with break-all;
  desktop keeps the compact pill. Copy button has aria-label.
- Fix invalid `hover:bg-white/8` opacity to `hover:bg-white/[0.08]` so
  the install pill's hover background actually renders.
- Add `flex-wrap` and gap-y to the "Works with" row so the label + 5
  logos can stack on small screens instead of overflowing horizontally.
- Move `priority` from the decorative backdrop image onto the product
  hero image (the actual LCP candidate) to stop background bytes from
  starving the foreground.

* refactor(landing): remove install command from hero

Per design feedback, the install command pill is removed from the hero.
The download path now flows through the Download Desktop CTA only;
install instructions remain available in the docs and README.
2026-04-17 09:37:43 +08:00
Jiayuan Zhang
3b7abae5b4 refactor(search): collapse cmd+k empty-state commands to primary action (#1225)
Previously every registered Command (New Issue, New Project, three theme
switches, plus contextual Copy actions on issue pages) surfaced on empty
query, leaving only 3–5 rows for Recent in a 400px panel. Low-frequency
commands (theme, copy, New Project) are now revealed by typing, matching
the progressive-disclosure pattern already used for Pages and Switch
Workspace. Refs MUL-991.
2026-04-17 09:09:55 +08:00
Jiayuan Zhang
7843da0315 refactor(issues): lighten board card styling (#1217)
Slimmer 0.5px border, 12/10 asymmetric padding, and a two-layer soft
drop shadow give the kanban card a more weightless look on the board.
2026-04-17 02:15:24 +08:00
29 changed files with 999 additions and 276 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,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}

View File

@@ -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"}

View File

@@ -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;
},

View File

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

View File

@@ -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;

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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 () => {

View File

@@ -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) ||

View File

@@ -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("");
});
});

View 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>
);
}

View File

@@ -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>
);
}

View File

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

View File

@@ -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) {

View File

@@ -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"