Compare commits

...

1 Commits

Author SHA1 Message Date
Naiyuan Qing
86c88cd974 feat(routing): add workspace slug to dashboard URLs
Add [workspaceSlug] segment to all dashboard routes so that workspace
context is encoded in the URL. This prevents crashes when switching
workspaces while viewing a workspace-specific page (e.g. an issue that
doesn't exist in the target workspace).

Key changes:
- Move all dashboard pages under app/(dashboard)/[workspaceSlug]/
- Add [workspaceSlug]/layout.tsx that validates the URL slug against the
  workspace store and auto-switches or redirects on mismatch
- Add useWorkspacePath() hook and stripWorkspaceSlug() utility
- Update sidebar, issue components, modals, inbox, and login page to
  build URLs with the workspace slug prefix
- Update navigation store to strip slug from persisted lastPath
- Update E2E tests and unit test mocks for new URL structure

Closes MUL-43

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 15:45:32 +08:00
35 changed files with 124 additions and 40 deletions

View File

@@ -26,7 +26,10 @@ vi.mock("@/features/workspace", () => ({
useWorkspaceStore: (selector: (s: any) => any) =>
selector({
hydrateWorkspace: mockHydrateWorkspace,
workspace: { slug: "test" },
}),
useWorkspacePath: () => (path: string) => `/test${path}`,
stripWorkspaceSlug: (pathname: string) => pathname.replace(/^\/[^/]+/, "") || "/",
}));
// Mock api

View File

@@ -51,14 +51,15 @@ function LoginPageContent() {
const sendCode = useAuthStore((s) => s.sendCode);
const verifyCode = useAuthStore((s) => s.verifyCode);
const hydrateWorkspace = useWorkspaceStore((s) => s.hydrateWorkspace);
const workspace = useWorkspaceStore((s) => s.workspace);
const searchParams = useSearchParams();
// Already authenticated — redirect to dashboard
useEffect(() => {
if (!isLoading && user && !searchParams.get("cli_callback")) {
router.replace(searchParams.get("next") || "/issues");
if (!isLoading && user && workspace && !searchParams.get("cli_callback")) {
router.replace(searchParams.get("next") || `/${workspace.slug}/issues`);
}
}, [isLoading, user, router, searchParams]);
}, [isLoading, user, workspace, router, searchParams]);
const [step, setStep] = useState<"email" | "code" | "cli_confirm">("email");
const [email, setEmail] = useState("");
@@ -153,8 +154,9 @@ function LoginPageContent() {
await verifyCode(email, value);
const wsList = await api.listWorkspaces();
await hydrateWorkspace(wsList);
router.push(searchParams.get("next") || "/issues");
const ws = await hydrateWorkspace(wsList);
const defaultPath = ws ? `/${ws.slug}/issues` : "/issues";
router.push(searchParams.get("next") || defaultPath);
} catch (err) {
setError(
err instanceof Error ? err.message : "Invalid or expired code"

View File

@@ -5,7 +5,7 @@ import { useDefaultLayout } from "react-resizable-panels";
import { useInboxStore } from "@/features/inbox";
import { IssueDetail, StatusIcon, PriorityIcon } from "@/features/issues/components";
import { STATUS_CONFIG, PRIORITY_CONFIG } from "@/features/issues/config";
import { useActorName } from "@/features/workspace";
import { useActorName, useWorkspaceStore } from "@/features/workspace";
import { ActorAvatar } from "@/components/common/actor-avatar";
import { toast } from "sonner";
import {
@@ -219,9 +219,11 @@ function InboxListItem({
export default function InboxPage() {
const searchParams = useSearchParams();
const workspaceSlug = useWorkspaceStore((s) => s.workspace?.slug);
const selectedKey = searchParams.get("issue") ?? "";
const setSelectedKey = (key: string) => {
const url = key ? `/inbox?issue=${key}` : "/inbox";
const base = workspaceSlug ? `/${workspaceSlug}/inbox` : "/inbox";
const url = key ? `${base}?issue=${key}` : base;
window.history.replaceState(null, "", url);
};

View File

@@ -60,6 +60,8 @@ vi.mock("@/features/workspace", () => ({
},
getActorAvatarUrl: () => null,
}),
useWorkspacePath: () => (path: string) => `/test${path}`,
stripWorkspaceSlug: (pathname: string) => pathname.replace(/^\/[^/]+/, "") || "/",
}));
// Mock issue store — supply a stable full issue object so storeIssue
@@ -357,6 +359,6 @@ describe("IssueDetailPage", () => {
});
const wsLink = screen.getByText("Test WS");
expect(wsLink.closest("a")).toHaveAttribute("href", "/issues");
expect(wsLink.closest("a")).toHaveAttribute("href", "/test/issues");
});
});

