Merge pull request #236 from multica-ai/forrestchang/analyze-next-steps

feat(web): add multi-workspace switching and creation
This commit is contained in:
Jiayuan Zhang 2026-03-23 11:27:08 +08:00 committed by GitHub
commit 4a273bcb83
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 290 additions and 10 deletions

View file

@ -2,7 +2,7 @@
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useEffect, useState, useCallback } from "react";
import { useEffect, useState } from "react";
import {
Inbox,
ListTodo,
@ -12,11 +12,10 @@ import {
Settings,
LogOut,
Plus,
Check,
} from "lucide-react";
import { MulticaIcon } from "@multica/ui/components/multica-icon";
import { useAuth } from "../../lib/auth-context";
import type { Workspace } from "@multica/types";
import { api } from "../../lib/api";
const navItems = [
{ href: "/inbox", label: "Inbox", icon: Inbox },
@ -32,8 +31,33 @@ export default function DashboardLayout({
}) {
const pathname = usePathname();
const router = useRouter();
const { user, workspace, isLoading, logout } = useAuth();
const { user, workspace, workspaces, isLoading, logout, switchWorkspace, createWorkspace } = useAuth();
const [showMenu, setShowMenu] = useState(false);
const [showCreateDialog, setShowCreateDialog] = useState(false);
const [newName, setNewName] = useState("");
const [newSlug, setNewSlug] = useState("");
const [creating, setCreating] = useState(false);
const handleNameChange = (value: string) => {
setNewName(value);
setNewSlug(value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""));
};
const handleCreateWorkspace = async () => {
if (!newName.trim() || !newSlug.trim()) return;
setCreating(true);
try {
const ws = await createWorkspace({ name: newName.trim(), slug: newSlug.trim() });
setShowCreateDialog(false);
setNewName("");
setNewSlug("");
await switchWorkspace(ws.id);
} catch (err) {
console.error("Failed to create workspace:", err);
} finally {
setCreating(false);
}
};
useEffect(() => {
if (!isLoading && !user) {
@ -79,6 +103,40 @@ export default function DashboardLayout({
{user.email}
</div>
<div className="my-1 border-t" />
<div className="px-2 py-1 text-xs font-medium text-muted-foreground">
Workspaces
</div>
{workspaces.map((ws) => (
<button
key={ws.id}
onClick={() => {
setShowMenu(false);
if (ws.id !== workspace?.id) {
switchWorkspace(ws.id);
}
}}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent"
>
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded bg-muted text-[10px] font-semibold">
{ws.name.charAt(0).toUpperCase()}
</span>
<span className="flex-1 truncate text-left">{ws.name}</span>
{ws.id === workspace?.id && (
<Check className="h-3.5 w-3.5 text-primary" />
)}
</button>
))}
<button
onClick={() => {
setShowMenu(false);
setShowCreateDialog(true);
}}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm text-muted-foreground hover:bg-accent"
>
<Plus className="h-3.5 w-3.5" />
Create workspace
</button>
<div className="my-1 border-t" />
<Link
href="/settings"
onClick={() => setShowMenu(false)}
@ -148,6 +206,62 @@ export default function DashboardLayout({
{children}
</main>
</div>
{/* Create Workspace Dialog */}
{showCreateDialog && (
<>
<div
className="fixed inset-0 z-50 bg-black/10 backdrop-blur-xs"
onClick={() => setShowCreateDialog(false)}
/>
<div className="fixed top-1/2 left-1/2 z-50 w-full max-w-lg -translate-x-1/2 -translate-y-1/2 rounded-xl bg-background p-6 shadow-lg ring-1 ring-foreground/10">
<div className="flex flex-col gap-1.5">
<h2 className="text-lg font-semibold leading-none">Create workspace</h2>
<p className="text-sm text-muted-foreground">
Create a new workspace for your team.
</p>
</div>
<div className="mt-4 space-y-3">
<div>
<label className="text-xs font-medium text-muted-foreground">Name</label>
<input
autoFocus
type="text"
value={newName}
onChange={(e) => handleNameChange(e.target.value)}
placeholder="My Workspace"
className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
<div>
<label className="text-xs font-medium text-muted-foreground">Slug</label>
<input
type="text"
value={newSlug}
onChange={(e) => setNewSlug(e.target.value)}
placeholder="my-workspace"
className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
</div>
<div className="mt-4 flex justify-end gap-2">
<button
onClick={() => setShowCreateDialog(false)}
className="rounded-md px-3 py-1.5 text-sm hover:bg-accent"
>
Cancel
</button>
<button
onClick={handleCreateWorkspace}
disabled={creating || !newName.trim() || !newSlug.trim()}
className="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{creating ? "Creating..." : "Create"}
</button>
</div>
</div>
</>
)}
</div>
);
}

View file

@ -17,6 +17,7 @@ const mockApi = vi.hoisted(() => ({
listWorkspaces: vi.fn(),
listMembers: vi.fn(),
listAgents: vi.fn(),
createWorkspace: vi.fn(),
}));
vi.mock("./api", () => ({
@ -305,4 +306,134 @@ describe("AuthContext", () => {
expect(result.current.user).toBeNull();
expect(localStorage.getItem("multica_token")).toBeNull();
});
it("initialization prefers stored workspace ID from list", async () => {
const mockWorkspace2: Workspace = {
id: "ws-2",
name: "Second WS",
slug: "second",
description: null,
settings: {},
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
};
localStorage.setItem("multica_token", "stored-token");
localStorage.setItem("multica_workspace_id", "ws-2");
mockApi.getMe.mockResolvedValueOnce(mockUser);
mockApi.listWorkspaces.mockResolvedValueOnce([mockWorkspace, mockWorkspace2]);
mockApi.listMembers.mockResolvedValueOnce(mockMembers);
mockApi.listAgents.mockResolvedValueOnce(mockAgents);
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.workspace).toEqual(mockWorkspace2);
expect(result.current.workspaces).toHaveLength(2);
});
it("initialization falls back to first workspace when stored ID not in list", async () => {
localStorage.setItem("multica_token", "stored-token");
localStorage.setItem("multica_workspace_id", "ws-deleted");
mockApi.getMe.mockResolvedValueOnce(mockUser);
mockApi.listWorkspaces.mockResolvedValueOnce([mockWorkspace]);
mockApi.listMembers.mockResolvedValueOnce(mockMembers);
mockApi.listAgents.mockResolvedValueOnce(mockAgents);
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.workspace).toEqual(mockWorkspace);
});
it("createWorkspace calls API and adds to workspaces list", async () => {
mockApi.login.mockResolvedValueOnce({ token: "test-jwt", user: mockUser });
mockApi.listWorkspaces.mockResolvedValueOnce([mockWorkspace]);
mockApi.listMembers.mockResolvedValueOnce(mockMembers);
mockApi.listAgents.mockResolvedValueOnce(mockAgents);
const newWs: Workspace = {
id: "ws-new",
name: "New WS",
slug: "new-ws",
description: null,
settings: {},
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
};
mockApi.createWorkspace.mockResolvedValueOnce(newWs);
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
await act(async () => {
await result.current.login("test@multica.ai");
});
let created: Workspace | undefined;
await act(async () => {
created = await result.current.createWorkspace({ name: "New WS", slug: "new-ws" });
});
expect(mockApi.createWorkspace).toHaveBeenCalledWith({ name: "New WS", slug: "new-ws" });
expect(created).toEqual(newWs);
expect(result.current.workspaces).toHaveLength(2);
expect(result.current.workspaces[1]).toEqual(newWs);
});
it("switchWorkspace updates context and calls setWorkspaceId", async () => {
const reloadMock = vi.fn();
Object.defineProperty(window, "location", {
value: { ...window.location, reload: reloadMock },
writable: true,
});
const mockWorkspace2: Workspace = {
id: "ws-2",
name: "Second WS",
slug: "second",
description: null,
settings: {},
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
};
mockApi.login.mockResolvedValueOnce({ token: "test-jwt", user: mockUser });
mockApi.listWorkspaces.mockResolvedValueOnce([mockWorkspace, mockWorkspace2]);
mockApi.listMembers.mockResolvedValueOnce(mockMembers);
mockApi.listAgents.mockResolvedValueOnce(mockAgents);
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
await act(async () => {
await result.current.login("test@multica.ai");
});
// Setup mocks for the switch
mockApi.listMembers.mockResolvedValueOnce([]);
mockApi.listAgents.mockResolvedValueOnce([]);
await act(async () => {
await result.current.switchWorkspace("ws-2");
});
expect(mockApi.setWorkspaceId).toHaveBeenCalledWith("ws-2");
expect(localStorage.getItem("multica_workspace_id")).toBe("ws-2");
expect(reloadMock).toHaveBeenCalled();
});
});

View file

@ -15,11 +15,14 @@ import { api } from "./api";
interface AuthContextValue {
user: User | null;
workspace: Workspace | null;
workspaces: Workspace[];
members: MemberWithUser[];
agents: Agent[];
isLoading: boolean;
login: (email: string, name?: string) => Promise<void>;
logout: () => void;
switchWorkspace: (workspaceId: string) => Promise<void>;
createWorkspace: (data: { name: string; slug: string; description?: string }) => Promise<Workspace>;
updateWorkspace: (ws: Workspace) => void;
refreshMembers: () => Promise<void>;
refreshAgents: () => Promise<void>;
@ -35,6 +38,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [workspace, setWorkspace] = useState<Workspace | null>(null);
const [members, setMembers] = useState<MemberWithUser[]>([]);
const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
const [agents, setAgents] = useState<Agent[]>([]);
const [isLoading, setIsLoading] = useState(true);
const router = useRouter();
@ -56,9 +60,11 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const me = await api.getMe();
setUser(me);
const workspaces = await api.listWorkspaces();
if (workspaces.length > 0) {
const ws = workspaces[0]!;
const wsList = await api.listWorkspaces();
setWorkspaces(wsList);
if (wsList.length > 0) {
const stored = wsId ? wsList.find(w => w.id === wsId) : null;
const ws = stored ?? wsList[0]!;
setWorkspace(ws);
api.setWorkspaceId(ws.id);
localStorage.setItem("multica_workspace_id", ws.id);
@ -87,9 +93,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setUser(u);
// Load workspace
const workspaces = await api.listWorkspaces();
if (workspaces.length > 0) {
const ws = workspaces[0]!;
const wsList = await api.listWorkspaces();
setWorkspaces(wsList);
if (wsList.length > 0) {
const ws = wsList[0]!;
setWorkspace(ws);
api.setWorkspaceId(ws.id);
localStorage.setItem("multica_workspace_id", ws.id);
@ -110,11 +117,36 @@ export function AuthProvider({ children }: { children: ReactNode }) {
localStorage.removeItem("multica_workspace_id");
setUser(null);
setWorkspace(null);
setWorkspaces([]);
setMembers([]);
setAgents([]);
router.push("/login");
}, [router]);
const switchWorkspace = useCallback(async (workspaceId: string) => {
const ws = workspaces.find(w => w.id === workspaceId);
if (!ws) return;
api.setWorkspaceId(ws.id);
localStorage.setItem("multica_workspace_id", ws.id);
setWorkspace(ws);
const [m, a] = await Promise.all([
api.listMembers(ws.id),
api.listAgents({ workspace_id: ws.id }),
]);
setMembers(m);
setAgents(a);
window.location.reload();
}, [workspaces]);
const createNewWorkspace = useCallback(async (data: { name: string; slug: string; description?: string }) => {
const ws = await api.createWorkspace(data);
setWorkspaces(prev => [...prev, ws]);
return ws;
}, []);
const updateWorkspaceState = useCallback((ws: Workspace) => {
setWorkspace(ws);
}, []);
@ -174,11 +206,14 @@ export function AuthProvider({ children }: { children: ReactNode }) {
value={{
user,
workspace,
workspaces,
members,
agents,
isLoading,
login,
logout,
switchWorkspace,
createWorkspace: createNewWorkspace,
updateWorkspace: updateWorkspaceState,
refreshMembers,
refreshAgents,