diff --git a/CLAUDE.md b/CLAUDE.md index 179e4728..77ef183f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,21 +12,20 @@ Multica is an AI-native task management platform — like Linear, but with AI ag ## Architecture -**Polyglot monorepo** — Go backend + TypeScript frontend. +**Go backend + standalone Next.js frontend.** - `server/` — Go backend (Chi router, sqlc for DB, gorilla/websocket for real-time) -- `apps/web/` — Next.js 16 frontend (App Router) -- `packages/` — Shared TypeScript packages (ui, types, sdk, utils) +- `apps/web/` — Next.js 16 frontend (App Router) — self-contained, no shared package dependencies ### Web App Structure (`apps/web/`) -The frontend uses a **feature-based architecture** with three layers: +The frontend uses a **feature-based architecture** with four layers: ``` apps/web/ ├── app/ # Routing layer (thin shells — import from features/) ├── features/ # Business logic, organized by domain -├── shared/ # Cross-feature utilities (api client) +├── shared/ # Cross-feature utilities (api client, types, logger) ``` **`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/`. @@ -43,7 +42,10 @@ apps/web/ | `features/modals/` | Modal registry and state | Modal store and components | | `features/skills/` | Skill management | Skill components | -**`shared/`** — Code used across multiple features. Currently only `api.ts` (SDK singleton). +**`shared/`** — Code used across multiple features: +- `shared/api/` — `ApiClient` (REST) and `WSClient` (WebSocket) for backend communication, plus the `api` singleton. +- `shared/types/` — Domain types (Issue, Agent, Workspace, etc.) and WebSocket event types. +- `shared/logger.ts` — Logger utility. ### State Management @@ -63,6 +65,7 @@ apps/web/ Use `@/` alias (maps to `apps/web/`): ```typescript import { api } from "@/shared/api"; +import type { Issue } from "@/shared/types"; import { useAuthStore } from "@/features/auth"; import { useWorkspaceStore } from "@/features/workspace"; import { useIssueStore } from "@/features/issues"; @@ -76,8 +79,8 @@ Within a feature, use relative imports. Between features or to shared, use `@/`. ### Data Flow ``` -Browser → ApiClient (SDK) → REST API (Chi handlers) → sqlc queries → PostgreSQL -Browser ← WSClient (SDK) ← WebSocket ← Hub.Broadcast() ← Handlers/TaskService +Browser → ApiClient (shared/api) → REST API (Chi handlers) → sqlc queries → PostgreSQL +Browser ← WSClient (shared/api) ← WebSocket ← Hub.Broadcast() ← Handlers/TaskService ``` ### Backend Structure (`server/`) @@ -94,13 +97,6 @@ Browser ← WSClient (SDK) ← WebSocket ← Hub.Broadcast() ← Handlers/TaskSe - **Database**: sqlc generates Go code from SQL in `pkg/db/queries/` → `pkg/db/generated/`. Migrations in `migrations/`. - **Routes** (`cmd/server/router.go`): Public routes (auth, health, ws) + protected routes (require JWT) + daemon routes (unauthenticated, separate auth model). -### Key Packages - -- **`@multica/sdk`**: `ApiClient` (REST) and `WSClient` (WebSocket) classes. All backend communication goes through here. -- **`@multica/types`**: Shared domain types + WebSocket event types (issue:created/updated/deleted, task:*, agent:status, comment:*, inbox:new, daemon:*). -- **`@multica/ui`**: shadcn/ui component library with Radix primitives, Tailwind CSS 4, Shiki syntax highlighting for markdown. -- **`@multica/utils`**: Shared utility functions used across apps and packages. - ### Multi-tenancy All queries filter by `workspace_id`. Membership checks gate access. `X-Workspace-ID` header routes requests to the correct workspace. @@ -121,7 +117,7 @@ make db-down # Stop the shared PostgreSQL container # Frontend pnpm install pnpm dev:web # Next.js dev server (port 3000) -pnpm build # Build all TS packages +pnpm build # Build frontend pnpm typecheck # TypeScript check pnpm test # TS tests (Vitest) @@ -170,10 +166,8 @@ make start-worktree # Start using .env.worktree ## UI/UX Rules -- 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. 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`. +- Prefer shadcn components over custom implementations. Install missing components via `npx shadcn add`. +- **Feature-specific components** → `features//components/` — issue icons, pickers, and other domain-bound UI live inside their feature module. - 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. Prefer zustand stores for shared state over React Context. - Pay close attention to **overflow** (truncate long text, scrollable containers), **alignment**, and **spacing** consistency. diff --git a/apps/web/app/(auth)/login/page.test.tsx b/apps/web/app/(auth)/login/page.test.tsx index 50152d87..e69555d4 100644 --- a/apps/web/app/(auth)/login/page.test.tsx +++ b/apps/web/app/(auth)/login/page.test.tsx @@ -10,13 +10,11 @@ vi.mock("next/navigation", () => ({ })); // Mock auth store -const mockSendCode = vi.fn(); -const mockVerifyCode = vi.fn(); +const mockLogin = vi.fn(); vi.mock("@/features/auth", () => ({ useAuthStore: (selector: (s: any) => any) => selector({ - sendCode: mockSendCode, - verifyCode: mockVerifyCode, + login: mockLogin, isLoading: false, }), })); @@ -44,82 +42,78 @@ describe("LoginPage", () => { vi.clearAllMocks(); }); - it("renders email form with heading and button", () => { + it("renders login form with heading, inputs, and button", () => { render(); expect(screen.getByText("Multica")).toBeInTheDocument(); expect(screen.getByText("AI-native task management")).toBeInTheDocument(); expect(screen.getByLabelText("Email")).toBeInTheDocument(); - expect( - screen.getByRole("button", { name: /continue/i }) - ).toBeInTheDocument(); + expect(screen.getByLabelText("Name")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /sign in/i })).toBeInTheDocument(); }); - it("does not call sendCode when email is empty", async () => { + it("does not call login when email is empty", async () => { const user = userEvent.setup(); render(); - await user.click(screen.getByRole("button", { name: /continue/i })); - expect(mockSendCode).not.toHaveBeenCalled(); + await user.click(screen.getByRole("button", { name: "Sign in" })); + expect(mockLogin).not.toHaveBeenCalled(); }); - it("calls sendCode on submit and shows code step", async () => { - mockSendCode.mockResolvedValueOnce(undefined); + it("calls login with correct args on submit", async () => { + mockLogin.mockResolvedValueOnce({ id: "u1", name: "Test User" }); + mockHydrateWorkspace.mockResolvedValueOnce(null); const user = userEvent.setup(); render(); await user.type(screen.getByLabelText("Email"), "test@multica.ai"); - await user.click(screen.getByRole("button", { name: /continue/i })); + await user.type(screen.getByLabelText("Name"), "Test User"); + await user.click(screen.getByRole("button", { name: "Sign in" })); await waitFor(() => { - expect(mockSendCode).toHaveBeenCalledWith("test@multica.ai"); - }); - - await waitFor(() => { - expect(screen.getByText("Check your email")).toBeInTheDocument(); + expect(mockLogin).toHaveBeenCalledWith("test@multica.ai", "Test User"); }); }); - it("shows 'Sending code...' while submitting", async () => { - mockSendCode.mockReturnValueOnce(new Promise(() => {})); + it("calls login with email only when name is empty", async () => { + mockLogin.mockResolvedValueOnce({ id: "u1", name: "" }); + mockHydrateWorkspace.mockResolvedValueOnce(null); const user = userEvent.setup(); render(); await user.type(screen.getByLabelText("Email"), "test@multica.ai"); - await user.click(screen.getByRole("button", { name: /continue/i })); + await user.click(screen.getByRole("button", { name: "Sign in" })); await waitFor(() => { - expect(screen.getByText("Sending code...")).toBeInTheDocument(); + expect(mockLogin).toHaveBeenCalledWith("test@multica.ai", undefined); }); }); - it("shows error when sendCode fails", async () => { - mockSendCode.mockRejectedValueOnce(new Error("Network error")); + it("shows 'Signing in...' while submitting", async () => { + mockLogin.mockReturnValueOnce(new Promise(() => {})); const user = userEvent.setup(); render(); await user.type(screen.getByLabelText("Email"), "test@multica.ai"); - await user.click(screen.getByRole("button", { name: /continue/i })); + await user.click(screen.getByRole("button", { name: "Sign in" })); await waitFor(() => { - expect(screen.getByText("Network error")).toBeInTheDocument(); + expect(screen.getByText("Signing in...")).toBeInTheDocument(); }); }); - it("shows back button on code step", async () => { - mockSendCode.mockResolvedValueOnce(undefined); + it("shows error when login fails", async () => { + mockLogin.mockRejectedValueOnce(new Error("Network error")); const user = userEvent.setup(); render(); await user.type(screen.getByLabelText("Email"), "test@multica.ai"); - await user.click(screen.getByRole("button", { name: /continue/i })); + await user.click(screen.getByRole("button", { name: "Sign in" })); await waitFor(() => { - expect(screen.getByText("Check your email")).toBeInTheDocument(); + expect( + screen.getByText("Login failed. Make sure the server is running."), + ).toBeInTheDocument(); }); - - expect( - screen.getByRole("button", { name: /back/i }) - ).toBeInTheDocument(); }); }); diff --git a/apps/web/app/(auth)/login/page.tsx b/apps/web/app/(auth)/login/page.tsx index abe2ac12..215a34ae 100644 --- a/apps/web/app/(auth)/login/page.tsx +++ b/apps/web/app/(auth)/login/page.tsx @@ -21,7 +21,7 @@ import { InputOTPGroup, InputOTPSlot, } from "@/components/ui/input-otp"; -import type { User } from "@multica/types"; +import type { User } from "@/shared/types"; function validateCliCallback(cliCallback: string): boolean { try { diff --git a/apps/web/app/(dashboard)/_components/app-sidebar.tsx b/apps/web/app/(dashboard)/_components/app-sidebar.tsx index c3caca71..101b59a2 100644 --- a/apps/web/app/(dashboard)/_components/app-sidebar.tsx +++ b/apps/web/app/(dashboard)/_components/app-sidebar.tsx @@ -13,7 +13,6 @@ import { Plus, Check, Sparkles, - Search, SquarePen, } from "lucide-react"; import { WorkspaceAvatar } from "@/features/workspace"; @@ -26,6 +25,7 @@ import { SidebarMenu, SidebarMenuButton, SidebarMenuItem, + SidebarRail, } from "@/components/ui/sidebar"; import { DropdownMenu, @@ -155,25 +155,15 @@ export function AppSidebar() { -
- - - - - Search - - - useModalStore.getState().open("create-issue")} - > - - - New issue - -
+ + useModalStore.getState().open("create-issue")} + > + + + New issue + @@ -228,6 +218,7 @@ export function AppSidebar() { + ); } diff --git a/apps/web/app/(dashboard)/agents/page.tsx b/apps/web/app/(dashboard)/agents/page.tsx index d27e3ee4..c020587b 100644 --- a/apps/web/app/(dashboard)/agents/page.tsx +++ b/apps/web/app/(dashboard)/agents/page.tsx @@ -1,6 +1,7 @@ "use client"; import { useState, useEffect, useCallback } from "react"; +import { useDefaultLayout } from "react-resizable-panels"; import { Bot, Cloud, @@ -32,7 +33,7 @@ import type { RuntimeDevice, CreateAgentRequest, UpdateAgentRequest, -} from "@multica/types"; +} from "@/shared/types"; import { Dialog, DialogContent, @@ -41,6 +42,11 @@ import { DialogDescription, DialogFooter, } from "@/components/ui/dialog"; +import { + ResizablePanelGroup, + ResizablePanel, + ResizableHandle, +} from "@/components/ui/resizable"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -1134,6 +1140,9 @@ export default function AgentsPage() { const [selectedId, setSelectedId] = useState(""); const [showCreate, setShowCreate] = useState(false); const [runtimes, setRuntimes] = useState([]); + const { defaultLayout, onLayoutChanged } = useDefaultLayout({ + id: "multica_agents_layout", + }); useEffect(() => { if (!workspace) { @@ -1191,70 +1200,81 @@ export default function AgentsPage() { } return ( -
- {/* Left column — agent list */} -
-
-

Agents

- + + + {/* Left column — agent list */} +
+
+

Agents

+ +
+ {agents.length === 0 ? ( +
+ +

No agents yet

+ +
+ ) : ( +
+ {agents.map((agent) => ( + setSelectedId(agent.id)} + /> + ))} +
+ )}
- {agents.length === 0 ? ( -
- -

No agents yet

- -
- ) : ( -
- {agents.map((agent) => ( - setSelectedId(agent.id)} - /> - ))} -
- )} -
+ - {/* Right column — agent detail */} -
- {selected ? ( - - ) : ( -
- -

Select an agent to view details

- -
- )} -
+ + + + {/* Right column — agent detail */} +
+ {selected ? ( + + ) : ( +
+ +

Select an agent to view details

+ +
+ )} +
+
{showCreate && ( )} -
+ ); } diff --git a/apps/web/app/(dashboard)/inbox/page.tsx b/apps/web/app/(dashboard)/inbox/page.tsx index 32440798..e64194c7 100644 --- a/apps/web/app/(dashboard)/inbox/page.tsx +++ b/apps/web/app/(dashboard)/inbox/page.tsx @@ -1,6 +1,7 @@ "use client"; import { useState, useMemo } from "react"; +import { useDefaultLayout } from "react-resizable-panels"; import { useInboxStore } from "@/features/inbox"; import { IssueDetail, StatusIcon } from "@/features/issues/components"; import { ActorAvatar } from "@/components/common/actor-avatar"; @@ -13,8 +14,13 @@ import { BookCheck, ListChecks, } from "lucide-react"; -import type { InboxItem, InboxItemType, InboxSeverity } from "@multica/types"; +import type { InboxItem, InboxItemType, InboxSeverity } from "@/shared/types"; import { Button } from "@/components/ui/button"; +import { + ResizablePanelGroup, + ResizablePanel, + ResizableHandle, +} from "@/components/ui/resizable"; import { Skeleton } from "@/components/ui/skeleton"; import { DropdownMenu, @@ -82,25 +88,25 @@ function InboxListItem({ />
- - {item.title} - -
- {item.issue_status && ( - - )} +
{!item.read && ( - + )} + + {item.title} +
+ {item.issue_status && ( + + )}
-

+

{typeLabels[item.type] ?? item.type}

- + {timeAgo(item.created_at)}
@@ -119,6 +125,10 @@ export default function InboxPage() { const storeItems = useInboxStore((s) => s.items); const loading = useInboxStore((s) => s.loading); + const { defaultLayout, onLayoutChanged } = useDefaultLayout({ + id: "multica_inbox_layout", + }); + // Sort: severity first, then newest first const items = useMemo(() => { return [...storeItems] @@ -202,40 +212,46 @@ export default function InboxPage() { if (loading) { return ( -
-
-
- -
-
- {Array.from({ length: 5 }).map((_, i) => ( -
- -
- - + + +
+
+ +
+
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ +
+ + +
-
- ))} + ))} +
-
-
- - -
-
+ + + +
+ + +
+
+ ); } return ( -
+ + {/* Left column — inbox list */} -
+

Inbox

{unreadCount > 0 && ( - + {unreadCount} )} @@ -280,7 +296,7 @@ export default function InboxPage() {

No notifications

) : ( -
+
{items.map((item) => ( )}
- + + + {/* Right column — detail */} -
+
{selected?.issue_id ? ( { handleArchive(selected.id); }} @@ -336,6 +353,7 @@ export default function InboxPage() {
)}
-
+ + ); } diff --git a/apps/web/app/(dashboard)/issues/[id]/page.test.tsx b/apps/web/app/(dashboard)/issues/[id]/page.test.tsx index c2481acb..05fd853a 100644 --- a/apps/web/app/(dashboard)/issues/[id]/page.test.tsx +++ b/apps/web/app/(dashboard)/issues/[id]/page.test.tsx @@ -1,8 +1,8 @@ -import { Suspense } from "react"; +import { Suspense, forwardRef, useRef, useState, useImperativeHandle } from "react"; import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen, waitFor, act } from "@testing-library/react"; +import { render, screen, waitFor, act, fireEvent } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import type { Issue, Comment } from "@multica/types"; +import type { Issue, Comment } from "@/shared/types"; // Mock next/navigation vi.mock("next/navigation", () => ({ @@ -71,6 +71,41 @@ vi.mock("@/components/ui/calendar", () => ({ Calendar: () => null, })); +// Mock RichTextEditor (Tiptap needs real DOM) +vi.mock("@/components/common/rich-text-editor", () => ({ + RichTextEditor: forwardRef(({ defaultValue, onUpdate, placeholder, onSubmit }: any, ref: any) => { + const valueRef = useRef(defaultValue || ""); + const [value, setValue] = useState(defaultValue || ""); + useImperativeHandle(ref, () => ({ + getMarkdown: () => valueRef.current, + clearContent: () => { valueRef.current = ""; setValue(""); }, + focus: () => {}, + })); + return ( +