diff --git a/apps/web/app/(dashboard)/layout.tsx b/apps/web/app/(dashboard)/layout.tsx
index cd03bcf4..e902b5e3 100644
--- a/apps/web/app/(dashboard)/layout.tsx
+++ b/apps/web/app/(dashboard)/layout.tsx
@@ -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}
+
+ Workspaces
+
+ {workspaces.map((ws) => (
+
+ ))}
+
+
setShowMenu(false)}
@@ -148,6 +206,62 @@ export default function DashboardLayout({
{children}
+
+ {/* Create Workspace Dialog */}
+ {showCreateDialog && (
+ <>
+ setShowCreateDialog(false)}
+ />
+
+
+
Create workspace
+
+ Create a new workspace for your team.
+
+
+
+
+
+
+
+
+ >
+ )}
);
}
diff --git a/apps/web/lib/auth-context.test.tsx b/apps/web/lib/auth-context.test.tsx
index 8d3d8e3b..cc918a35 100644
--- a/apps/web/lib/auth-context.test.tsx
+++ b/apps/web/lib/auth-context.test.tsx
@@ -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();
+ });
});
diff --git a/apps/web/lib/auth-context.tsx b/apps/web/lib/auth-context.tsx
index 72eb2805..18333b6e 100644
--- a/apps/web/lib/auth-context.tsx
+++ b/apps/web/lib/auth-context.tsx
@@ -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;
logout: () => void;
+ switchWorkspace: (workspaceId: string) => Promise;
+ createWorkspace: (data: { name: string; slug: string; description?: string }) => Promise;
updateWorkspace: (ws: Workspace) => void;
refreshMembers: () => Promise;
refreshAgents: () => Promise;
@@ -35,6 +38,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState(null);
const [workspace, setWorkspace] = useState(null);
const [members, setMembers] = useState([]);
+ const [workspaces, setWorkspaces] = useState([]);
const [agents, setAgents] = useState([]);
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,