refactor(web): restructure to feature-based architecture with zustand stores

- Remove tab system entirely (tab-store, tab-bar, tab-link)
- Split monolithic AuthContext into zustand auth + workspace stores
- Move issue components/config to features/issues/
- Move WebSocket provider to features/realtime/
- Move api.ts to shared/
- Migrate all consumers from useAuth() to direct store imports
- Simplify sidebar: replace hand-built dropdown with shadcn DropdownMenu,
  replace custom layout wrapper with SidebarInset
- Remove unused @multica/store and @multica/hooks dependencies
- Add @/ path alias and zustand dependency
- Update CLAUDE.md with feature-based architecture conventions

Net change: +293 / -2435 lines

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing 2026-03-24 16:01:23 +08:00
parent 768a555a80
commit a2d7501d57
48 changed files with 604 additions and 1704 deletions

View file

@ -15,8 +15,58 @@ Multica is an AI-native task management platform — like Linear, but with AI ag
**Polyglot monorepo** — Go backend + TypeScript frontend.
- `server/` — Go backend (Chi + sqlc + gorilla/websocket)
- `apps/web/` — Next.js 16 frontend
- `packages/` — Shared TypeScript packages (ui, types, sdk, store, hooks, utils)
- `apps/web/` — Next.js 16 frontend (App Router)
- `packages/` — Shared TypeScript packages (ui, types, sdk, utils)
### 2.1 Web App Structure (`apps/web/`)
The frontend uses a **feature-based architecture** with three layers:
```
apps/web/
├── app/ # Routing layer (thin shells — import from features/)
├── features/ # Business logic, organized by domain
├── shared/ # Cross-feature utilities (api client)
```
**`app/`** — Next.js App Router pages. Route files should be thin: import and re-export from `features/`. Layout components and route-specific glue (redirects, auth guards) live here. Shared layout components (e.g. `app-sidebar`) stay in `app/(dashboard)/_components/`.
**`features/`** — Domain modules, each with its own components, hooks, stores, and config:
| Feature | Purpose | Exports |
|---|---|---|
| `features/auth/` | Authentication state | `useAuthStore`, `AuthInitializer` |
| `features/workspace/` | Workspace, members, agents | `useWorkspaceStore`, `useActorName` |
| `features/issues/` | Issue components and config | Icons, pickers, status/priority config |
| `features/realtime/` | WebSocket connection | `WSProvider`, `useWSEvent` |
**`shared/`** — Code used across multiple features. Currently only `api.ts` (SDK singleton).
### 2.2 State Management
- **Zustand** for global client state (`features/auth/store.ts`, `features/workspace/store.ts`).
- **React Context** only for connection lifecycle (`WSProvider` in `features/realtime/`).
- **Local `useState`** for component-scoped UI state (forms, modals, filters).
- Do not use React Context for data that can be a zustand store.
**Store conventions:**
- One store per feature domain. Import via `useAuthStore(selector)` or `useWorkspaceStore(selector)`.
- Stores must not call `useRouter` or any React hooks — keep navigation in components.
- Cross-store reads use `useOtherStore.getState()` inside actions (not hooks).
- Dependency direction: `workspace``auth`, `realtime``auth`, `issues``workspace`. Never reverse.
### 2.3 Import Aliases
Use `@/` alias (maps to `apps/web/`):
```typescript
import { api } from "@/shared/api";
import { useAuthStore } from "@/features/auth";
import { useWorkspaceStore } from "@/features/workspace";
import { useWSEvent } from "@/features/realtime";
import { StatusIcon } from "@/features/issues/components";
```
Within a feature, use relative imports. Between features or to shared, use `@/`.
## 3. Core Workflow Commands
@ -62,9 +112,10 @@ make db-down # Stop shared PostgreSQL
- Prefer `packages/ui` shadcn components over custom implementations.
- **shadcn official components**`packages/ui/src/components/ui/` — keep this directory clean; install missing components via `npx shadcn add`, do not mix in business code.
- **Shared business components & utils**`packages/ui/src/components/common/` — reusable project-level UI components (e.g. StatusBadge, PriorityIcon) and shared utilities live here.
- **Shared business components & utils**`packages/ui/src/components/common/` — reusable project-level UI components (e.g. ActorAvatar) and shared utilities live here.
- **Feature-specific components**`features/<domain>/components/` — issue icons, pickers, and other domain-bound UI live inside their feature module, not in `packages/ui`.
- Use shadcn design tokens for styling (e.g. `bg-primary`, `text-muted-foreground`, `text-destructive`). Avoid hardcoded color values (e.g. `text-red-500`, `bg-gray-100`).
- Do not introduce extra state (useState, context, reducers) unless explicitly required by the design.
- Do not introduce extra state (useState, context, reducers) unless explicitly required by the design. Prefer zustand stores for shared state over React Context.
- Pay close attention to **overflow** (truncate long text, scrollable containers), **alignment**, and **spacing** consistency.
- When unsure about interaction or state design, ask — the user will provide direction.

View file