View File

@@ -44,6 +44,8 @@ vi.mock("@/features/workspace", () => ({
{ getState: () => ({ workspace: { id: "ws-1", name: "Test", slug: "test" }, agents: [], members: [] }) },
),
WorkspaceAvatar: ({ name }: { name: string }) => <span>{name.charAt(0)}</span>,
useWorkspacePath: () => (path: string) => `/test${path}`,
stripWorkspaceSlug: (pathname: string) => pathname.replace(/^\/[^/]+/, "") || "/",
}));
// Mock WebSocket context

View File

@@ -0,0 +1,42 @@
"use client";
import { use, useEffect } from "react";
import { useRouter } from "next/navigation";
import { useWorkspaceStore } from "@/features/workspace";
import { MulticaIcon } from "@/components/multica-icon";
export default function WorkspaceSlugLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ workspaceSlug: string }>;
}) {
const { workspaceSlug } = use(params);
const router = useRouter();
const workspace = useWorkspaceStore((s) => s.workspace);
const workspaces = useWorkspaceStore((s) => s.workspaces);
const switchWorkspace = useWorkspaceStore((s) => s.switchWorkspace);
useEffect(() => {
if (!workspace) return;
if (workspace.slug === workspaceSlug) return;
const target = workspaces.find((ws) => ws.slug === workspaceSlug);
if (target) {
switchWorkspace(target.id);
} else {
router.replace(`/${workspace.slug}/issues`);
}
}, [workspaceSlug, workspace, workspaces, switchWorkspace, router]);
if (!workspace || workspace.slug !== workspaceSlug) {
return (
<div className="flex h-full items-center justify-center">
<MulticaIcon className="size-6 animate-pulse" />
</div>
);
}
return children;
}

View File

