diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index afe63e0e..9ce4d13e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,17 +29,8 @@ jobs: - name: Install dependencies run: pnpm install - - name: Restore Turbo cache - uses: actions/cache@v5 - with: - path: .turbo - key: turbo-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ github.sha }} - restore-keys: | - turbo-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}- - turbo-${{ runner.os }}- - - name: Build, type check, and test - run: pnpm turbo build typecheck test + run: pnpm build && pnpm typecheck && pnpm test backend: runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 7d7617b7..aed07640 100644 --- a/.gitignore +++ b/.gitignore @@ -7,7 +7,6 @@ dist # build outputs .next out -.turbo build bin dist-electron 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/Makefile b/Makefile index 8d5811e3..2fb7fcc4 100644 --- a/Makefile +++ b/Makefile @@ -119,7 +119,7 @@ dev: cd server && go run ./cmd/server daemon: - cd server && MULTICA_REPOS_ROOT="${MULTICA_REPOS_ROOT:-$(abspath .)}" go run ./cmd/multica daemon + cd server && go run ./cmd/multica daemon cli: cd server && go run ./cmd/multica $(ARGS) diff --git a/README.md b/README.md index 4fa32aaa..f2e1399f 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,79 @@ That keeps one Docker container and one volume, while still isolating schema and | `make setup-main` / `make start-main` | Force use of the shared main `.env` | | `make setup-worktree` / `make start-worktree` | Force use of isolated `.env.worktree` | +## CLI (`multica`) + +The CLI manages authentication, workspace configuration, and the local agent daemon. + +### Install + +```bash +brew tap multica-ai/tap +brew install multica-cli +``` + +Or build from source: + +```bash +make build +cp server/bin/multica /usr/local/bin/multica # or ~/.local/bin/multica +``` + +### Authentication + +```bash +multica auth login # Open browser to authenticate (one-click if already logged in) +multica auth login --token # Paste a personal access token manually +multica auth status # Show current auth status +multica auth logout # Remove stored token +``` + +Credentials are saved to `~/.multica/config.json`. + +### Workspaces + +```bash +multica workspace list # List all workspaces you belong to +``` + +### Daemon Watch List + +The daemon monitors one or more workspaces for tasks. Manage which workspaces are watched: + +```bash +multica workspace watch # Add a workspace to the watch list +multica workspace unwatch # Remove a workspace from the watch list +multica workspace list # Show all workspaces (watched ones marked with *) +``` + +The watch list is stored in `~/.multica/config.json`. Changes are picked up by a running daemon within 5 seconds (hot-reload). + +### Local Agent Daemon + +The daemon polls watched workspaces for tasks and executes them using locally installed AI agents (Claude Code, Codex). + +```bash +# 1. Authenticate +multica auth login + +# 2. Add workspaces to watch +multica workspace watch + +# 3. Start the daemon +multica daemon +``` + +The daemon auto-detects available agent CLIs (`claude`, `codex`) on your PATH. When a task is claimed, it creates an isolated execution environment, runs the agent, and reports results back to the server. + +### Other Commands + +```bash +multica agent list # List agents in the current workspace +multica runtime list # List registered runtimes +multica config show # Show CLI configuration +multica version # Show CLI version +``` + ## Environment Variables See [`.env.example`](.env.example) for all available variables: @@ -110,31 +183,14 @@ See [`.env.example`](.env.example) for all available variables: - `PORT` — Backend server port (default: 8080) - `FRONTEND_PORT` / `FRONTEND_ORIGIN` — Frontend port and browser origin - `JWT_SECRET` — JWT signing secret -- `MULTICA_APP_URL` — Browser origin used when generating local runtime pairing links -- `MULTICA_DAEMON_CONFIG` — Optional path for the daemon's persisted local config -- `MULTICA_WORKSPACE_ID` — Optional dev override for the workspace id; normal usage should rely on browser pairing instead -- `MULTICA_DAEMON_ID` / `MULTICA_DAEMON_DEVICE_NAME` — Stable daemon identity for local runtime registration -- `MULTICA_CODEX_PATH` / `MULTICA_CODEX_MODEL` — Codex executable and optional model override for local task execution -- `MULTICA_CODEX_WORKDIR` — Default working directory used by the local Codex runtime -- `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` — Google OAuth (optional) +- `MULTICA_APP_URL` — Browser origin for CLI login callback (default: `http://localhost:3000`) +- `MULTICA_DAEMON_ID` / `MULTICA_DAEMON_DEVICE_NAME` — Stable daemon identity for runtime registration +- `MULTICA_CLAUDE_PATH` / `MULTICA_CLAUDE_MODEL` — Claude Code executable and optional model override +- `MULTICA_CODEX_PATH` / `MULTICA_CODEX_MODEL` — Codex executable and optional model override +- `MULTICA_WORKSPACES_ROOT` — Base directory for agent execution environments (default: `~/multica_workspaces`) - `NEXT_PUBLIC_API_URL` — Frontend → backend API URL - `NEXT_PUBLIC_WS_URL` — Frontend → backend WebSocket URL -## Local Codex Daemon - -The local daemon currently supports one local runtime type: `codex`. - -1. Start the daemon with `make daemon`. -2. If the daemon does not already know its workspace, it prints a pairing link in the terminal. -3. Open that link in the browser, sign in, and choose the workspace that should own the local Codex runtime. -4. The daemon stores the approved workspace locally in `MULTICA_DAEMON_CONFIG` or `~/.multica/daemon.json`. -5. The daemon registers the local Codex runtime via `/api/daemon/register`. -6. Create an agent in Multica and bind it to that runtime. -7. Assign an issue to the agent and move the issue to `todo`. -8. The daemon claims the task, runs `codex exec`, and reports the final comment back to the issue. - -For local development you can still set `MULTICA_WORKSPACE_ID` directly to skip pairing, but that should be treated as a debug shortcut rather than the normal flow. - ## Local Development Notes - `make setup`, `make start`, `make dev`, and `make test` now require an env file. They fail fast if `.env` or `.env.worktree` is missing. diff --git a/apps/web/app/(auth)/login/page.test.tsx b/apps/web/app/(auth)/login/page.test.tsx index 50152d87..0f5e3146 100644 --- a/apps/web/app/(auth)/login/page.test.tsx +++ b/apps/web/app/(auth)/login/page.test.tsx @@ -17,7 +17,6 @@ vi.mock("@/features/auth", () => ({ selector({ sendCode: mockSendCode, verifyCode: mockVerifyCode, - isLoading: false, }), })); @@ -34,6 +33,9 @@ vi.mock("@/features/workspace", () => ({ vi.mock("@/shared/api", () => ({ api: { listWorkspaces: vi.fn().mockResolvedValue([]), + verifyCode: vi.fn(), + setToken: vi.fn(), + getMe: vi.fn(), }, })); @@ -44,14 +46,14 @@ describe("LoginPage", () => { vi.clearAllMocks(); }); - it("renders email form with heading and button", () => { + it("renders login form with email input and continue 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 }) + screen.getByRole("button", { name: "Continue" }) ).toBeInTheDocument(); }); @@ -59,25 +61,21 @@ describe("LoginPage", () => { const user = userEvent.setup(); render(); - await user.click(screen.getByRole("button", { name: /continue/i })); + await user.click(screen.getByRole("button", { name: "Continue" })); expect(mockSendCode).not.toHaveBeenCalled(); }); - it("calls sendCode on submit and shows code step", async () => { + it("calls sendCode with email on submit", async () => { mockSendCode.mockResolvedValueOnce(undefined); 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: "Continue" })); await waitFor(() => { expect(mockSendCode).toHaveBeenCalledWith("test@multica.ai"); }); - - await waitFor(() => { - expect(screen.getByText("Check your email")).toBeInTheDocument(); - }); }); it("shows 'Sending code...' while submitting", async () => { @@ -86,40 +84,36 @@ describe("LoginPage", () => { 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: "Continue" })); await waitFor(() => { expect(screen.getByText("Sending code...")).toBeInTheDocument(); }); }); + it("shows verification code step after sending code", async () => { + mockSendCode.mockResolvedValueOnce(undefined); + const user = userEvent.setup(); + render(); + + await user.type(screen.getByLabelText("Email"), "test@multica.ai"); + await user.click(screen.getByRole("button", { name: "Continue" })); + + await waitFor(() => { + expect(screen.getByText("Check your email")).toBeInTheDocument(); + }); + }); + it("shows error when sendCode fails", async () => { mockSendCode.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: "Continue" })); await waitFor(() => { expect(screen.getByText("Network error")).toBeInTheDocument(); }); }); - - it("shows back button on code step", async () => { - mockSendCode.mockResolvedValueOnce(undefined); - const user = userEvent.setup(); - render(); - - await user.type(screen.getByLabelText("Email"), "test@multica.ai"); - await user.click(screen.getByRole("button", { name: /continue/i })); - - await waitFor(() => { - expect(screen.getByText("Check your email")).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 de94a4ee..215a34ae 100644 --- a/apps/web/app/(auth)/login/page.tsx +++ b/apps/web/app/(auth)/login/page.tsx @@ -21,6 +21,28 @@ import { InputOTPGroup, InputOTPSlot, } from "@/components/ui/input-otp"; +import type { User } from "@/shared/types"; + +function validateCliCallback(cliCallback: string): boolean { + try { + const cbUrl = new URL(cliCallback); + if (cbUrl.protocol !== "http:") return false; + if (cbUrl.hostname !== "localhost" && cbUrl.hostname !== "127.0.0.1") + return false; + return true; + } catch { + return false; + } +} + +function redirectToCliCallback( + cliCallback: string, + token: string, + cliState: string +) { + const separator = cliCallback.includes("?") ? "&" : "?"; + window.location.href = `${cliCallback}${separator}token=${encodeURIComponent(token)}&state=${encodeURIComponent(cliState)}`; +} function LoginPageContent() { const router = useRouter(); @@ -29,12 +51,38 @@ function LoginPageContent() { const hydrateWorkspace = useWorkspaceStore((s) => s.hydrateWorkspace); const searchParams = useSearchParams(); - const [step, setStep] = useState<"email" | "code">("email"); + const [step, setStep] = useState<"email" | "code" | "cli_confirm">("email"); const [email, setEmail] = useState(""); const [code, setCode] = useState(""); const [error, setError] = useState(""); const [submitting, setSubmitting] = useState(false); const [cooldown, setCooldown] = useState(0); + const [existingUser, setExistingUser] = useState(null); + + // Check for existing session when CLI callback is present. + useEffect(() => { + const cliCallback = searchParams.get("cli_callback"); + if (!cliCallback) return; + + const token = localStorage.getItem("multica_token"); + if (!token) return; + + if (!validateCliCallback(cliCallback)) return; + + // Verify the existing token is still valid. + api.setToken(token); + api + .getMe() + .then((user) => { + setExistingUser(user); + setStep("cli_confirm"); + }) + .catch(() => { + // Token expired/invalid — clear and fall through to normal login. + api.setToken(null); + localStorage.removeItem("multica_token"); + }); + }, [searchParams]); useEffect(() => { if (cooldown <= 0) return; @@ -42,6 +90,15 @@ function LoginPageContent() { return () => clearTimeout(timer); }, [cooldown]); + const handleCliAuthorize = async () => { + const cliCallback = searchParams.get("cli_callback"); + const token = localStorage.getItem("multica_token"); + if (!cliCallback || !token) return; + const cliState = searchParams.get("cli_state") || ""; + setSubmitting(true); + redirectToCliCallback(cliCallback, token, cliState); + }; + const handleSendCode = async (e?: React.FormEvent) => { e?.preventDefault(); if (!email) { @@ -57,7 +114,9 @@ function LoginPageContent() { setCooldown(10); } catch (err) { setError( - err instanceof Error ? err.message : "Failed to send code. Make sure the server is running." + err instanceof Error + ? err.message + : "Failed to send code. Make sure the server is running." ); } finally { setSubmitting(false); @@ -72,29 +131,14 @@ function LoginPageContent() { try { const cliCallback = searchParams.get("cli_callback"); if (cliCallback) { - // CLI browser login: verify code, get JWT, redirect to CLI callback. - // Only allow http://localhost callbacks to prevent open redirect / JWT theft. - try { - const cbUrl = new URL(cliCallback); - if (cbUrl.protocol !== "http:") { - setError("Invalid callback URL"); - setSubmitting(false); - return; - } - if (cbUrl.hostname !== "localhost" && cbUrl.hostname !== "127.0.0.1") { - setError("Invalid callback URL"); - setSubmitting(false); - return; - } - } catch { + if (!validateCliCallback(cliCallback)) { setError("Invalid callback URL"); setSubmitting(false); return; } const { token } = await api.verifyCode(email, value); const cliState = searchParams.get("cli_state") || ""; - const separator = cliCallback.includes("?") ? "&" : "?"; - window.location.href = `${cliCallback}${separator}token=${encodeURIComponent(token)}&state=${encodeURIComponent(cliState)}`; + redirectToCliCallback(cliCallback, token, cliState); return; } @@ -126,6 +170,46 @@ function LoginPageContent() { } }; + // CLI confirm step: user is already logged in, just authorize. + if (step === "cli_confirm" && existingUser) { + return ( +
+ + + Authorize CLI + + Allow the CLI to access Multica as{" "} + + {existingUser.email} + + ? + + + + + + + +
+ ); + } + if (step === "code") { return (
diff --git a/apps/web/app/(dashboard)/_components/app-sidebar.tsx b/apps/web/app/(dashboard)/_components/app-sidebar.tsx index b703e359..5cb3a520 100644 --- a/apps/web/app/(dashboard)/_components/app-sidebar.tsx +++ b/apps/web/app/(dashboard)/_components/app-sidebar.tsx @@ -14,7 +14,6 @@ import { Plus, Check, Sparkles, - Search, SquarePen, } from "lucide-react"; import { WorkspaceAvatar } from "@/features/workspace"; @@ -27,6 +26,7 @@ import { SidebarMenu, SidebarMenuButton, SidebarMenuItem, + SidebarRail, } from "@/components/ui/sidebar"; import { DropdownMenu, @@ -157,25 +157,15 @@ export function AppSidebar() { -
- - - - - Search - - - useModalStore.getState().open("create-issue")} - > - - - New issue - -
+ + useModalStore.getState().open("create-issue")} + > + + + New issue +
@@ -230,6 +220,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 ( +