@ -9,27 +9,30 @@ vi.mock("next/navigation", () => ({
useSearchParams: () => new URLSearchParams(),
}));
// Mock auth-context
// Mock auth store
const mockLogin = vi.fn();
const mockAuthValue = {
user: null,
workspace: null,
members: [],
agents: [],
isLoading: false,
vi.mock("@/features/auth", () => ({
useAuthStore: (selector: (s: any) => any) =>
selector({
login: mockLogin,
logout: vi.fn(),
refreshMembers: vi.fn(),
refreshAgents: vi.fn(),
getMemberName: () => "Unknown",
getAgentName: () => "Unknown Agent",
getActorName: () => "System",
getActorInitials: () => "XX",
};
isLoading: false,
}),
}));
vi.mock("../../../lib/auth-context", () => ({
useAuth: () => mockAuthValue,
AuthProvider: ({ children }: { children: React.ReactNode }) => children,
// Mock workspace store
const mockHydrateWorkspace = vi.fn();
vi.mock("@/features/workspace", () => ({
useWorkspaceStore: (selector: (s: any) => any) =>
selector({
hydrateWorkspace: mockHydrateWorkspace,
}),
}));
// Mock api
vi.mock("@/shared/api", () => ({
api: {
listWorkspaces: vi.fn().mockResolvedValue([]),
},
}));
import LoginPage from "./page";
@ -53,14 +56,13 @@ describe("LoginPage", () => {
const user = userEvent.setup();
render(<LoginPage />);
// The email input has required attribute, so browser validation blocks submit
// Verify login was never called
await user.click(screen.getByRole("button", { name: "Sign in" }));
expect(mockLogin).not.toHaveBeenCalled();
});
it("calls login with correct args on submit", async () => {
mockLogin.mockResolvedValueOnce(undefined);
mockLogin.mockResolvedValueOnce({ id: "u1", name: "Test User" });
mockHydrateWorkspace.mockResolvedValueOnce(null);
const user = userEvent.setup();
render(<LoginPage />);
@ -69,12 +71,13 @@ describe("LoginPage", () => {
await user.click(screen.getByRole("button", { name: "Sign in" }));
await waitFor(() => {
expect(mockLogin).toHaveBeenCalledWith("test@multica.ai", "Test User", undefined);
expect(mockLogin).toHaveBeenCalledWith("test@multica.ai", "Test User");
});
});
it("calls login with email only when name is empty", async () => {
mockLogin.mockResolvedValueOnce(undefined);
mockLogin.mockResolvedValueOnce({ id: "u1", name: "" });
mockHydrateWorkspace.mockResolvedValueOnce(null);
const user = userEvent.setup();
render(<LoginPage />);
@ -82,12 +85,11 @@ describe("LoginPage", () => {
await user.click(screen.getByRole("button", { name: "Sign in" }));
await waitFor(() => {
expect(mockLogin).toHaveBeenCalledWith("test@multica.ai", undefined, undefined);
expect(mockLogin).toHaveBeenCalledWith("test@multica.ai", undefined);
});
});
it("shows 'Signing in...' while submitting", async () => {
// Make login hang
mockLogin.mockReturnValueOnce(new Promise(() => {}));
const user = userEvent.setup();
render(<LoginPage />);

View file

@ -1,8 +1,10 @@
"use client";
import { Suspense, useState } from "react";
import { useSearchParams } from "next/navigation";
import { useAuth } from "../../../lib/auth-context";
import { useSearchParams, useRouter } from "next/navigation";
import { useAuthStore } from "@/features/auth";
import { useWorkspaceStore } from "@/features/workspace";
import { api } from "@/shared/api";
import {
Card,
CardHeader,
@ -16,7 +18,10 @@ import { Button } from "@multica/ui/components/ui/button";
import { Label } from "@multica/ui/components/ui/label";
function LoginPageContent() {
const { login, isLoading } = useAuth();
const router = useRouter();
const login = useAuthStore((s) => s.login);
const isLoading = useAuthStore((s) => s.isLoading);
const hydrateWorkspace = useWorkspaceStore((s) => s.hydrateWorkspace);
const searchParams = useSearchParams();
const [email, setEmail] = useState("");
const [name, setName] = useState("");
@ -32,7 +37,10 @@ function LoginPageContent() {
setError("");
setSubmitting(true);
try {
await login(email, name || undefined, searchParams.get("next") || undefined);
await login(email, name || undefined);
const wsList = await api.listWorkspaces();
await hydrateWorkspace(wsList);
router.push(searchParams.get("next") || "/issues");
} catch (err) {
setError("Login failed. Make sure the server is running.");
setSubmitting(false);

View file

@ -2,7 +2,7 @@
import { useState } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { usePathname, useRouter } from "next/navigation";
import {
Inbox,
ListTodo,
@ -26,6 +26,15 @@ import {
SidebarMenuButton,
SidebarMenuItem,
} from "@multica/ui/components/ui/sidebar";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@multica/ui/components/ui/dropdown-menu";
import { Input } from "@multica/ui/components/ui/input";
import { Label } from "@multica/ui/components/ui/label";
import { Button } from "@multica/ui/components/ui/button";
@ -37,46 +46,44 @@ import {
DialogDescription,
DialogFooter,
} from "@multica/ui/components/ui/dialog";
import { useAuth } from "../../../lib/auth-context";
import { useTabStore } from "../../../lib/tab-store";
import { useAuthStore } from "@/features/auth";
import { useWorkspaceStore } from "@/features/workspace";
const navItems = [
{ href: "/inbox", label: "Inbox", icon: Inbox, iconKey: "inbox" },
{ href: "/agents", label: "Agents", icon: Bot, iconKey: "agents" },
{ href: "/issues", label: "Issues", icon: ListTodo, iconKey: "issues" },
{
href: "/knowledge-base",
label: "Knowledge Base",
icon: BookOpen,
iconKey: "knowledge-base",
},
{ href: "/inbox", label: "Inbox", icon: Inbox },
{ href: "/agents", label: "Agents", icon: Bot },
{ href: "/issues", label: "Issues", icon: ListTodo },
{ href: "/knowledge-base", label: "Knowledge Base", icon: BookOpen },
];
export function AppSidebar() {
const pathname = usePathname();
const {
user,
workspace,
workspaces,
logout,
switchWorkspace,
createWorkspace,
} = useAuth();
const { openTab } = useTabStore();
const router = useRouter();
const user = useAuthStore((s) => s.user);
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 createWorkspace = useWorkspaceStore((s) => s.createWorkspace);
const [showMenu, setShowMenu] = useState(false);
const [showCreateDialog, setShowCreateDialog] = useState(false);
const [newName, setNewName] = useState("");
const [newSlug, setNewSlug] = useState("");
const [creating, setCreating] = useState(false);
const logout = () => {
authLogout();
useWorkspaceStore.getState().clearWorkspace();
router.push("/login");
};
const handleNameChange = (value: string) => {
setNewName(value);
setNewSlug(
value
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "")
.replace(/^-|-$/g, ""),
);
};
@ -106,84 +113,74 @@ export function AppSidebar() {
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton size="lg" onClick={() => setShowMenu(!showMenu)}>
<DropdownMenu>
<DropdownMenuTrigger
render={
<SidebarMenuButton size="lg">
<MulticaIcon className="size-4" noSpin />
<span className="flex-1 truncate font-semibold">
{workspace?.name ?? "Multica"}
</span>
<ChevronDown className="size-4" />
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
{showMenu && (
<>
<div
className="fixed inset-0 z-40"
onClick={() => setShowMenu(false)}
}
/>
<div className="absolute left-2 top-14 z-50 w-52 rounded-lg border bg-popover p-1 shadow-md">
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground">
<DropdownMenuContent
className="w-52"
align="start"
side="bottom"
sideOffset={4}
>
<DropdownMenuGroup>
<DropdownMenuLabel className="text-xs text-muted-foreground">
{user?.email}
</div>
<div className="my-1 border-t" />
<div className="px-2 py-1 text-xs font-medium text-muted-foreground">
</DropdownMenuLabel>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuLabel className="text-xs text-muted-foreground">
Workspaces
</div>
</DropdownMenuLabel>
{workspaces.map((ws) => (
<button
<DropdownMenuItem
key={ws.id}
onClick={() => {
setShowMenu(false);
onSelect={() => {
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>
<span className="flex-1 truncate">{ws.name}</span>
{ws.id === workspace?.id && (
<Check className="h-3.5 w-3.5 text-primary" />
)}
</button>
</DropdownMenuItem>
))}
<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"
>
<DropdownMenuItem onSelect={() => setShowCreateDialog(true)}>
<Plus className="h-3.5 w-3.5" />
Create workspace
</button>
<div className="my-1 border-t" />
<Link
href="/settings"
onClick={() => setShowMenu(false)}
className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent"
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem
render={<Link href="/settings" />}
>
<Settings className="h-3.5 w-3.5" />
Settings
</Link>
<button
onClick={() => {
setShowMenu(false);
logout();
}}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm text-destructive hover:bg-accent"
>
</DropdownMenuItem>
<DropdownMenuItem variant="destructive" onSelect={logout}>
<LogOut className="h-3.5 w-3.5" />
Sign out
</button>
</div>
</>
)}
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
{/* Navigation */}
@ -200,12 +197,6 @@ export function AppSidebar() {
<SidebarMenuButton
isActive={isActive}
render={<Link href={item.href} />}
onClick={() =>
openTab(item.href, item.label, {
replace: true,
iconKey: item.iconKey,
})
}
>
<item.icon />
<span>{item.label}</span>
@ -251,9 +242,7 @@ export function AppSidebar() {
</DialogHeader>
<div className="space-y-3">
<div>
<Label className="text-xs text-muted-foreground">
Name
</Label>
<Label className="text-xs text-muted-foreground">Name</Label>
<Input
autoFocus
type="text"
@ -264,9 +253,7 @@ export function AppSidebar() {
/>
</div>
<div>
<Label className="text-xs text-muted-foreground">
Slug
</Label>
<Label className="text-xs text-muted-foreground">Slug</Label>
<Input
type="text"
value={newSlug}

View file

@ -1,271 +0,0 @@
"use client";
import { useCallback, useState, useEffect, useRef } from "react";
import {
DndContext,
PointerSensor,
useSensor,
useSensors,
closestCenter,
type DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext,
horizontalListSortingStrategy,
useSortable,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import {
Plus,
X,
Inbox,
Bot,
ListTodo,
BookOpen,
Settings,
FileText,
} from "lucide-react";
import { useTabStore, type Tab } from "../../../lib/tab-store";
// ---------------------------------------------------------------------------
// Icon lookup
// ---------------------------------------------------------------------------
const TAB_ICONS: Record<string, typeof Inbox> = {
inbox: Inbox,
agents: Bot,
issues: ListTodo,
"knowledge-base": BookOpen,
settings: Settings,
};
function TabIcon({ iconKey }: { iconKey?: string }) {
const Icon = iconKey ? TAB_ICONS[iconKey] : undefined;
if (!Icon) return <FileText className="h-3.5 w-3.5 shrink-0" />;
return <Icon className="h-3.5 w-3.5 shrink-0" />;
}
// ---------------------------------------------------------------------------
// Context Menu
// ---------------------------------------------------------------------------
function TabContextMenu({
x,
y,
tabId,
onClose,
}: {
x: number;
y: number;
tabId: string;
onClose: () => void;
}) {
const { tabs, closeTab } = useTabStore();
const menuRef = useRef<HTMLDivElement>(null);
const canClose = tabs.length > 1;
useEffect(() => {
const handleClick = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
onClose();
}
};
const handleEsc = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("mousedown", handleClick);
document.addEventListener("keydown", handleEsc);
return () => {
document.removeEventListener("mousedown", handleClick);
document.removeEventListener("keydown", handleEsc);
};
}, [onClose]);
const handleClose = () => {
if (canClose) closeTab(tabId);
onClose();
};
const handleCloseOthers = () => {
tabs.forEach((t) => {
if (t.id !== tabId && tabs.length > 1) closeTab(t.id);
});
onClose();
};
return (
<div
ref={menuRef}
className="fixed z-50 min-w-[140px] rounded-md border bg-popover p-1 shadow-md"
style={{ left: x, top: y }}
>
<button
onClick={handleClose}
disabled={!canClose}
className="flex w-full items-center rounded-sm px-2 py-1.5 text-xs hover:bg-accent disabled:opacity-50"
>
Close
</button>
<button
onClick={handleCloseOthers}
disabled={tabs.length <= 1}
className="flex w-full items-center rounded-sm px-2 py-1.5 text-xs hover:bg-accent disabled:opacity-50"
>
Close others
</button>
</div>
);
}
// ---------------------------------------------------------------------------
// SortableTab
// ---------------------------------------------------------------------------
function SortableTab({
tab,
isActive,
canClose,
onContextMenu,
}: {
tab: Tab;
isActive: boolean;
canClose: boolean;
onContextMenu: (e: React.MouseEvent, tabId: string) => void;
}) {
const { activateTab, closeTab } = useTabStore();
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: tab.id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
const handleClick = () => {
if (!isDragging) {
activateTab(tab.id);
}
};
const handleClose = (e: React.MouseEvent) => {
e.stopPropagation();
closeTab(tab.id);
};
return (
<button
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
onClick={handleClick}
onContextMenu={(e) => onContextMenu(e, tab.id)}
className={`group flex h-7 max-w-[200px] items-center gap-1.5 rounded-lg px-2.5 text-[13px] transition-all select-none ${
isDragging ? "opacity-30" : ""
} ${
isActive
? "bg-background text-foreground shadow-sm ring-1 ring-border/60"
: "bg-background/50 text-foreground ring-1 ring-border/30 opacity-60 hover:opacity-85"
}`}
>
<TabIcon iconKey={tab.iconKey} />
<span className="truncate">{tab.title}</span>
{canClose && (
<span
onClick={handleClose}
className="ml-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-sm opacity-0 transition-opacity hover:bg-foreground/10 group-hover:opacity-100"
>
<X className="h-3 w-3" />
</span>
)}
</button>
);
}
// ---------------------------------------------------------------------------
// TabBar
// ---------------------------------------------------------------------------
export function TabBar() {
const { tabs, activeTabId, reorderTabs, openTab } = useTabStore();
const [contextMenu, setContextMenu] = useState<{
x: number;
y: number;
tabId: string;
} | null>(null);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 5 },
})
);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = tabs.findIndex((t) => t.id === active.id);
const newIndex = tabs.findIndex((t) => t.id === over.id);
if (oldIndex !== -1 && newIndex !== -1) {
reorderTabs(oldIndex, newIndex);
}
},
[tabs, reorderTabs]
);
const handleNewTab = () => {
openTab("/issues", "All Issues", { replace: false, iconKey: "issues" });
};
const handleContextMenu = (e: React.MouseEvent, tabId: string) => {
e.preventDefault();
setContextMenu({ x: e.clientX, y: e.clientY, tabId });
};
return (
<div className="flex h-10 shrink-0 items-center gap-1 bg-sidebar px-2">
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={tabs.map((t) => t.id)}
strategy={horizontalListSortingStrategy}
>
{tabs.map((tab) => (
<SortableTab
key={tab.id}
tab={tab}
isActive={tab.id === activeTabId}
canClose={tab.closeable && tabs.length > 1}
onContextMenu={handleContextMenu}
/>
))}
</SortableContext>
</DndContext>
<button
onClick={handleNewTab}
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-background/60 hover:text-foreground"
>
<Plus className="h-3.5 w-3.5" />
</button>
{contextMenu && (
<TabContextMenu
x={contextMenu.x}
y={contextMenu.y}
tabId={contextMenu.tabId}
onClose={() => setContextMenu(null)}
/>
)}
</div>
);
}

View file

@ -1,30 +0,0 @@
"use client";
import Link from "next/link";
import { useTabStore } from "../../../lib/tab-store";
export function TabLink({
href,
title,
iconKey,
children,
...props
}: {
href: string;
title: string;
iconKey?: string;
children: React.ReactNode;
} & Omit<React.ComponentProps<typeof Link>, "onClick" | "href">) {
const { openTab } = useTabStore();
const handleClick = (e: React.MouseEvent) => {
e.preventDefault();
openTab(href, title, { replace: false, iconKey });
};
return (
<Link href={href} onClick={handleClick} {...props}>
{children}
</Link>
);
}

View file

@ -45,9 +45,10 @@ import { Button } from "@multica/ui/components/ui/button";
import { Input } from "@multica/ui/components/ui/input";
import { Textarea } from "@multica/ui/components/ui/textarea";
import { Label } from "@multica/ui/components/ui/label";
import { api } from "../../../lib/api";
import { useAuth } from "../../../lib/auth-context";
import { useWSEvent } from "../../../lib/ws-context";
import { api } from "@/shared/api";
import { useAuthStore } from "@/features/auth";
import { useWorkspaceStore } from "@/features/workspace";
import { useWSEvent } from "@/features/realtime";
// ---------------------------------------------------------------------------
// Helpers
@ -1030,7 +1031,10 @@ function AgentDetail({
// ---------------------------------------------------------------------------
export default function AgentsPage() {
const { agents, refreshAgents, workspace, isLoading } = useAuth();
const isLoading = useAuthStore((s) => s.isLoading);
const workspace = useWorkspaceStore((s) => s.workspace);
const agents = useWorkspaceStore((s) => s.agents);
const refreshAgents = useWorkspaceStore((s) => s.refreshAgents);
const [selectedId, setSelectedId] = useState<string>("");
const [showCreate, setShowCreate] = useState(false);
const [runtimes, setRuntimes] = useState<RuntimeDevice[]>([]);

View file

@ -12,8 +12,8 @@ import {
} from "lucide-react";
import type { InboxItem, InboxItemType, InboxSeverity, InboxNewPayload } from "@multica/types";
import { Button } from "@multica/ui/components/ui/button";
import { api } from "../../../lib/api";
import { useWSEvent } from "../../../lib/ws-context";
import { api } from "@/shared/api";
import { useWSEvent } from "@/features/realtime";
// ---------------------------------------------------------------------------
// Helpers

View file

@ -27,16 +27,27 @@ vi.mock("next/link", () => ({
),
}));
// Mock auth context
vi.mock("../../../../lib/auth-context", () => ({
useAuth: () => ({
// Mock auth store
vi.mock("@/features/auth", () => ({
useAuthStore: (selector: (s: any) => any) =>
selector({
user: { id: "user-1", name: "Test User", email: "test@multica.ai" },
workspace: { id: "ws-1", name: "Test WS" },
members: [
{ user_id: "user-1", name: "Test User", email: "test@multica.ai" },
],
agents: [{ id: "agent-1", name: "Claude Agent" }],
isLoading: false,
}),
}));
// Mock workspace feature
vi.mock("@/features/workspace", () => ({
useWorkspaceStore: (selector: (s: any) => any) =>
selector({
workspace: { id: "ws-1", name: "Test WS" },
workspaces: [{ id: "ws-1", name: "Test WS" }],
members: [{ user_id: "user-1", name: "Test User", email: "test@multica.ai" }],
agents: [{ id: "agent-1", name: "Claude Agent" }],
}),
useActorName: () => ({
getMemberName: (id: string) => (id === "user-1" ? "Test User" : "Unknown"),
getAgentName: (id: string) => (id === "agent-1" ? "Claude Agent" : "Unknown Agent"),
getActorName: (type: string, id: string) => {
if (type === "member" && id === "user-1") return "Test User";
if (type === "agent" && id === "agent-1") return "Claude Agent";
@ -51,7 +62,7 @@ vi.mock("../../../../lib/auth-context", () => ({
}));
// Mock ws-context
vi.mock("../../../../lib/ws-context", () => ({
vi.mock("@/features/realtime", () => ({
useWSEvent: () => {},
}));
@ -60,14 +71,6 @@ vi.mock("@multica/ui/components/ui/calendar", () => ({
Calendar: () => null,
}));
// Mock tab-store
vi.mock("../../../../lib/tab-store", () => ({
useTabStore: () => ({
updateTabTitle: vi.fn(),
activeTabId: "tab-1",
}),
}));
// Mock api
const mockGetIssue = vi.hoisted(() => vi.fn());
const mockListComments = vi.hoisted(() => vi.fn());
@ -77,7 +80,7 @@ const mockDeleteComment = vi.hoisted(() => vi.fn());
const mockDeleteIssue = vi.hoisted(() => vi.fn());
const mockUpdateIssue = vi.hoisted(() => vi.fn());
vi.mock("../../../../lib/api", () => ({
vi.mock("@/shared/api", () => ({
api: {
getIssue: (...args: any[]) => mockGetIssue(...args),
listComments: (...args: any[]) => mockListComments(...args),

View file

@ -34,11 +34,11 @@ import { Button } from "@multica/ui/components/ui/button";
import { Input } from "@multica/ui/components/ui/input";
import { ActorAvatar } from "@multica/ui/components/common/actor-avatar";
import type { Issue, Comment, UpdateIssueRequest } from "@multica/types";
import { StatusPicker, PriorityPicker, AssigneePicker } from "../_components";
import { api } from "../../../../lib/api";
import { useAuth } from "../../../../lib/auth-context";
import { useWSEvent } from "../../../../lib/ws-context";
import { useTabStore } from "../../../../lib/tab-store";
import { StatusPicker, PriorityPicker, AssigneePicker } from "@/features/issues/components";
import { api } from "@/shared/api";
import { useAuthStore } from "@/features/auth";
import { useActorName } from "@/features/workspace";
import { useWSEvent } from "@/features/realtime";
import type { CommentCreatedPayload, CommentUpdatedPayload, CommentDeletedPayload } from "@multica/types";
// ---------------------------------------------------------------------------
@ -385,8 +385,8 @@ export default function IssueDetailPage({
}) {
const { id } = use(params);
const router = useRouter();
const { user, getActorName, getActorInitials } = useAuth();
const { updateTabTitle, activeTabId, closeTabByPath } = useTabStore();
const user = useAuthStore((s) => s.user);
const { getActorName, getActorInitials } = useActorName();
const [issue, setIssue] = useState<Issue | null>(null);
const [comments, setComments] = useState<Comment[]>([]);
const [loading, setLoading] = useState(true);
@ -409,13 +409,6 @@ export default function IssueDetailPage({
.finally(() => setLoading(false));
}, [id]);
// Sync tab title with loaded issue title
useEffect(() => {
if (issue?.title && activeTabId) {
updateTabTitle(activeTabId, issue.title);
}
}, [issue?.title, activeTabId, updateTabTitle]);
const handleSubmitComment = async (e: React.FormEvent) => {
e.preventDefault();
if (!commentText.trim() || submitting) return;
@ -449,7 +442,6 @@ export default function IssueDetailPage({
try {
await api.deleteIssue(issue!.id);
toast.success("Issue deleted");
closeTabByPath(`/issues/${id}`);
router.push("/issues");
} catch {
toast.error("Failed to delete issue");

View file

@ -1,2 +0,0 @@
export * from "./icons";
export * from "./pickers";

View file

@ -1,25 +0,0 @@
import type { IssueStatus, IssuePriority } from "@multica/types";
export const STATUS_CONFIG: Record<
IssueStatus,
{ label: string; iconColor: string }
> = {
backlog: { label: "Backlog", iconColor: "text-muted-foreground" },
todo: { label: "Todo", iconColor: "text-muted-foreground" },
in_progress: { label: "In Progress", iconColor: "text-yellow-500" },
in_review: { label: "In Review", iconColor: "text-blue-500" },
done: { label: "Done", iconColor: "text-green-500" },
blocked: { label: "Blocked", iconColor: "text-red-500" },
cancelled: { label: "Cancelled", iconColor: "text-muted-foreground/50" },
};
export const PRIORITY_CONFIG: Record<
IssuePriority,
{ label: string; bars: number; color: string }
> = {
urgent: { label: "Urgent", bars: 4, color: "text-orange-500" },
high: { label: "High", bars: 3, color: "text-orange-400" },
medium: { label: "Medium", bars: 2, color: "text-yellow-500" },
low: { label: "Low", bars: 1, color: "text-blue-400" },
none: { label: "No priority", bars: 0, color: "text-muted-foreground" },
};

View file

@ -26,16 +26,11 @@ vi.mock("next/link", () => ({
),
}));
// Mock auth context
vi.mock("../../../lib/auth-context", () => ({
useAuth: () => ({
user: { id: "user-1", name: "Test User", email: "test@multica.ai" },
workspace: { id: "ws-1", name: "Test WS" },
members: [
{ user_id: "user-1", name: "Test User", email: "test@multica.ai" },
],
agents: [{ id: "agent-1", name: "Claude Agent" }],
isLoading: false,
// Mock workspace feature
vi.mock("@/features/workspace", () => ({
useActorName: () => ({
getMemberName: (id: string) => (id === "user-1" ? "Test User" : "Unknown"),
getAgentName: (id: string) => (id === "agent-1" ? "Claude Agent" : "Unknown Agent"),
getActorName: (type: string, id: string) =>
type === "member" ? "Test User" : "Claude Agent",
getActorInitials: () => "TU",
@ -43,32 +38,18 @@ vi.mock("../../../lib/auth-context", () => ({
}));
// Mock WebSocket context
vi.mock("../../../lib/ws-context", () => ({
vi.mock("@/features/realtime", () => ({
useWSEvent: vi.fn(),
useWS: () => ({ subscribe: vi.fn(() => () => {}) }),
WSProvider: ({ children }: { children: React.ReactNode }) => children,
}));
// Mock tab-store
vi.mock("../../../lib/tab-store", () => ({
useTabStore: () => ({
updateTabTitle: vi.fn(),
activeTabId: "tab-1",
openTab: vi.fn(),
}),
}));
// Mock tab-link to avoid TabProvider dependency
vi.mock("../_components/tab-link", () => ({
TabLink: ({ children, href, ...props }: any) => <a href={href} {...props}>{children}</a>,
}));
// Mock api
const mockListIssues = vi.fn();
const mockCreateIssue = vi.fn();
const mockUpdateIssue = vi.fn();
vi.mock("../../../lib/api", () => ({
vi.mock("@/shared/api", () => ({
api: {
listIssues: (...args: any[]) => mockListIssues(...args),
createIssue: (...args: any[]) => mockCreateIssue(...args),

View file

@ -2,8 +2,6 @@
import { useState, useCallback, useEffect } from "react";
import Link from "next/link";
import { TabLink } from "../_components/tab-link";
import { useTabStore } from "../../../lib/tab-store";
import {
Columns3,
List,
@ -23,7 +21,7 @@ import {
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import type { Issue, IssueStatus, IssuePriority } from "@multica/types";
import { STATUS_CONFIG, PRIORITY_CONFIG, ALL_STATUSES, PRIORITY_ORDER } from "./_config";
import { STATUS_CONFIG, PRIORITY_CONFIG, ALL_STATUSES, PRIORITY_ORDER } from "@/features/issues/config";
import {
Dialog,
DialogContent,
@ -43,10 +41,10 @@ import {
SelectItem,
} from "@multica/ui/components/ui/select";
import { ActorAvatar } from "@multica/ui/components/common/actor-avatar";
import { StatusIcon, PriorityIcon } from "./_components";
import { api } from "../../../lib/api";
import { useAuth } from "../../../lib/auth-context";
import { useWSEvent } from "../../../lib/ws-context";
import { StatusIcon, PriorityIcon } from "@/features/issues/components";
import { api } from "@/shared/api";
import { useActorName } from "@/features/workspace";
import { useWSEvent } from "@/features/realtime";
import type { IssueCreatedPayload, IssueUpdatedPayload, IssueDeletedPayload } from "@multica/types";
function formatDate(date: string): string {
@ -61,7 +59,7 @@ function formatDate(date: string): string {
// ---------------------------------------------------------------------------
function BoardCardContent({ issue }: { issue: Issue }) {
const { getActorName, getActorInitials } = useAuth();
const { getActorName, getActorInitials } = useActorName();
return (
<div className="rounded-lg border bg-background p-3">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
@ -124,14 +122,12 @@ function DraggableBoardCard({ issue }: { issue: Issue }) {
if (isDragging) e.stopPropagation();
}}
>
<TabLink
<Link
href={`/issues/${issue.id}`}
title={issue.title}
iconKey="issues"
className="block transition-colors hover:opacity-80"
>
<BoardCardContent issue={issue} />
</TabLink>
</Link>
</div>
);
}
@ -265,12 +261,10 @@ function BoardView({
// ---------------------------------------------------------------------------
function ListRow({ issue }: { issue: Issue }) {
const { getActorName, getActorInitials } = useAuth();
const { getActorName, getActorInitials } = useActorName();
return (
<TabLink
<Link
href={`/issues/${issue.id}`}
title={issue.title}
iconKey="issues"
className="flex h-9 items-center gap-2 px-4 text-[13px] transition-colors hover:bg-accent/50"
>
<PriorityIcon priority={issue.priority} />
@ -293,7 +287,7 @@ function ListRow({ issue }: { issue: Issue }) {
getInitials={getActorInitials}
/>
)}
</TabLink>
</Link>
);
}
@ -450,7 +444,6 @@ function CreateIssueDialog({ onCreated }: { onCreated: (issue: Issue) => void })
type ViewMode = "board" | "list";
export default function IssuesPage() {
const { closeTabByPath } = useTabStore();
const [view, setView] = useState<ViewMode>("board");
const [issues, setIssues] = useState<Issue[]>([]);
const [loading, setLoading] = useState(true);
@ -497,8 +490,7 @@ export default function IssuesPage() {
useCallback((payload: unknown) => {
const { issue_id } = payload as IssueDeletedPayload;
setIssues((prev) => prev.filter((i) => i.id !== issue_id));
closeTabByPath(`/issues/${issue_id}`);
}, [closeTabByPath]),
}, []),
);
const handleMoveIssue = useCallback(

View file

@ -3,11 +3,10 @@
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { MulticaIcon } from "@multica/ui/components/multica-icon";
import { SidebarProvider } from "@multica/ui/components/ui/sidebar";
import { useAuth } from "../../lib/auth-context";
import { TabProvider } from "../../lib/tab-store";
import { SidebarProvider, SidebarInset } from "@multica/ui/components/ui/sidebar";
import { useAuthStore } from "@/features/auth";
import { useWorkspaceStore } from "@/features/workspace";
import { AppSidebar } from "./_components/app-sidebar";
import { TabBar } from "./_components/tab-bar";
export default function DashboardLayout({
children,
@ -15,7 +14,9 @@ export default function DashboardLayout({
children: React.ReactNode;
}) {
const router = useRouter();
const { user, workspace, isLoading } = useAuth();
const user = useAuthStore((s) => s.user);
const isLoading = useAuthStore((s) => s.isLoading);
const workspace = useWorkspaceStore((s) => s.workspace);
useEffect(() => {
if (!isLoading && !user) {
@ -34,16 +35,9 @@ export default function DashboardLayout({
if (!user || !workspace) return null;
return (
<TabProvider workspaceId={workspace.id}>
<SidebarProvider>
<AppSidebar />
<div className="relative flex w-full flex-1 flex-col overflow-hidden">
<TabBar />
<main className="flex-1 overflow-auto rounded-xl bg-background shadow-sm md:mr-2 md:mb-2">
{children}
</main>
</div>
<SidebarInset>{children}</SidebarInset>
</SidebarProvider>
</TabProvider>
);
}

View file

@ -14,8 +14,9 @@ import {
SelectContent,
SelectItem,
} from "@multica/ui/components/ui/select";
import { useAuth } from "../../../lib/auth-context";
import { api } from "../../../lib/api";
import { useAuthStore } from "@/features/auth";
import { useWorkspaceStore } from "@/features/workspace";
import { api } from "@/shared/api";
const roleConfig: Record<MemberRole, { label: string; icon: typeof Crown }> = {
owner: { label: "Owner", icon: Crown },
@ -90,16 +91,14 @@ function MemberRow({
}
export default function SettingsPage() {
const {
user,
workspace,
members,
updateWorkspace,
updateCurrentUser,
refreshMembers,
leaveWorkspace,
deleteWorkspace,
} = useAuth();
const user = useAuthStore((s) => s.user);
const setUser = useAuthStore((s) => s.setUser);
const workspace = useWorkspaceStore((s) => s.workspace);
const members = useWorkspaceStore((s) => s.members);
const updateWorkspace = useWorkspaceStore((s) => s.updateWorkspace);
const refreshMembers = useWorkspaceStore((s) => s.refreshMembers);
const leaveWorkspace = useWorkspaceStore((s) => s.leaveWorkspace);
const deleteWorkspace = useWorkspaceStore((s) => s.deleteWorkspace);
const [name, setName] = useState(workspace?.name ?? "");
const [description, setDescription] = useState(
@ -159,7 +158,7 @@ export default function SettingsPage() {
name: profileName,
avatar_url: avatarUrl || undefined,
});
updateCurrentUser(updated);
setUser(updated);
setProfileSaved(true);
setTimeout(() => setProfileSaved(false), 2000);
} catch (e) {

View file

@ -1,8 +1,8 @@
import type { Metadata } from "next";
import { ThemeProvider } from "@multica/ui/components/theme-provider";
import { Toaster } from "@multica/ui/components/ui/sonner";
import { AuthProvider } from "../lib/auth-context";
import { WSProvider } from "../lib/ws-context";
import { AuthInitializer } from "@/features/auth";
import { WSProvider } from "@/features/realtime";
import "./globals.css";
export const metadata: Metadata = {
@ -28,9 +28,9 @@ export default function RootLayout({
enableSystem
disableTransitionOnChange
>
<AuthProvider>
<AuthInitializer>
<WSProvider>{children}</WSProvider>
</AuthProvider>
</AuthInitializer>
<Toaster />
</ThemeProvider>
</body>

View file

@ -12,8 +12,9 @@ import {
SelectContent,
SelectItem,
} from "@multica/ui/components/ui/select";
import { api } from "../../../lib/api";
import { useAuth } from "../../../lib/auth-context";
import { api } from "@/shared/api";
import { useAuthStore } from "@/features/auth";
import { useWorkspaceStore } from "@/features/workspace";
function formatExpiresAt(value: string) {
return new Date(value).toLocaleString("en-US", {
@ -27,7 +28,10 @@ function formatExpiresAt(value: string) {
function LocalDaemonPairPageContent() {
const searchParams = useSearchParams();
const token = searchParams.get("token") ?? "";
const { user, workspaces, workspace, isLoading } = useAuth();
const user = useAuthStore((s) => s.user);
const isLoading = useAuthStore((s) => s.isLoading);
const workspace = useWorkspaceStore((s) => s.workspace);
const workspaces = useWorkspaceStore((s) => s.workspaces);
const [session, setSession] = useState<DaemonPairingSession | null>(null);
const [selectedWorkspaceId, setSelectedWorkspaceId] = useState("");
const [loading, setLoading] = useState(true);

View file

@ -0,0 +1,2 @@
export { useAuthStore } from "./store";
export { AuthInitializer } from "./initializer";

View file

@ -0,0 +1,32 @@
"use client";
import { useEffect, type ReactNode } from "react";
import { useAuthStore } from "./store";
import { useWorkspaceStore } from "@/features/workspace";
import { api } from "@/shared/api";
/**
* Initializes auth + workspace state from localStorage on mount.
* Must wrap the app to ensure stores are hydrated before children render.
*/
export function AuthInitializer({ children }: { children: ReactNode }) {
const initialize = useAuthStore((s) => s.initialize);
const user = useAuthStore((s) => s.user);
const isLoading = useAuthStore((s) => s.isLoading);
const hydrateWorkspace = useWorkspaceStore((s) => s.hydrateWorkspace);
useEffect(() => {
initialize();
}, [initialize]);
useEffect(() => {
if (isLoading || !user) return;
const wsId = localStorage.getItem("multica_workspace_id");
api.listWorkspaces().then((wsList) => {
hydrateWorkspace(wsList, wsId);
}).catch(console.error);
}, [user, isLoading, hydrateWorkspace]);
return <>{children}</>;
}

View file

@ -0,0 +1,61 @@
"use client";
import { create } from "zustand";
import type { User } from "@multica/types";
import { api } from "@/shared/api";
interface AuthState {
user: User | null;
isLoading: boolean;
initialize: () => Promise<void>;
login: (email: string, name?: string) => Promise<User>;
logout: () => void;
setUser: (user: User) => void;
}
export const useAuthStore = create<AuthState>((set) => ({
user: null,
isLoading: true,
initialize: async () => {
const token = localStorage.getItem("multica_token");
if (!token) {
set({ isLoading: false });
return;
}
api.setToken(token);
try {
const user = await api.getMe();
set({ user, isLoading: false });
} catch {
api.setToken(null);
api.setWorkspaceId(null);
localStorage.removeItem("multica_token");
localStorage.removeItem("multica_workspace_id");
set({ user: null, isLoading: false });
}
},
login: async (email: string, name?: string) => {
const { token, user } = await api.login(email, name);
localStorage.setItem("multica_token", token);
api.setToken(token);
set({ user });
return user;
},
logout: () => {
localStorage.removeItem("multica_token");
localStorage.removeItem("multica_workspace_id");
api.setToken(null);
api.setWorkspaceId(null);
set({ user: null });
},
setUser: (user: User) => {
set({ user });
},
}));

View file

@ -1,2 +1,3 @@
export { StatusIcon } from "./status-icon";
export { PriorityIcon } from "./priority-icon";
export { StatusPicker, PriorityPicker, AssigneePicker } from "./pickers";

View file

@ -3,7 +3,7 @@
import { useState } from "react";
import { Bot, UserMinus } from "lucide-react";
import type { IssueAssigneeType, UpdateIssueRequest } from "@multica/types";
import { useAuth } from "../../../../../lib/auth-context";
import { useWorkspaceStore, useActorName } from "@/features/workspace";
import {
PropertyPicker,
PickerItem,
@ -22,7 +22,9 @@ export function AssigneePicker({
}) {
const [open, setOpen] = useState(false);
const [filter, setFilter] = useState("");
const { members, agents, getActorName, getActorInitials } = useAuth();
const members = useWorkspaceStore((s) => s.members);
const agents = useWorkspaceStore((s) => s.agents);
const { getActorName, getActorInitials } = useActorName();
const query = filter.toLowerCase();
const filteredMembers = members.filter((m) =>

View file

@ -2,8 +2,8 @@
import { useState } from "react";
import type { IssuePriority, UpdateIssueRequest } from "@multica/types";
import { PRIORITY_ORDER, PRIORITY_CONFIG } from "../../_config";
import { PriorityIcon } from "../icons";
import { PRIORITY_ORDER, PRIORITY_CONFIG } from "@/features/issues/config";
import { PriorityIcon } from "../priority-icon";
import { PropertyPicker, PickerItem } from "./property-picker";
export function PriorityPicker({

View file

@ -2,8 +2,8 @@
import { useState } from "react";
import type { IssueStatus, UpdateIssueRequest } from "@multica/types";
import { ALL_STATUSES, STATUS_CONFIG } from "../../_config";
import { StatusIcon } from "../icons";
import { ALL_STATUSES, STATUS_CONFIG } from "@/features/issues/config";
import { StatusIcon } from "../status-icon";
import { PropertyPicker, PickerItem } from "./property-picker";
export function StatusPicker({

View file

@ -1,5 +1,5 @@
import type { IssuePriority } from "@multica/types";
import { PRIORITY_CONFIG } from "../../_config";
import { PRIORITY_CONFIG } from "@/features/issues/config";
export function PriorityIcon({
priority,

View file

@ -1,5 +1,5 @@
import type { IssueStatus } from "@multica/types";
import { STATUS_CONFIG } from "../../_config";
import { STATUS_CONFIG } from "@/features/issues/config";
// ---------------------------------------------------------------------------
// Circle geometry constants (viewBox 0 0 16 16, center 8,8, radius 6)

View file

@ -0,0 +1,2 @@
export { StatusIcon, PriorityIcon, StatusPicker, PriorityPicker, AssigneePicker } from "./components";
export * from "./config";

View file

@ -0,0 +1,20 @@
"use client";
import { useEffect } from "react";
import type { WSEventType } from "@multica/types";
import { useWS } from "./provider";
type EventHandler = (payload: unknown) => void;
/**
* Hook that subscribes to a WebSocket event and calls the handler.
* Automatically unsubscribes on cleanup.
*/
export function useWSEvent(event: WSEventType, handler: EventHandler) {
const { subscribe } = useWS();
useEffect(() => {
const unsub = subscribe(event, handler);
return unsub;
}, [event, handler, subscribe]);
}

View file

@ -0,0 +1,2 @@
export { WSProvider, useWS } from "./provider";
export { useWSEvent } from "./hooks";

View file

@ -10,7 +10,7 @@ import {
} from "react";
import { WSClient } from "@multica/sdk";
import type { WSEventType } from "@multica/types";
import { useAuth } from "./auth-context";
import { useAuthStore } from "@/features/auth";
const WS_URL = process.env.NEXT_PUBLIC_WS_URL ?? "ws://localhost:8080/ws";
@ -23,7 +23,7 @@ interface WSContextValue {
const WSContext = createContext<WSContextValue | null>(null);
export function WSProvider({ children }: { children: ReactNode }) {
const { user } = useAuth();
const user = useAuthStore((s) => s.user);
const wsRef = useRef<WSClient | null>(null);
useEffect(() => {
@ -60,16 +60,3 @@ export function useWS() {
if (!ctx) throw new Error("useWS must be used within WSProvider");
return ctx;
}
/**
* Hook that subscribes to a WebSocket event and calls the handler.
* Automatically unsubscribes on cleanup.
*/
export function useWSEvent(event: WSEventType, handler: EventHandler) {
const { subscribe } = useWS();
useEffect(() => {
const unsub = subscribe(event, handler);
return unsub;
}, [event, handler, subscribe]);
}

View file

@ -0,0 +1,36 @@
"use client";
import { useWorkspaceStore } from "./store";
export function useActorName() {
const members = useWorkspaceStore((s) => s.members);
const agents = useWorkspaceStore((s) => s.agents);
const getMemberName = (userId: string) => {
const m = members.find((m) => m.user_id === userId);
return m?.name ?? "Unknown";
};
const getAgentName = (agentId: string) => {
const a = agents.find((a) => a.id === agentId);
return a?.name ?? "Unknown Agent";
};
const getActorName = (type: string, id: string) => {
if (type === "member") return getMemberName(id);
if (type === "agent") return getAgentName(id);
return "System";
};
const getActorInitials = (type: string, id: string) => {
const name = getActorName(type, id);
return name
.split(" ")
.map((w) => w[0])
.join("")
.toUpperCase()
.slice(0, 2);
};
return { getMemberName, getAgentName, getActorName, getActorInitials };
}

View file

@ -0,0 +1,2 @@
export { useWorkspaceStore } from "./store";
export { useActorName } from "./hooks";

View file

@ -0,0 +1,142 @@
"use client";
import { create } from "zustand";
import type { Workspace, MemberWithUser, Agent } from "@multica/types";
import { api } from "@/shared/api";
interface WorkspaceState {
workspace: Workspace | null;
workspaces: Workspace[];
members: MemberWithUser[];
agents: Agent[];
}
interface WorkspaceActions {
hydrateWorkspace: (
wsList: Workspace[],
preferredWorkspaceId?: string | null,
) => Promise<Workspace | null>;
switchWorkspace: (workspaceId: string) => Promise<void>;
refreshWorkspaces: () => Promise<Workspace[]>;
refreshMembers: () => Promise<void>;
refreshAgents: () => Promise<void>;
createWorkspace: (data: {
name: string;
slug: string;
description?: string;
}) => Promise<Workspace>;
updateWorkspace: (ws: Workspace) => void;
leaveWorkspace: (workspaceId: string) => Promise<void>;
deleteWorkspace: (workspaceId: string) => Promise<void>;
clearWorkspace: () => void;
}
type WorkspaceStore = WorkspaceState & WorkspaceActions;
export const useWorkspaceStore = create<WorkspaceStore>((set, get) => ({
// State
workspace: null,
workspaces: [],
members: [],
agents: [],
// Actions
hydrateWorkspace: async (wsList, preferredWorkspaceId) => {
set({ workspaces: wsList });
const nextWorkspace =
(preferredWorkspaceId
? wsList.find((item) => item.id === preferredWorkspaceId)
: null) ??
wsList[0] ??
null;
if (!nextWorkspace) {
api.setWorkspaceId(null);
localStorage.removeItem("multica_workspace_id");
set({ workspace: null, members: [], agents: [] });
return null;
}
api.setWorkspaceId(nextWorkspace.id);
localStorage.setItem("multica_workspace_id", nextWorkspace.id);
set({ workspace: nextWorkspace });
const [nextMembers, nextAgents] = await Promise.all([
api.listMembers(nextWorkspace.id),
api.listAgents({ workspace_id: nextWorkspace.id }),
]);
set({ members: nextMembers, agents: nextAgents });
return nextWorkspace;
},
switchWorkspace: async (workspaceId) => {
const { workspaces, hydrateWorkspace } = get();
const ws = workspaces.find((item) => item.id === workspaceId);
if (!ws) return;
await hydrateWorkspace(workspaces, ws.id);
},
refreshWorkspaces: async () => {
const { workspace, hydrateWorkspace } = get();
const storedWorkspaceId = localStorage.getItem("multica_workspace_id");
const wsList = await api.listWorkspaces();
await hydrateWorkspace(wsList, workspace?.id ?? storedWorkspaceId);
return wsList;
},
refreshMembers: async () => {
const { workspace } = get();
if (!workspace) return;
const members = await api.listMembers(workspace.id);
set({ members });
},
refreshAgents: async () => {
const { workspace } = get();
if (!workspace) return;
const agents = await api.listAgents({ workspace_id: workspace.id });
set({ agents });
},
createWorkspace: async (data) => {
const ws = await api.createWorkspace(data);
set((state) => ({ workspaces: [...state.workspaces, ws] }));
return ws;
},
updateWorkspace: (ws) => {
set((state) => ({
workspace: state.workspace?.id === ws.id ? ws : state.workspace,
workspaces: state.workspaces.map((item) =>
item.id === ws.id ? ws : item,
),
}));
},
leaveWorkspace: async (workspaceId) => {
await api.leaveWorkspace(workspaceId);
const { workspace, hydrateWorkspace } = get();
const wsList = await api.listWorkspaces();
const preferredWorkspaceId =
workspace?.id === workspaceId ? null : (workspace?.id ?? null);
await hydrateWorkspace(wsList, preferredWorkspaceId);
},
deleteWorkspace: async (workspaceId) => {
await api.deleteWorkspace(workspaceId);
const { workspace, hydrateWorkspace } = get();
const wsList = await api.listWorkspaces();
const preferredWorkspaceId =
workspace?.id === workspaceId ? null : (workspace?.id ?? null);
await hydrateWorkspace(wsList, preferredWorkspaceId);
},
clearWorkspace: () => {
api.setWorkspaceId(null);
localStorage.removeItem("multica_workspace_id");
set({ workspace: null, workspaces: [], members: [], agents: [] });
},
}));

View file

@ -1,442 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, act, waitFor } from "@testing-library/react";
import type { User, Workspace, MemberWithUser, Agent } from "@multica/types";
// Mock next/navigation
const mockPush = vi.fn();
const mockRefresh = vi.fn();
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: mockPush, refresh: mockRefresh }),
}));
// Must use vi.hoisted so the mock object is defined before vi.mock factory runs
const mockApi = vi.hoisted(() => ({
setToken: vi.fn(),
setWorkspaceId: vi.fn(),
login: vi.fn(),
getMe: vi.fn(),
listWorkspaces: vi.fn(),
listMembers: vi.fn(),
listAgents: vi.fn(),
createWorkspace: vi.fn(),
updateMe: vi.fn(),
leaveWorkspace: vi.fn(),
deleteWorkspace: vi.fn(),
}));
vi.mock("./api", () => ({
api: mockApi,
}));
import { AuthProvider, useAuth } from "./auth-context";
const mockUser: User = {
id: "user-1",
name: "Test User",
email: "test@multica.ai",
avatar_url: null,
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
};
const mockWorkspace: Workspace = {
id: "ws-1",
name: "Test WS",
slug: "test",
description: null,
settings: {},
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
};
const mockMembers: MemberWithUser[] = [
{
id: "member-1",
workspace_id: "ws-1",
user_id: "user-1",
role: "owner",
created_at: "2026-01-01T00:00:00Z",
name: "Test User",
email: "test@multica.ai",
avatar_url: null,
},
{
id: "member-2",
workspace_id: "ws-1",
user_id: "user-2",
role: "member",
created_at: "2026-01-01T00:00:00Z",
name: "Other User",
email: "other@multica.ai",
avatar_url: null,
},
];
const mockAgents: Agent[] = [
{
id: "agent-1",
workspace_id: "ws-1",
runtime_id: "runtime-1",
name: "Claude",
description: "",
avatar_url: null,
status: "idle",
runtime_mode: "cloud",
runtime_config: {},
visibility: "workspace",
max_concurrent_tasks: 3,
owner_id: null,
skills: "",
tools: [],
triggers: [],
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
},
];
function wrapper({ children }: { children: React.ReactNode }) {
return <AuthProvider>{children}</AuthProvider>;
}
describe("AuthContext", () => {
beforeEach(() => {
vi.clearAllMocks();
// Clear localStorage manually since jsdom may not have .clear()
localStorage.removeItem("multica_token");
localStorage.removeItem("multica_workspace_id");
});
it("starts with null user when no token stored", async () => {
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.user).toBeNull();
expect(result.current.workspace).toBeNull();
});
it("login stores token and navigates to /issues", async () => {
mockApi.login.mockResolvedValueOnce({ token: "test-jwt", user: 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);
});
await act(async () => {
await result.current.login("test@multica.ai", "Test User");
});
expect(mockApi.login).toHaveBeenCalledWith("test@multica.ai", "Test User");
expect(mockApi.setToken).toHaveBeenCalledWith("test-jwt");
expect(localStorage.getItem("multica_token")).toBe("test-jwt");
expect(result.current.user).toEqual(mockUser);
expect(result.current.workspace).toEqual(mockWorkspace);
expect(result.current.members).toEqual(mockMembers);
expect(result.current.agents).toEqual(mockAgents);
expect(mockPush).toHaveBeenCalledWith("/issues");
});
it("logout clears state and navigates to /login", async () => {
mockApi.login.mockResolvedValueOnce({ token: "test-jwt", user: 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);
});
await act(async () => {
await result.current.login("test@multica.ai");
});
act(() => {
result.current.logout();
});
expect(localStorage.getItem("multica_token")).toBeNull();
expect(localStorage.getItem("multica_workspace_id")).toBeNull();
expect(result.current.user).toBeNull();
expect(result.current.workspace).toBeNull();
expect(result.current.members).toEqual([]);
expect(result.current.agents).toEqual([]);
expect(mockPush).toHaveBeenCalledWith("/login");
});
it("getMemberName returns correct name for known user", async () => {
mockApi.login.mockResolvedValueOnce({ token: "test-jwt", user: 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);
});
await act(async () => {
await result.current.login("test@multica.ai");
});
expect(result.current.getMemberName("user-1")).toBe("Test User");
expect(result.current.getMemberName("user-2")).toBe("Other User");
expect(result.current.getMemberName("unknown")).toBe("Unknown");
});
it("getAgentName returns correct name for known agent", async () => {
mockApi.login.mockResolvedValueOnce({ token: "test-jwt", user: 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);
});
await act(async () => {
await result.current.login("test@multica.ai");
});
expect(result.current.getAgentName("agent-1")).toBe("Claude");
expect(result.current.getAgentName("unknown")).toBe("Unknown Agent");
});
it("getActorName dispatches to member or agent", async () => {
mockApi.login.mockResolvedValueOnce({ token: "test-jwt", user: 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);
});
await act(async () => {
await result.current.login("test@multica.ai");
});
expect(result.current.getActorName("member", "user-1")).toBe("Test User");
expect(result.current.getActorName("agent", "agent-1")).toBe("Claude");
expect(result.current.getActorName("system", "xxx")).toBe("System");
});
it("getActorInitials returns uppercase initials", async () => {
mockApi.login.mockResolvedValueOnce({ token: "test-jwt", user: 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);
});
await act(async () => {
await result.current.login("test@multica.ai");
});
expect(result.current.getActorInitials("member", "user-1")).toBe("TU");
expect(result.current.getActorInitials("agent", "agent-1")).toBe("C");
});
it("initializes from localStorage token on mount", async () => {
localStorage.setItem("multica_token", "stored-token");
localStorage.setItem("multica_workspace_id", "ws-1");
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(mockApi.setToken).toHaveBeenCalledWith("stored-token");
expect(result.current.user).toEqual(mockUser);
expect(result.current.workspace).toEqual(mockWorkspace);
});
it("updateWorkspace updates workspace in context", async () => {
mockApi.login.mockResolvedValueOnce({ token: "test-jwt", user: 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);
});
await act(async () => {
await result.current.login("test@multica.ai");
});
expect(result.current.workspace?.name).toBe("Test WS");
const updated: Workspace = { ...mockWorkspace, name: "Renamed WS", description: "new desc" };
act(() => {
result.current.updateWorkspace(updated);
});
expect(result.current.workspace?.name).toBe("Renamed WS");
expect(result.current.workspace?.description).toBe("new desc");
});
it("clears token when stored token is invalid", async () => {
localStorage.setItem("multica_token", "invalid-token");
mockApi.getMe.mockRejectedValueOnce(new Error("Unauthorized"));
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
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 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(mockRefresh).toHaveBeenCalled();
});
});

View file

@ -1,274 +0,0 @@
"use client";
import {
createContext,
useContext,
useState,
useEffect,
useCallback,
type ReactNode,
} from "react";
import { useRouter } from "next/navigation";
import type { User, Workspace, MemberWithUser, Agent } from "@multica/types";
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, redirectTo?: string) => Promise<void>;
logout: () => void;
switchWorkspace: (workspaceId: string) => Promise<void>;
createWorkspace: (data: { name: string; slug: string; description?: string }) => Promise<Workspace>;
updateWorkspace: (ws: Workspace) => void;
updateCurrentUser: (nextUser: User) => void;
leaveWorkspace: (workspaceId: string) => Promise<void>;
deleteWorkspace: (workspaceId: string) => Promise<void>;
refreshWorkspaces: () => Promise<Workspace[]>;
refreshMembers: () => Promise<void>;
refreshAgents: () => Promise<void>;
getMemberName: (userId: string) => string;
getAgentName: (agentId: string) => string;
getActorName: (type: string, id: string) => string;
getActorInitials: (type: string, id: string) => string;
}
const AuthContext = createContext<AuthContextValue | null>(null);
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();
const hydrateWorkspace = useCallback(async (wsList: Workspace[], preferredWorkspaceId?: string | null) => {
setWorkspaces(wsList);
const nextWorkspace =
(preferredWorkspaceId ? wsList.find((item) => item.id === preferredWorkspaceId) : null) ??
wsList[0] ??
null;
if (!nextWorkspace) {
api.setWorkspaceId(null);
localStorage.removeItem("multica_workspace_id");
setWorkspace(null);
setMembers([]);
setAgents([]);
return null;
}
api.setWorkspaceId(nextWorkspace.id);
localStorage.setItem("multica_workspace_id", nextWorkspace.id);
setWorkspace(nextWorkspace);
const [nextMembers, nextAgents] = await Promise.all([
api.listMembers(nextWorkspace.id),
api.listAgents({ workspace_id: nextWorkspace.id }),
]);
setMembers(nextMembers);
setAgents(nextAgents);
return nextWorkspace;
}, []);
const refreshWorkspaces = useCallback(async () => {
const storedWorkspaceId = localStorage.getItem("multica_workspace_id");
const wsList = await api.listWorkspaces();
await hydrateWorkspace(wsList, workspace?.id ?? storedWorkspaceId);
return wsList;
}, [hydrateWorkspace, workspace]);
// Initialize from stored token
useEffect(() => {
const token = localStorage.getItem("multica_token");
const wsId = localStorage.getItem("multica_workspace_id");
if (!token) {
setIsLoading(false);
return;
}
api.setToken(token);
api.setWorkspaceId(wsId);
(async () => {
try {
const me = await api.getMe();
setUser(me);
const wsList = await api.listWorkspaces();
await hydrateWorkspace(wsList, wsId);
} catch {
// Token invalid, clear it
api.setToken(null);
api.setWorkspaceId(null);
localStorage.removeItem("multica_token");
localStorage.removeItem("multica_workspace_id");
setUser(null);
setWorkspace(null);
setWorkspaces([]);
setMembers([]);
setAgents([]);
} finally {
setIsLoading(false);
}
})();
}, [hydrateWorkspace]);
const login = useCallback(async (email: string, name?: string, redirectTo?: string) => {
const { token, user: u } = await api.login(email, name);
api.setToken(token);
localStorage.setItem("multica_token", token);
setUser(u);
const wsList = await api.listWorkspaces();
await hydrateWorkspace(wsList);
router.push(redirectTo || "/issues");
}, [hydrateWorkspace, router]);
const logout = useCallback(() => {
api.setToken(null);
api.setWorkspaceId(null);
localStorage.removeItem("multica_token");
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((item) => item.id === workspaceId);
if (!ws) return;
await hydrateWorkspace(workspaces, ws.id);
router.refresh();
}, [hydrateWorkspace, router, 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);
setWorkspaces((prev) => prev.map((item) => (item.id === ws.id ? ws : item)));
}, []);
const updateCurrentUser = useCallback((nextUser: User) => {
setUser(nextUser);
}, []);
const reloadAfterWorkspaceRemoval = useCallback(async (removedWorkspaceId: string) => {
const wsList = await api.listWorkspaces();
const preferredWorkspaceId = workspace?.id === removedWorkspaceId ? null : workspace?.id ?? null;
await hydrateWorkspace(wsList, preferredWorkspaceId);
router.refresh();
}, [hydrateWorkspace, router, workspace]);
const leaveWorkspace = useCallback(async (workspaceId: string) => {
await api.leaveWorkspace(workspaceId);
await reloadAfterWorkspaceRemoval(workspaceId);
}, [reloadAfterWorkspaceRemoval]);
const deleteWorkspace = useCallback(async (workspaceId: string) => {
await api.deleteWorkspace(workspaceId);
await reloadAfterWorkspaceRemoval(workspaceId);
}, [reloadAfterWorkspaceRemoval]);
const refreshMembers = useCallback(async () => {
if (!workspace) return;
const m = await api.listMembers(workspace.id);
setMembers(m);
}, [workspace]);
const refreshAgents = useCallback(async () => {
if (!workspace) return;
const a = await api.listAgents({ workspace_id: workspace.id });
setAgents(a);
}, [workspace]);
const getMemberName = useCallback(
(userId: string) => {
const m = members.find((m) => m.user_id === userId);
return m?.name ?? "Unknown";
},
[members],
);
const getAgentName = useCallback(
(agentId: string) => {
const a = agents.find((a) => a.id === agentId);
return a?.name ?? "Unknown Agent";
},
[agents],
);
const getActorName = useCallback(
(type: string, id: string) => {
if (type === "member") return getMemberName(id);
if (type === "agent") return getAgentName(id);
return "System";
},
[getMemberName, getAgentName],
);
const getActorInitials = useCallback(
(type: string, id: string) => {
const name = getActorName(type, id);
return name
.split(" ")
.map((w) => w[0])
.join("")
.toUpperCase()
.slice(0, 2);
},
[getActorName],
);
return (
<AuthContext.Provider
value={{
user,
workspace,
workspaces,
members,
agents,
isLoading,
login,
logout,
switchWorkspace,
createWorkspace: createNewWorkspace,
updateWorkspace: updateWorkspaceState,
updateCurrentUser,
leaveWorkspace,
deleteWorkspace,
refreshWorkspaces,
refreshMembers,
refreshAgents,
getMemberName,
getAgentName,
getActorName,
getActorInitials,
}}
>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be used within AuthProvider");
return ctx;
}

View file

@ -1,357 +0,0 @@
"use client";
import {
createContext,
useContext,
useState,
useEffect,
useCallback,
useRef,
type ReactNode,
} from "react";
import { usePathname, useRouter } from "next/navigation";
import { arrayMove } from "@dnd-kit/sortable";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface Tab {
id: string;
path: string;
title: string;
iconKey?: string;
closeable: boolean;
}
interface TabStoreValue {
tabs: Tab[];
activeTabId: string;
openTab: (
path: string,
title: string,
opts?: { replace?: boolean; iconKey?: string }
) => void;
activateTab: (tabId: string) => void;
closeTab: (tabId: string) => void;
closeTabByPath: (path: string) => void;
updateTabTitle: (tabId: string, title: string) => void;
reorderTabs: (oldIndex: number, newIndex: number) => void;
}
// ---------------------------------------------------------------------------
// Route title mapping (for hydration / fallback)
// ---------------------------------------------------------------------------
const ROUTE_TITLES: Record<string, string> = {
"/inbox": "Inbox",
"/agents": "Agents",
"/issues": "All Issues",
"/knowledge-base": "Knowledge Base",
"/settings": "Settings",
};
const ROUTE_ICON_KEYS: Record<string, string> = {
"/inbox": "inbox",
"/agents": "agents",
"/issues": "issues",
"/knowledge-base": "knowledge-base",
"/settings": "settings",
};
function getTitleForPath(path: string): string {
if (ROUTE_TITLES[path]) return ROUTE_TITLES[path];
if (path.startsWith("/issues/")) return path.split("/")[2]?.slice(0, 8) ?? "Issue";
if (path.startsWith("/agents/")) return "Agent";
return "Tab";
}
function getIconKeyForPath(path: string): string | undefined {
if (ROUTE_ICON_KEYS[path]) return ROUTE_ICON_KEYS[path];
// Sub-paths inherit parent icon
for (const [route, key] of Object.entries(ROUTE_ICON_KEYS)) {
if (path.startsWith(route + "/")) return key;
}
return undefined;
}
// ---------------------------------------------------------------------------
// localStorage helpers
// ---------------------------------------------------------------------------
function storageKey(workspaceId: string): string {
return `multica-tabs-${workspaceId}`;
}
function loadTabs(workspaceId: string): { tabs: Tab[]; activeTabId: string } | null {
try {
const raw = localStorage.getItem(storageKey(workspaceId));
if (!raw) return null;
const data = JSON.parse(raw) as { tabs: Tab[]; activeTabId: string };
if (Array.isArray(data.tabs) && data.tabs.length > 0 && data.activeTabId) {
return data;
}
return null;
} catch {
return null;
}
}
function saveTabs(workspaceId: string, tabs: Tab[], activeTabId: string): void {
try {
localStorage.setItem(
storageKey(workspaceId),
JSON.stringify({ tabs, activeTabId })
);
} catch {
// localStorage full or unavailable
}
}
// ---------------------------------------------------------------------------
// Context
// ---------------------------------------------------------------------------
const TabStoreContext = createContext<TabStoreValue | null>(null);
export function useTabStore(): TabStoreValue {
const ctx = useContext(TabStoreContext);
if (!ctx) {
throw new Error("useTabStore must be used within a TabProvider.");
}
return ctx;
}
// ---------------------------------------------------------------------------
// Provider
// ---------------------------------------------------------------------------
export function TabProvider({
workspaceId,
children,
}: {
workspaceId: string;
children: ReactNode;
}) {
const pathname = usePathname();
const router = useRouter();
// Suppress URL-sync effect when we are the ones triggering navigation
const navigatingRef = useRef(false);
// Initialize tabs: hydrate from localStorage or create default
const [tabs, setTabs] = useState<Tab[]>(() => {
const saved = loadTabs(workspaceId);
if (saved) return saved.tabs;
return [
{
id: crypto.randomUUID(),
path: pathname,
title: getTitleForPath(pathname),
iconKey: getIconKeyForPath(pathname),
closeable: false,
},
];
});
const [activeTabId, setActiveTabId] = useState<string>(() => {
const saved = loadTabs(workspaceId);
if (saved) {
// If saved active tab still exists, use it
const exists = saved.tabs.find((t) => t.id === saved.activeTabId);
if (exists) return saved.activeTabId;
}
return tabs[0]?.id ?? "";
});
// Persist on change
useEffect(() => {
saveTabs(workspaceId, tabs, activeTabId);
}, [workspaceId, tabs, activeTabId]);
// Sync active tab with initial pathname on mount
const initialSyncDone = useRef(false);
useEffect(() => {
if (initialSyncDone.current) return;
initialSyncDone.current = true;
const activeTab = tabs.find((t) => t.id === activeTabId);
if (activeTab && activeTab.path === pathname) return;
// Try to find a tab matching the current URL
const match = tabs.find((t) => t.path === pathname);
if (match) {
setActiveTabId(match.id);
} else if (activeTab) {
// Replace the active tab with current URL
setTabs((prev) =>
prev.map((t) =>
t.id === activeTabId
? {
...t,
path: pathname,
title: getTitleForPath(pathname),
iconKey: getIconKeyForPath(pathname),
}
: t
)
);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// URL sync: when pathname changes externally (back/forward, direct URL)
useEffect(() => {
if (navigatingRef.current) {
navigatingRef.current = false;
return;
}
const activeTab = tabs.find((t) => t.id === activeTabId);
if (activeTab?.path === pathname) return;
// Find existing tab with this path
const match = tabs.find((t) => t.path === pathname);
if (match) {
setActiveTabId(match.id);
} else {
// Replace current tab
setTabs((prev) =>
prev.map((t) =>
t.id === activeTabId
? {
...t,
path: pathname,
title: getTitleForPath(pathname),
iconKey: getIconKeyForPath(pathname),
}
: t
)
);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pathname]);
// -----------------------------------------------------------------------
// Actions
// -----------------------------------------------------------------------
const openTab = useCallback(
(
path: string,
title: string,
opts?: { replace?: boolean; iconKey?: string }
) => {
const replace = opts?.replace ?? false;
const iconKey = opts?.iconKey ?? getIconKeyForPath(path);
if (replace) {
// Sidebar nav click: find existing tab with same path or replace current
const existing = tabs.find((t) => t.path === path);
if (existing) {
setActiveTabId(existing.id);
navigatingRef.current = true;
router.push(path);
return;
}
// Replace current active tab
setTabs((prev) =>
prev.map((t) =>
t.id === activeTabId
? { ...t, path, title, iconKey, closeable: false }
: t
)
);
setActiveTabId(activeTabId); // stays the same
navigatingRef.current = true;
router.push(path);
} else {
// Open new tab (e.g., clicking an issue)
const newTab: Tab = {
id: crypto.randomUUID(),
path,
title,
iconKey,
closeable: true,
};
setTabs((prev) => {
const idx = prev.findIndex((t) => t.id === activeTabId);
const next = [...prev];
next.splice(idx + 1, 0, newTab);
return next;
});
setActiveTabId(newTab.id);
navigatingRef.current = true;
router.push(path);
}
},
[tabs, activeTabId, router]
);
const activateTab = useCallback(
(tabId: string) => {
const tab = tabs.find((t) => t.id === tabId);
if (!tab) return;
setActiveTabId(tabId);
navigatingRef.current = true;
router.push(tab.path);
},
[tabs, router]
);
const closeTab = useCallback(
(tabId: string) => {
if (tabs.length <= 1) return;
const idx = tabs.findIndex((t) => t.id === tabId);
if (idx === -1) return;
const next = tabs.filter((t) => t.id !== tabId);
setTabs(next);
if (tabId === activeTabId) {
// Activate neighbor: prefer left, fallback to first
const newActive = next[Math.max(0, idx - 1)];
if (newActive) {
setActiveTabId(newActive.id);
navigatingRef.current = true;
router.push(newActive.path);
}
}
},
[tabs, activeTabId, router]
);
const closeTabByPath = useCallback(
(path: string) => {
const tab = tabs.find((t) => t.path === path);
if (tab) closeTab(tab.id);
},
[tabs, closeTab]
);
const updateTabTitle = useCallback((tabId: string, title: string) => {
setTabs((prev) =>
prev.map((t) => (t.id === tabId ? { ...t, title } : t))
);
}, []);
const reorderTabs = useCallback((oldIndex: number, newIndex: number) => {
setTabs((prev) => arrayMove(prev, oldIndex, newIndex));
}, []);
const value: TabStoreValue = {
tabs,
activeTabId,
openTab,
activateTab,
closeTab,
closeTabByPath,
updateTabTitle,
reorderTabs,
};
return (
<TabStoreContext.Provider value={value}>{children}</TabStoreContext.Provider>
);
}

View file

@ -3,8 +3,6 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = {
transpilePackages: [
"@multica/ui",
"@multica/store",
"@multica/hooks",
"@multica/sdk",
"@multica/types",
"@multica/utils",

View file

@ -15,9 +15,7 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@multica/hooks": "workspace:*",
"@multica/sdk": "workspace:*",
"@multica/store": "workspace:*",
"@multica/types": "workspace:*",
"@multica/ui": "workspace:*",
"@multica/utils": "workspace:*",
@ -25,7 +23,8 @@
"next": "^16.1.6",
"next-themes": "^0.4.6",
"react": "catalog:",
"react-dom": "catalog:"
"react-dom": "catalog:",
"zustand": "catalog:"
},
"devDependencies": {
"@tailwindcss/postcss": "catalog:",

View file

@ -3,7 +3,7 @@
"compilerOptions": {
"plugins": [{ "name": "next" }],
"paths": {
"@/*": ["./src/*"]
"@/*": ["./*"]
},
"module": "ESNext",
"moduleResolution": "bundler",

View file

@ -12,6 +12,7 @@ export default defineConfig({
},
resolve: {
alias: {
"@": path.resolve(__dirname, "."),
"@multica/types": path.resolve(__dirname, "../../packages/types/src"),
"@multica/sdk": path.resolve(__dirname, "../../packages/sdk/src"),
},

9
pnpm-lock.yaml generated
View file

@ -72,15 +72,9 @@ importers:
'@dnd-kit/utilities':
specifier: ^3.2.2
version: 3.2.2(react@19.2.3)
'@multica/hooks':
specifier: workspace:*
version: link:../../packages/hooks
'@multica/sdk':
specifier: workspace:*
version: link:../../packages/sdk
'@multica/store':
specifier: workspace:*
version: link:../../packages/store
'@multica/types':
specifier: workspace:*
version: link:../../packages/types
@ -105,6 +99,9 @@ importers:
react-dom:
specifier: 'catalog:'
version: 19.2.3(react@19.2.3)
zustand:
specifier: 'catalog:'
version: 5.0.12(@types/react@19.2.14)(react@19.2.3)(use-sync-external-store@1.6.0(react@19.2.3))
devDependencies:
'@tailwindcss/postcss':
specifier: 'catalog:'