@@ -16,7 +16,7 @@ import {
SquarePen,
CircleUser,
} from "lucide-react";
import { WorkspaceAvatar } from "@/features/workspace";
import { WorkspaceAvatar, useWorkspacePath, stripWorkspaceSlug } from "@/features/workspace";
import { useIssueDraftStore } from "@/features/issues/stores/draft-store";
import {
Sidebar,
@@ -71,7 +71,7 @@ export function AppSidebar() {
const authLogout = useAuthStore((s) => s.logout);
const workspace = useWorkspaceStore((s) => s.workspace);
const workspaces = useWorkspaceStore((s) => s.workspaces);
const switchWorkspace = useWorkspaceStore((s) => s.switchWorkspace);
const wp = useWorkspacePath();
const unreadCount = useInboxStore((s) => s.unreadCount());
@@ -132,7 +132,7 @@ export function AppSidebar() {
key={ws.id}
onClick={() => {
if (ws.id !== workspace?.id) {
switchWorkspace(ws.id);
router.push(`/${ws.slug}/issues`);
}
}}
>
@@ -174,12 +174,13 @@ export function AppSidebar() {
<SidebarGroupContent>
<SidebarMenu className="gap-0.5">
{primaryNav.map((item) => {
const isActive = pathname === item.href;
const subPath = stripWorkspaceSlug(pathname);
const isActive = subPath === item.href;
return (
<SidebarMenuItem key={item.href}>
<SidebarMenuButton
isActive={isActive}
render={<Link href={item.href} />}
render={<Link href={wp(item.href)} />}
className="text-muted-foreground hover:not-data-active:bg-sidebar-accent/70 data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground"
>
<item.icon />
@@ -201,12 +202,13 @@ export function AppSidebar() {
<SidebarGroupContent>
<SidebarMenu className="gap-0.5">
{workspaceNav.map((item) => {
const isActive = pathname === item.href;
const subPath = stripWorkspaceSlug(pathname);
const isActive = subPath === item.href;
return (
<SidebarMenuItem key={item.href}>
<SidebarMenuButton
isActive={isActive}
render={<Link href={item.href} />}
render={<Link href={wp(item.href)} />}
className="text-muted-foreground hover:not-data-active:bg-sidebar-accent/70 data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground"
>
<item.icon />

View File

@@ -15,6 +15,7 @@ import { PriorityPicker, AssigneePicker, DueDatePicker } from "./pickers";
import { PRIORITY_CONFIG } from "@/features/issues/config";
import type { CardProperties } from "@/features/issues/stores/view-store";
import { useViewStore } from "@/features/issues/stores/view-store-context";
import { useWorkspacePath } from "@/features/workspace";
function formatDate(date: string): string {
return new Date(date).toLocaleDateString("en-US", {
@@ -169,6 +170,7 @@ export const BoardCardContent = memo(function BoardCardContent({
});
export const DraggableBoardCard = memo(function DraggableBoardCard({ issue }: { issue: Issue }) {
const wp = useWorkspacePath();
const {
attributes,
listeners,
@@ -195,7 +197,7 @@ export const DraggableBoardCard = memo(function DraggableBoardCard({ issue }: {
className={isDragging ? "opacity-30" : ""}
>
<Link
href={`/issues/${issue.id}`}
href={wp(`/issues/${issue.id}`)}
className={`group block transition-colors ${isDragging ? "pointer-events-none" : ""}`}
>
<BoardCardContent issue={issue} editable />

View File

@@ -63,7 +63,7 @@ import { CommentInput } from "./comment-input";
import { AgentLiveCard, TaskRunHistory } from "./agent-live-card";
import { api } from "@/shared/api";
import { useAuthStore } from "@/features/auth";
import { useWorkspaceStore, useActorName } from "@/features/workspace";
import { useWorkspaceStore, useActorName, useWorkspacePath } from "@/features/workspace";
import { useIssueStore } from "@/features/issues";
import { useIssueTimeline } from "@/features/issues/hooks/use-issue-timeline";
import { useIssueReactions } from "@/features/issues/hooks/use-issue-reactions";
@@ -169,6 +169,7 @@ interface IssueDetailProps {
export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layoutId = "multica_issue_detail_layout" }: IssueDetailProps) {
const id = issueId;
const router = useRouter();
const wp = useWorkspacePath();
const user = useAuthStore((s) => s.user);
const workspace = useWorkspaceStore((s) => s.workspace);
const members = useWorkspaceStore((s) => s.members);
@@ -256,7 +257,7 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
useIssueStore.getState().removeIssue(issue!.id);
toast.success("Issue deleted");
if (onDelete) onDelete();
else router.push("/issues");
else router.push(wp("/issues"));
} catch {
toast.error("Failed to delete issue");
setDeleting(false);
@@ -276,7 +277,7 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
<div className="flex flex-1 min-h-0 flex-col items-center justify-center gap-3 text-sm text-muted-foreground">
<p>This issue does not exist or has been deleted in this workspace.</p>
{!onDelete && (
<Button variant="outline" size="sm" onClick={() => router.push("/issues")}>
<Button variant="outline" size="sm" onClick={() => router.push(wp("/issues"))}>
<ChevronLeft className="mr-1 h-3.5 w-3.5" />
Back to Issues
</Button>
@@ -296,7 +297,7 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
{workspace && (
<>
<Link
href="/issues"
href={wp("/issues")}
className="text-muted-foreground hover:text-foreground transition-colors truncate shrink-0"
>
{workspace.name}
@@ -322,7 +323,7 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
size="icon-xs"
className="text-muted-foreground"
disabled={!prevIssue}
onClick={() => prevIssue && router.push(`/issues/${prevIssue.id}`)}
onClick={() => prevIssue && router.push(wp(`/issues/${prevIssue.id}`))}
>
<ChevronLeft className="h-4 w-4" />
</Button>
@@ -341,7 +342,7 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
size="icon-xs"
className="text-muted-foreground"
disabled={!nextIssue}
onClick={() => nextIssue && router.push(`/issues/${nextIssue.id}`)}
onClick={() => nextIssue && router.push(wp(`/issues/${nextIssue.id}`))}
>
<ChevronRight className="h-4 w-4" />
</Button>

View File

@@ -2,6 +2,7 @@
import Link from "next/link";
import { useIssueStore } from "@/features/issues/store";
import { useWorkspacePath } from "@/features/workspace";
import { StatusIcon } from "./status-icon";
interface IssueMentionCardProps {
@@ -11,12 +12,13 @@ interface IssueMentionCardProps {
}
export function IssueMentionCard({ issueId, fallbackLabel }: IssueMentionCardProps) {
const wp = useWorkspacePath();
const issue = useIssueStore((s) => s.issues.find((i) => i.id === issueId));
if (!issue) {
return (
<Link
href={`/issues/${issueId}`}
href={wp(`/issues/${issueId}`)}
className="text-primary font-medium cursor-pointer hover:underline"
>
{fallbackLabel ?? issueId.slice(0, 8)}
@@ -26,7 +28,7 @@ export function IssueMentionCard({ issueId, fallbackLabel }: IssueMentionCardPro
return (
<Link
href={`/issues/${issueId}`}
href={wp(`/issues/${issueId}`)}
className="inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-sm hover:bg-accent transition-colors cursor-pointer no-underline"
>
<StatusIcon status={issue.status} className="h-3.5 w-3.5" />

View File

@@ -4,6 +4,7 @@ import { memo } from "react";
import Link from "next/link";
import type { Issue } from "@/shared/types";
import { ActorAvatar } from "@/components/common/actor-avatar";
import { useWorkspacePath } from "@/features/workspace";
import { useIssueSelectionStore } from "@/features/issues/stores/selection-store";
import { PriorityIcon } from "./priority-icon";
@@ -15,6 +16,7 @@ function formatDate(date: string): string {
}
export const ListRow = memo(function ListRow({ issue }: { issue: Issue }) {
const wp = useWorkspacePath();
const selected = useIssueSelectionStore((s) => s.selectedIds.has(issue.id));
const toggle = useIssueSelectionStore((s) => s.toggle);
@@ -39,7 +41,7 @@ export const ListRow = memo(function ListRow({ issue }: { issue: Issue }) {
/>
</div>
<Link
href={`/issues/${issue.id}`}
href={wp(`/issues/${issue.id}`)}
className="flex flex-1 items-center gap-2 min-w-0"
>
<span className="w-16 shrink-0 text-xs text-muted-foreground">

View File

@@ -29,7 +29,7 @@ import { RichTextEditor, type RichTextEditorRef } from "@/components/common/rich
import { TitleEditor } from "@/components/common/title-editor";
import { StatusIcon, PriorityIcon } from "@/features/issues/components";
import { ALL_STATUSES, STATUS_CONFIG, PRIORITY_ORDER, PRIORITY_CONFIG } from "@/features/issues/config";
import { useWorkspaceStore, useActorName } from "@/features/workspace";
import { useWorkspaceStore, useActorName, useWorkspacePath } from "@/features/workspace";
import { useIssueStore } from "@/features/issues";
import { useIssueDraftStore } from "@/features/issues/stores/draft-store";
import { api } from "@/shared/api";
@@ -67,6 +67,7 @@ function PillButton({
export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?: Record<string, unknown> | null }) {
const router = useRouter();
const wp = useWorkspacePath();
const workspaceName = useWorkspaceStore((s) => s.workspace?.name);
const members = useWorkspaceStore((s) => s.members);
const agents = useWorkspaceStore((s) => s.agents);
@@ -150,7 +151,7 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?
type="button"
className="ml-7 mt-2 text-sm text-primary hover:underline cursor-pointer"
onClick={() => {
router.push(`/issues/${issue.id}`);
router.push(wp(`/issues/${issue.id}`));
toast.dismiss(t);
}}
>

View File

@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { ArrowLeft } from "lucide-react";
import { Input } from "@/components/ui/input";
@@ -18,6 +19,7 @@ import { useWorkspaceStore } from "@/features/workspace";
const SLUG_REGEX = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
export function CreateWorkspaceModal({ onClose }: { onClose: () => void }) {
const router = useRouter();
const [name, setName] = useState("");
const [slug, setSlug] = useState("");
const [creating, setCreating] = useState(false);
@@ -43,14 +45,13 @@ export function CreateWorkspaceModal({ onClose }: { onClose: () => void }) {
if (!canSubmit) return;
setCreating(true);
try {
const { createWorkspace, switchWorkspace } =
useWorkspaceStore.getState();
const { createWorkspace } = useWorkspaceStore.getState();
const ws = await createWorkspace({
name: name.trim(),
slug: slug.trim(),
});
onClose();
await switchWorkspace(ws.id);
router.push(`/${ws.slug}/issues`);
} catch {
toast.error("Failed to create workspace");
} finally {

View File

@@ -17,7 +17,9 @@ export const useNavigationStore = create<NavigationState>()(
onPathChange: (path: string) => {
if (!EXCLUDED_PREFIXES.some((prefix) => path.startsWith(prefix))) {
set({ lastPath: path });
// Strip workspace slug — store only the sub-path
const subPath = path.replace(/^\/[^/]+/, "") || "/issues";
set({ lastPath: subPath });
}
},
}),

View File

@@ -40,3 +40,21 @@ export function useActorName() {
return { getMemberName, getAgentName, getActorName, getActorInitials, getActorAvatarUrl };
}
/**
* Returns a function that prefixes a path with the current workspace slug.
* Usage: `const wp = useWorkspacePath(); wp("/issues")` → `"/my-workspace/issues"`
*/
export function useWorkspacePath() {
const slug = useWorkspaceStore((s) => s.workspace?.slug);
return (path: string) => (slug ? `/${slug}${path}` : path);
}
/**
* Strip the first path segment (workspace slug) from a pathname.
* `"/my-workspace/issues"` → `"/issues"`
* `"/my-workspace"` → `"/"`
*/
export function stripWorkspaceSlug(pathname: string): string {
return pathname.replace(/^\/[^/]+/, "") || "/";
}

View File

@@ -1,3 +1,3 @@
export { useWorkspaceStore } from "./store";
export { useActorName } from "./hooks";
export { useActorName, useWorkspacePath, stripWorkspaceSlug } from "./hooks";
export { WorkspaceAvatar } from "./components/workspace-avatar";

View File

@@ -1,5 +1,5 @@
import { test, expect } from "@playwright/test";
import { loginAsDefault, openWorkspaceMenu } from "./helpers";
import { loginAsDefault, openWorkspaceMenu, DEFAULT_E2E_WORKSPACE } from "./helpers";
test.describe("Authentication", () => {
test("login page renders correctly", async ({ page }) => {
@@ -27,7 +27,7 @@ test.describe("Authentication", () => {
localStorage.removeItem("multica_workspace_id");
});
await page.goto("/issues");
await page.goto(`/${DEFAULT_E2E_WORKSPACE}/issues`);
await page.waitForURL("**/login", { timeout: 10000 });
});

View File

@@ -17,7 +17,7 @@ test.describe("Comments", () => {
test("can add a comment on an issue", async ({ page }) => {
// Wait for issues to load and click first one
const issueLink = page.locator('a[href^="/issues/"]').first();
const issueLink = page.locator('a[href*="/issues/"]').first();
await expect(issueLink).toBeVisible({ timeout: 5000 });
await issueLink.click();
await page.waitForURL(/\/issues\/[\w-]+/);
@@ -42,7 +42,7 @@ test.describe("Comments", () => {
});
test("comment submit button is disabled when empty", async ({ page }) => {
const issueLink = page.locator('a[href^="/issues/"]').first();
const issueLink = page.locator('a[href*="/issues/"]').first();
await expect(issueLink).toBeVisible({ timeout: 5000 });
await issueLink.click();
await page.waitForURL(/\/issues\/[\w-]+/);

View File

@@ -3,7 +3,7 @@ import { TestApiClient } from "./fixtures";
const DEFAULT_E2E_NAME = "E2E User";
const DEFAULT_E2E_EMAIL = "e2e@multica.ai";
const DEFAULT_E2E_WORKSPACE = "e2e-workspace";
export const DEFAULT_E2E_WORKSPACE = "e2e-workspace";
/**
* Log in as the default E2E user and ensure the workspace exists first.
@@ -20,8 +20,8 @@ export async function loginAsDefault(page: Page) {
await page.evaluate((t) => {
localStorage.setItem("multica_token", t);
}, token);
await page.goto("/issues");
await page.waitForURL("**/issues", { timeout: 10000 });
await page.goto(`/${DEFAULT_E2E_WORKSPACE}/issues`);
await page.waitForURL(`**/${DEFAULT_E2E_WORKSPACE}/issues`, { timeout: 10000 });
}
/**

View File

@@ -57,7 +57,7 @@ test.describe("Issues", () => {
await expect(page.locator("text=All Issues")).toBeVisible();
// Navigate to the issue detail
const issueLink = page.locator(`a[href="/issues/${issue.id}"]`);
const issueLink = page.locator(`a[href$="/issues/${issue.id}"]`);
await expect(issueLink).toBeVisible({ timeout: 5000 });
await issueLink.click();