From a2d7501d5787fa0578393ccefb98dc3dc7fa546c Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Tue, 24 Mar 2026 16:01:23 +0800 Subject: [PATCH] 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) --- CLAUDE.md | 59 ++- apps/web/app/(auth)/login/page.test.tsx | 54 +-- apps/web/app/(auth)/login/page.tsx | 16 +- .../(dashboard)/_components/app-sidebar.tsx | 209 ++++----- .../app/(dashboard)/_components/tab-bar.tsx | 271 ----------- .../app/(dashboard)/_components/tab-link.tsx | 30 -- apps/web/app/(dashboard)/agents/page.tsx | 12 +- apps/web/app/(dashboard)/inbox/page.tsx | 4 +- .../app/(dashboard)/issues/[id]/page.test.tsx | 43 +- apps/web/app/(dashboard)/issues/[id]/page.tsx | 22 +- .../(dashboard)/issues/_components/index.ts | 2 - .../app/(dashboard)/issues/_data/config.ts | 25 - apps/web/app/(dashboard)/issues/page.test.tsx | 33 +- apps/web/app/(dashboard)/issues/page.tsx | 32 +- apps/web/app/(dashboard)/layout.tsx | 26 +- apps/web/app/(dashboard)/settings/page.tsx | 25 +- apps/web/app/layout.tsx | 8 +- apps/web/app/pair/local/page.tsx | 10 +- apps/web/features/auth/index.ts | 2 + apps/web/features/auth/initializer.tsx | 32 ++ apps/web/features/auth/store.ts | 61 +++ .../issues/components}/index.ts | 1 + .../components}/pickers/assignee-picker.tsx | 6 +- .../issues/components}/pickers/index.ts | 0 .../components}/pickers/priority-picker.tsx | 4 +- .../components}/pickers/property-picker.tsx | 0 .../components}/pickers/status-picker.tsx | 4 +- .../issues/components}/priority-icon.tsx | 2 +- .../issues/components}/status-icon.tsx | 2 +- .../issues/config}/index.ts | 0 .../issues/config}/priority.ts | 0 .../issues/config}/status.ts | 0 apps/web/features/issues/index.ts | 2 + apps/web/features/realtime/hooks.ts | 20 + apps/web/features/realtime/index.ts | 2 + .../realtime/provider.tsx} | 17 +- apps/web/features/workspace/hooks.ts | 36 ++ apps/web/features/workspace/index.ts | 2 + apps/web/features/workspace/store.ts | 142 ++++++ apps/web/lib/auth-context.test.tsx | 442 ------------------ apps/web/lib/auth-context.tsx | 274 ----------- apps/web/lib/tab-store.tsx | 357 -------------- apps/web/next.config.ts | 2 - apps/web/package.json | 5 +- apps/web/{lib => shared}/api.ts | 0 apps/web/tsconfig.json | 2 +- apps/web/vitest.config.ts | 1 + pnpm-lock.yaml | 9 +- 48 files changed, 604 insertions(+), 1704 deletions(-) delete mode 100644 apps/web/app/(dashboard)/_components/tab-bar.tsx delete mode 100644 apps/web/app/(dashboard)/_components/tab-link.tsx delete mode 100644 apps/web/app/(dashboard)/issues/_components/index.ts delete mode 100644 apps/web/app/(dashboard)/issues/_data/config.ts create mode 100644 apps/web/features/auth/index.ts create mode 100644 apps/web/features/auth/initializer.tsx create mode 100644 apps/web/features/auth/store.ts rename apps/web/{app/(dashboard)/issues/_components/icons => features/issues/components}/index.ts (55%) rename apps/web/{app/(dashboard)/issues/_components => features/issues/components}/pickers/assignee-picker.tsx (94%) rename apps/web/{app/(dashboard)/issues/_components => features/issues/components}/pickers/index.ts (100%) rename apps/web/{app/(dashboard)/issues/_components => features/issues/components}/pickers/priority-picker.tsx (89%) rename apps/web/{app/(dashboard)/issues/_components => features/issues/components}/pickers/property-picker.tsx (100%) rename apps/web/{app/(dashboard)/issues/_components => features/issues/components}/pickers/status-picker.tsx (90%) rename apps/web/{app/(dashboard)/issues/_components/icons => features/issues/components}/priority-icon.tsx (95%) rename apps/web/{app/(dashboard)/issues/_components/icons => features/issues/components}/status-icon.tsx (98%) rename apps/web/{app/(dashboard)/issues/_config => features/issues/config}/index.ts (100%) rename apps/web/{app/(dashboard)/issues/_config => features/issues/config}/priority.ts (100%) rename apps/web/{app/(dashboard)/issues/_config => features/issues/config}/status.ts (100%) create mode 100644 apps/web/features/issues/index.ts create mode 100644 apps/web/features/realtime/hooks.ts create mode 100644 apps/web/features/realtime/index.ts rename apps/web/{lib/ws-context.tsx => features/realtime/provider.tsx} (75%) create mode 100644 apps/web/features/workspace/hooks.ts create mode 100644 apps/web/features/workspace/index.ts create mode 100644 apps/web/features/workspace/store.ts delete mode 100644 apps/web/lib/auth-context.test.tsx delete mode 100644 apps/web/lib/auth-context.tsx delete mode 100644 apps/web/lib/tab-store.tsx rename apps/web/{lib => shared}/api.ts (100%) diff --git a/CLAUDE.md b/CLAUDE.md index 30dae9d8..f30da4fd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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//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. diff --git a/apps/web/app/(auth)/login/page.test.tsx b/apps/web/app/(auth)/login/page.test.tsx index 84f484ef..e69555d4 100644 --- a/apps/web/app/(auth)/login/page.test.tsx +++ b/apps/web/app/(auth)/login/page.test.tsx @@ -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, - login: mockLogin, - logout: vi.fn(), - refreshMembers: vi.fn(), - refreshAgents: vi.fn(), - getMemberName: () => "Unknown", - getAgentName: () => "Unknown Agent", - getActorName: () => "System", - getActorInitials: () => "XX", -}; +vi.mock("@/features/auth", () => ({ + useAuthStore: (selector: (s: any) => any) => + selector({ + login: mockLogin, + 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(); - // 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(); @@ -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(); @@ -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(); diff --git a/apps/web/app/(auth)/login/page.tsx b/apps/web/app/(auth)/login/page.tsx index 34b2a311..a1d89c16 100644 --- a/apps/web/app/(auth)/login/page.tsx +++ b/apps/web/app/(auth)/login/page.tsx @@ -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); diff --git a/apps/web/app/(dashboard)/_components/app-sidebar.tsx b/apps/web/app/(dashboard)/_components/app-sidebar.tsx index b8999e26..79428b46 100644 --- a/apps/web/app/(dashboard)/_components/app-sidebar.tsx +++ b/apps/web/app/(dashboard)/_components/app-sidebar.tsx @@ -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() { - setShowMenu(!showMenu)}> - - - {workspace?.name ?? "Multica"} - - - + + + + + {workspace?.name ?? "Multica"} + + + + } + /> + + + + {user?.email} + + + + + + Workspaces + + {workspaces.map((ws) => ( + { + if (ws.id !== workspace?.id) { + switchWorkspace(ws.id); + } + }} + > + + {ws.name.charAt(0).toUpperCase()} + + {ws.name} + {ws.id === workspace?.id && ( + + )} + + ))} + setShowCreateDialog(true)}> + + Create workspace + + + + + } + > + + Settings + + + + Sign out + + + + - - {showMenu && ( - <> -
setShowMenu(false)} - /> -
-
- {user?.email} -
-
-
- Workspaces -
- {workspaces.map((ws) => ( - - ))} - -
- setShowMenu(false)} - className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent" - > - - Settings - - -
- - )} {/* Navigation */} @@ -200,12 +197,6 @@ export function AppSidebar() { } - onClick={() => - openTab(item.href, item.label, { - replace: true, - iconKey: item.iconKey, - }) - } > {item.label} @@ -251,9 +242,7 @@ export function AppSidebar() {
- +
- + = { - 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 ; - return ; -} - -// --------------------------------------------------------------------------- -// Context Menu -// --------------------------------------------------------------------------- - -function TabContextMenu({ - x, - y, - tabId, - onClose, -}: { - x: number; - y: number; - tabId: string; - onClose: () => void; -}) { - const { tabs, closeTab } = useTabStore(); - const menuRef = useRef(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 ( -
- - -
- ); -} - -// --------------------------------------------------------------------------- -// 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 ( - - ); -} - -// --------------------------------------------------------------------------- -// 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 ( -
- - t.id)} - strategy={horizontalListSortingStrategy} - > - {tabs.map((tab) => ( - 1} - onContextMenu={handleContextMenu} - /> - ))} - - - - - {contextMenu && ( - setContextMenu(null)} - /> - )} -
- ); -} diff --git a/apps/web/app/(dashboard)/_components/tab-link.tsx b/apps/web/app/(dashboard)/_components/tab-link.tsx deleted file mode 100644 index 5f8a50fa..00000000 --- a/apps/web/app/(dashboard)/_components/tab-link.tsx +++ /dev/null @@ -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, "onClick" | "href">) { - const { openTab } = useTabStore(); - - const handleClick = (e: React.MouseEvent) => { - e.preventDefault(); - openTab(href, title, { replace: false, iconKey }); - }; - - return ( - - {children} - - ); -} diff --git a/apps/web/app/(dashboard)/agents/page.tsx b/apps/web/app/(dashboard)/agents/page.tsx index 64c8104a..c762a8d7 100644 --- a/apps/web/app/(dashboard)/agents/page.tsx +++ b/apps/web/app/(dashboard)/agents/page.tsx @@ -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(""); const [showCreate, setShowCreate] = useState(false); const [runtimes, setRuntimes] = useState([]); diff --git a/apps/web/app/(dashboard)/inbox/page.tsx b/apps/web/app/(dashboard)/inbox/page.tsx index ca3641dd..da8b1237 100644 --- a/apps/web/app/(dashboard)/inbox/page.tsx +++ b/apps/web/app/(dashboard)/inbox/page.tsx @@ -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 diff --git a/apps/web/app/(dashboard)/issues/[id]/page.test.tsx b/apps/web/app/(dashboard)/issues/[id]/page.test.tsx index 7344ff45..6e23b41f 100644 --- a/apps/web/app/(dashboard)/issues/[id]/page.test.tsx +++ b/apps/web/app/(dashboard)/issues/[id]/page.test.tsx @@ -27,16 +27,27 @@ 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 auth store +vi.mock("@/features/auth", () => ({ + useAuthStore: (selector: (s: any) => any) => + selector({ + user: { id: "user-1", name: "Test User", email: "test@multica.ai" }, + 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), diff --git a/apps/web/app/(dashboard)/issues/[id]/page.tsx b/apps/web/app/(dashboard)/issues/[id]/page.tsx index ccc9bae4..76e56bca 100644 --- a/apps/web/app/(dashboard)/issues/[id]/page.tsx +++ b/apps/web/app/(dashboard)/issues/[id]/page.tsx @@ -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(null); const [comments, setComments] = useState([]); 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"); diff --git a/apps/web/app/(dashboard)/issues/_components/index.ts b/apps/web/app/(dashboard)/issues/_components/index.ts deleted file mode 100644 index 8b22c442..00000000 --- a/apps/web/app/(dashboard)/issues/_components/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./icons"; -export * from "./pickers"; diff --git a/apps/web/app/(dashboard)/issues/_data/config.ts b/apps/web/app/(dashboard)/issues/_data/config.ts deleted file mode 100644 index 643f0512..00000000 --- a/apps/web/app/(dashboard)/issues/_data/config.ts +++ /dev/null @@ -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" }, -}; diff --git a/apps/web/app/(dashboard)/issues/page.test.tsx b/apps/web/app/(dashboard)/issues/page.test.tsx index ca753fc9..7ceb96d6 100644 --- a/apps/web/app/(dashboard)/issues/page.test.tsx +++ b/apps/web/app/(dashboard)/issues/page.test.tsx @@ -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) => {children}, -})); - // 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), diff --git a/apps/web/app/(dashboard)/issues/page.tsx b/apps/web/app/(dashboard)/issues/page.tsx index 7d3fab07..31157240 100644 --- a/apps/web/app/(dashboard)/issues/page.tsx +++ b/apps/web/app/(dashboard)/issues/page.tsx @@ -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 (
@@ -124,14 +122,12 @@ function DraggableBoardCard({ issue }: { issue: Issue }) { if (isDragging) e.stopPropagation(); }} > - - +
); } @@ -265,12 +261,10 @@ function BoardView({ // --------------------------------------------------------------------------- function ListRow({ issue }: { issue: Issue }) { - const { getActorName, getActorInitials } = useAuth(); + const { getActorName, getActorInitials } = useActorName(); return ( - @@ -293,7 +287,7 @@ function ListRow({ issue }: { issue: Issue }) { getInitials={getActorInitials} /> )} - + ); } @@ -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("board"); const [issues, setIssues] = useState([]); 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( diff --git a/apps/web/app/(dashboard)/layout.tsx b/apps/web/app/(dashboard)/layout.tsx index 6f83a92e..26b3e7ae 100644 --- a/apps/web/app/(dashboard)/layout.tsx +++ b/apps/web/app/(dashboard)/layout.tsx @@ -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 ( - - - -
- -
- {children} -
-
-
-
+ + + {children} + ); } diff --git a/apps/web/app/(dashboard)/settings/page.tsx b/apps/web/app/(dashboard)/settings/page.tsx index dba1b245..ea117c7b 100644 --- a/apps/web/app/(dashboard)/settings/page.tsx +++ b/apps/web/app/(dashboard)/settings/page.tsx @@ -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 = { 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) { diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index f83a84b2..8c4bc1b7 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -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 > - + {children} - + diff --git a/apps/web/app/pair/local/page.tsx b/apps/web/app/pair/local/page.tsx index 3d2c3b66..f25fae49 100644 --- a/apps/web/app/pair/local/page.tsx +++ b/apps/web/app/pair/local/page.tsx @@ -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(null); const [selectedWorkspaceId, setSelectedWorkspaceId] = useState(""); const [loading, setLoading] = useState(true); diff --git a/apps/web/features/auth/index.ts b/apps/web/features/auth/index.ts new file mode 100644 index 00000000..e0458c48 --- /dev/null +++ b/apps/web/features/auth/index.ts @@ -0,0 +1,2 @@ +export { useAuthStore } from "./store"; +export { AuthInitializer } from "./initializer"; diff --git a/apps/web/features/auth/initializer.tsx b/apps/web/features/auth/initializer.tsx new file mode 100644 index 00000000..21903883 --- /dev/null +++ b/apps/web/features/auth/initializer.tsx @@ -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}; +} diff --git a/apps/web/features/auth/store.ts b/apps/web/features/auth/store.ts new file mode 100644 index 00000000..1d18b279 --- /dev/null +++ b/apps/web/features/auth/store.ts @@ -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; + login: (email: string, name?: string) => Promise; + logout: () => void; + setUser: (user: User) => void; +} + +export const useAuthStore = create((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 }); + }, +})); diff --git a/apps/web/app/(dashboard)/issues/_components/icons/index.ts b/apps/web/features/issues/components/index.ts similarity index 55% rename from apps/web/app/(dashboard)/issues/_components/icons/index.ts rename to apps/web/features/issues/components/index.ts index 03cb734a..8b26196f 100644 --- a/apps/web/app/(dashboard)/issues/_components/icons/index.ts +++ b/apps/web/features/issues/components/index.ts @@ -1,2 +1,3 @@ export { StatusIcon } from "./status-icon"; export { PriorityIcon } from "./priority-icon"; +export { StatusPicker, PriorityPicker, AssigneePicker } from "./pickers"; diff --git a/apps/web/app/(dashboard)/issues/_components/pickers/assignee-picker.tsx b/apps/web/features/issues/components/pickers/assignee-picker.tsx similarity index 94% rename from apps/web/app/(dashboard)/issues/_components/pickers/assignee-picker.tsx rename to apps/web/features/issues/components/pickers/assignee-picker.tsx index dee8b620..0739b918 100644 --- a/apps/web/app/(dashboard)/issues/_components/pickers/assignee-picker.tsx +++ b/apps/web/features/issues/components/pickers/assignee-picker.tsx @@ -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) => diff --git a/apps/web/app/(dashboard)/issues/_components/pickers/index.ts b/apps/web/features/issues/components/pickers/index.ts similarity index 100% rename from apps/web/app/(dashboard)/issues/_components/pickers/index.ts rename to apps/web/features/issues/components/pickers/index.ts diff --git a/apps/web/app/(dashboard)/issues/_components/pickers/priority-picker.tsx b/apps/web/features/issues/components/pickers/priority-picker.tsx similarity index 89% rename from apps/web/app/(dashboard)/issues/_components/pickers/priority-picker.tsx rename to apps/web/features/issues/components/pickers/priority-picker.tsx index 3c66285e..c2c7ecfa 100644 --- a/apps/web/app/(dashboard)/issues/_components/pickers/priority-picker.tsx +++ b/apps/web/features/issues/components/pickers/priority-picker.tsx @@ -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({ diff --git a/apps/web/app/(dashboard)/issues/_components/pickers/property-picker.tsx b/apps/web/features/issues/components/pickers/property-picker.tsx similarity index 100% rename from apps/web/app/(dashboard)/issues/_components/pickers/property-picker.tsx rename to apps/web/features/issues/components/pickers/property-picker.tsx diff --git a/apps/web/app/(dashboard)/issues/_components/pickers/status-picker.tsx b/apps/web/features/issues/components/pickers/status-picker.tsx similarity index 90% rename from apps/web/app/(dashboard)/issues/_components/pickers/status-picker.tsx rename to apps/web/features/issues/components/pickers/status-picker.tsx index 5354801f..5e8fb7b8 100644 --- a/apps/web/app/(dashboard)/issues/_components/pickers/status-picker.tsx +++ b/apps/web/features/issues/components/pickers/status-picker.tsx @@ -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({ diff --git a/apps/web/app/(dashboard)/issues/_components/icons/priority-icon.tsx b/apps/web/features/issues/components/priority-icon.tsx similarity index 95% rename from apps/web/app/(dashboard)/issues/_components/icons/priority-icon.tsx rename to apps/web/features/issues/components/priority-icon.tsx index 64a24752..552556f4 100644 --- a/apps/web/app/(dashboard)/issues/_components/icons/priority-icon.tsx +++ b/apps/web/features/issues/components/priority-icon.tsx @@ -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, diff --git a/apps/web/app/(dashboard)/issues/_components/icons/status-icon.tsx b/apps/web/features/issues/components/status-icon.tsx similarity index 98% rename from apps/web/app/(dashboard)/issues/_components/icons/status-icon.tsx rename to apps/web/features/issues/components/status-icon.tsx index 630b9d30..8b4fc180 100644 --- a/apps/web/app/(dashboard)/issues/_components/icons/status-icon.tsx +++ b/apps/web/features/issues/components/status-icon.tsx @@ -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) diff --git a/apps/web/app/(dashboard)/issues/_config/index.ts b/apps/web/features/issues/config/index.ts similarity index 100% rename from apps/web/app/(dashboard)/issues/_config/index.ts rename to apps/web/features/issues/config/index.ts diff --git a/apps/web/app/(dashboard)/issues/_config/priority.ts b/apps/web/features/issues/config/priority.ts similarity index 100% rename from apps/web/app/(dashboard)/issues/_config/priority.ts rename to apps/web/features/issues/config/priority.ts diff --git a/apps/web/app/(dashboard)/issues/_config/status.ts b/apps/web/features/issues/config/status.ts similarity index 100% rename from apps/web/app/(dashboard)/issues/_config/status.ts rename to apps/web/features/issues/config/status.ts diff --git a/apps/web/features/issues/index.ts b/apps/web/features/issues/index.ts new file mode 100644 index 00000000..df86a53f --- /dev/null +++ b/apps/web/features/issues/index.ts @@ -0,0 +1,2 @@ +export { StatusIcon, PriorityIcon, StatusPicker, PriorityPicker, AssigneePicker } from "./components"; +export * from "./config"; diff --git a/apps/web/features/realtime/hooks.ts b/apps/web/features/realtime/hooks.ts new file mode 100644 index 00000000..0635b9ce --- /dev/null +++ b/apps/web/features/realtime/hooks.ts @@ -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]); +} diff --git a/apps/web/features/realtime/index.ts b/apps/web/features/realtime/index.ts new file mode 100644 index 00000000..1de269d7 --- /dev/null +++ b/apps/web/features/realtime/index.ts @@ -0,0 +1,2 @@ +export { WSProvider, useWS } from "./provider"; +export { useWSEvent } from "./hooks"; diff --git a/apps/web/lib/ws-context.tsx b/apps/web/features/realtime/provider.tsx similarity index 75% rename from apps/web/lib/ws-context.tsx rename to apps/web/features/realtime/provider.tsx index 52f92735..503e3067 100644 --- a/apps/web/lib/ws-context.tsx +++ b/apps/web/features/realtime/provider.tsx @@ -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(null); export function WSProvider({ children }: { children: ReactNode }) { - const { user } = useAuth(); + const user = useAuthStore((s) => s.user); const wsRef = useRef(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]); -} diff --git a/apps/web/features/workspace/hooks.ts b/apps/web/features/workspace/hooks.ts new file mode 100644 index 00000000..325dc46c --- /dev/null +++ b/apps/web/features/workspace/hooks.ts @@ -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 }; +} diff --git a/apps/web/features/workspace/index.ts b/apps/web/features/workspace/index.ts new file mode 100644 index 00000000..b673eac1 --- /dev/null +++ b/apps/web/features/workspace/index.ts @@ -0,0 +1,2 @@ +export { useWorkspaceStore } from "./store"; +export { useActorName } from "./hooks"; diff --git a/apps/web/features/workspace/store.ts b/apps/web/features/workspace/store.ts new file mode 100644 index 00000000..475e69dc --- /dev/null +++ b/apps/web/features/workspace/store.ts @@ -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; + switchWorkspace: (workspaceId: string) => Promise; + refreshWorkspaces: () => Promise; + refreshMembers: () => Promise; + refreshAgents: () => Promise; + createWorkspace: (data: { + name: string; + slug: string; + description?: string; + }) => Promise; + updateWorkspace: (ws: Workspace) => void; + leaveWorkspace: (workspaceId: string) => Promise; + deleteWorkspace: (workspaceId: string) => Promise; + clearWorkspace: () => void; +} + +type WorkspaceStore = WorkspaceState & WorkspaceActions; + +export const useWorkspaceStore = create((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: [] }); + }, +})); diff --git a/apps/web/lib/auth-context.test.tsx b/apps/web/lib/auth-context.test.tsx deleted file mode 100644 index cb8b3763..00000000 --- a/apps/web/lib/auth-context.test.tsx +++ /dev/null @@ -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 {children}; -} - -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(); - }); -}); diff --git a/apps/web/lib/auth-context.tsx b/apps/web/lib/auth-context.tsx deleted file mode 100644 index 280812c6..00000000 --- a/apps/web/lib/auth-context.tsx +++ /dev/null @@ -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; - logout: () => void; - switchWorkspace: (workspaceId: string) => Promise; - createWorkspace: (data: { name: string; slug: string; description?: string }) => Promise; - updateWorkspace: (ws: Workspace) => void; - updateCurrentUser: (nextUser: User) => void; - leaveWorkspace: (workspaceId: string) => Promise; - deleteWorkspace: (workspaceId: string) => Promise; - refreshWorkspaces: () => Promise; - refreshMembers: () => Promise; - refreshAgents: () => Promise; - getMemberName: (userId: string) => string; - getAgentName: (agentId: string) => string; - getActorName: (type: string, id: string) => string; - getActorInitials: (type: string, id: string) => string; -} - -const AuthContext = createContext(null); - -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(); - - 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 ( - - {children} - - ); -} - -export function useAuth() { - const ctx = useContext(AuthContext); - if (!ctx) throw new Error("useAuth must be used within AuthProvider"); - return ctx; -} diff --git a/apps/web/lib/tab-store.tsx b/apps/web/lib/tab-store.tsx deleted file mode 100644 index e70635cf..00000000 --- a/apps/web/lib/tab-store.tsx +++ /dev/null @@ -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 = { - "/inbox": "Inbox", - "/agents": "Agents", - "/issues": "All Issues", - "/knowledge-base": "Knowledge Base", - "/settings": "Settings", -}; - -const ROUTE_ICON_KEYS: Record = { - "/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(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(() => { - 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(() => { - 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 ( - {children} - ); -} diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index 162d8178..90c341d2 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -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", diff --git a/apps/web/package.json b/apps/web/package.json index e64ff3bf..887c0e84 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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:", diff --git a/apps/web/lib/api.ts b/apps/web/shared/api.ts similarity index 100% rename from apps/web/lib/api.ts rename to apps/web/shared/api.ts diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 2242babc..9428e426 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -3,7 +3,7 @@ "compilerOptions": { "plugins": [{ "name": "next" }], "paths": { - "@/*": ["./src/*"] + "@/*": ["./*"] }, "module": "ESNext", "moduleResolution": "bundler", diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts index 5202026f..8e10b87f 100644 --- a/apps/web/vitest.config.ts +++ b/apps/web/vitest.config.ts @@ -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"), }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 633a5bc9..5cf1739a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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:'