Compare commits

..

1 Commits

Author SHA1 Message Date
Jiayuan
2a515fc0be feat(web): change Done status label color to purple
Add a dedicated `--done` CSS color token (oklch hue 300) for the Done
status instead of reusing the `--info` token (blue), so other info-colored
elements remain unaffected.
2026-04-04 15:49:06 +08:00
683 changed files with 10349 additions and 53118 deletions

View File

@@ -1,38 +0,0 @@
# Dependencies
node_modules
.pnpm-store
# Build outputs
.next
dist
server/bin
server/tmp
# Git
.git
.gitignore
# Environment
.env
.env.*
!.env.example
# IDE
.idea
.vscode
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Test
e2e/test-results
coverage
# Docs
docs/
# Desktop app (not needed for web self-hosting)
apps/desktop

View File

@@ -22,8 +22,6 @@ MULTICA_CODEX_WORKDIR=
MULTICA_CODEX_TIMEOUT=20m
# Email (Resend)
# For local/dev use, leave RESEND_API_KEY empty — codes print to stdout, and master code 888888 works.
# For production, set your Resend API key and change RESEND_FROM_EMAIL to a domain verified in your Resend account.
RESEND_API_KEY=
RESEND_FROM_EMAIL=noreply@multica.ai
@@ -31,7 +29,6 @@ RESEND_FROM_EMAIL=noreply@multica.ai
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GOOGLE_REDIRECT_URI=http://localhost:3000/auth/callback
NEXT_PUBLIC_GOOGLE_CLIENT_ID=
# S3 / CloudFront
S3_BUCKET=
@@ -42,16 +39,6 @@ CLOUDFRONT_PRIVATE_KEY=
CLOUDFRONT_DOMAIN=
COOKIE_DOMAIN=
# Local file storage (fallback when S3_BUCKET is not set)
LOCAL_UPLOAD_DIR=./data/uploads
LOCAL_UPLOAD_BASE_URL=http://localhost:8080
# Security
# Comma-separated list of allowed origins for CORS and WebSocket connections.
# Defaults to localhost dev origins when unset.
# Example: ALLOWED_ORIGINS=https://app.multica.ai,https://staging.multica.ai
ALLOWED_ORIGINS=
# Frontend
FRONTEND_PORT=3000
FRONTEND_ORIGIN=http://localhost:3000

1
.eslintcache Normal file

File diff suppressed because one or more lines are too long

6
.gitattributes vendored
View File

@@ -1,6 +0,0 @@
# Ensure shell scripts always use LF line endings (needed for Docker on Windows)
*.sh text eol=lf
docker/entrypoint.sh text eol=lf
# Default behavior
* text=auto

View File

@@ -1,39 +0,0 @@
name: "Bug Report"
description: Report a bug — something that's broken, crashes, or behaves incorrectly.
title: "[Bug]: "
labels: ["bug"]
body:
- type: textarea
id: description
attributes:
label: What happened?
description: Describe the bug and what you expected instead. Screenshots, error messages, or screen recordings are welcome.
placeholder: |
When I do X, Y happens. I expected Z instead.
validations:
required: true
- type: textarea
id: reproduction
attributes:
label: Steps to reproduce
description: How can we trigger this bug?
placeholder: |
1. Go to '...'
2. Click on '...'
3. See error
validations:
required: true
- type: textarea
id: screenshots
attributes:
label: Screenshots (optional)
description: If applicable, add screenshots or screen recordings to help explain the problem.
- type: textarea
id: context
attributes:
label: Additional context (optional)
description: Environment info, logs, or anything else that might help.
render: shell

View File

@@ -1 +0,0 @@
blank_issues_enabled: true

View File

@@ -1,26 +0,0 @@
name: "Feature Request"
description: Suggest a new feature or improvement.
title: "[Feature]: "
labels: ["enhancement"]
body:
- type: textarea
id: description
attributes:
label: What do you want and why?
description: Describe the problem you're trying to solve or the improvement you'd like to see.
placeholder: |
I'm trying to do X but there's no way to...
validations:
required: true
- type: textarea
id: solution
attributes:
label: Proposed solution (optional)
description: If you have an idea for how this should work, describe it here.
- type: textarea
id: screenshots
attributes:
label: Screenshots / mockups (optional)
description: If applicable, add screenshots, mockups, or sketches to illustrate your idea.

View File

@@ -1,56 +0,0 @@
## What does this PR do?
<!-- Describe the change clearly. What problem does it solve? Why is this approach the right one? -->
## Related Issue
<!-- Link the issue this PR addresses. If no issue exists, consider creating one first. -->
Closes #
## Type of Change
- [ ] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Refactor / code improvement (no behavior change)
- [ ] Documentation update
- [ ] Tests (adding or improving test coverage)
- [ ] CI / infrastructure
## Changes Made
<!-- List the specific changes. Include file paths for code changes. -->
-
## How to Test
<!-- Steps to verify this change works. For bugs: reproduction steps + proof that the fix works. -->
1.
2.
3.
## Checklist
- [ ] I searched for [existing PRs](https://github.com/multica-ai/multica/pulls) to make sure this isn't a duplicate
- [ ] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.)
- [ ] `make check` passes (typecheck, unit tests, Go tests, E2E)
- [ ] Changes follow existing code patterns and conventions
- [ ] No unrelated changes included
## AI Disclosure
<!-- Most PRs involve AI coding tools — that's totally fine! We're curious about your process. -->
**AI tool used:** <!-- e.g. Claude Code, Cursor, GitHub Copilot, Multica Agent, N/A -->
**Prompt / approach:**
<!-- How did you use AI to produce this code? Share your prompt, conversation link, or describe your approach. This helps the team learn from each other's AI workflows. -->
## Screenshots (optional)
<!-- If applicable, add screenshots showing the change in action. -->

5
.gitignore vendored
View File

@@ -41,12 +41,7 @@ apps/web/test-results/
# feature tracking
_features/
# runtime
*.pid
# platform specific
*.dmg
*.app
server/server
data/
.kilo

View File

@@ -11,28 +11,19 @@ builds:
- -s -w
- -X main.version={{.Version}}
- -X main.commit={{.ShortCommit}}
- -X main.date={{.Date}}
env:
- CGO_ENABLED=0
goos:
- darwin
- linux
- windows
goarch:
- amd64
- arm64
ignore:
- goos: windows
goarch: arm64
archives:
- id: default
formats:
- tar.gz
format_overrides:
- goos: windows
formats:
- zip
name_template: "{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}"
checksum:

293
AGENTS.md
View File

@@ -2,46 +2,273 @@
This file provides guidance to AI agents when working with code in this repository.
> **Single source of truth:** This file is a concise pointer document.
> All authoritative architecture, coding rules, commands, and conventions
> live in **CLAUDE.md** at the project root. Read that file first.
## Project Context
## Quick Reference
Multica is an AI-native task management platform — like Linear, but with AI agents as first-class citizens.
### Architecture
- Agents can be assigned issues, create issues, comment, and change status
- Supports local (daemon) and cloud agent runtimes
- Built for 2-10 person AI-native teams
Go backend + monorepo frontend (pnpm workspaces + Turborepo) with shared packages.
## Architecture
- `server/` — Go backend (Chi router, sqlc, gorilla/websocket)
- `apps/web/` — Next.js frontend (App Router)
- `apps/desktop/` — Electron desktop app
- `packages/core/` — Headless business logic (Zustand stores, React Query hooks, API client)
- `packages/ui/` — Atomic UI components (shadcn/Base UI, zero business logic)
- `packages/views/` — Shared business pages/components
- `packages/tsconfig/` — Shared TypeScript config
**Go backend + standalone Next.js frontend.**
### State Management (critical)
- `server/` — Go backend (Chi router, sqlc for DB, gorilla/websocket for real-time)
- `apps/web/` — Next.js 16 frontend (App Router) — self-contained, no shared package dependencies
- `e2e/` — Playwright end-to-end tests
- `scripts/` and root `Makefile` — local setup and verification
- **React Query** owns all server state (issues, members, agents, inbox, workspace list)
- **Zustand** owns all client state (current workspace selection, view filters, drafts, modals)
- All Zustand stores live in `packages/core/` — never in `packages/views/` or app directories
- WS events invalidate React Query — never write directly to stores
### Web App Structure (`apps/web/`)
### Package Boundaries (hard rules)
The frontend uses a **feature-based architecture** with four layers:
- `packages/core/` — zero react-dom, zero localStorage, zero process.env
- `packages/ui/` — zero `@multica/core` imports
- `packages/views/` — zero `next/*`, zero `react-router-dom`, use `NavigationAdapter` for routing
- `apps/web/platform/` — only place for Next.js APIs
### Commands
```bash
make dev # Auto-setup + start everything
pnpm typecheck # TypeScript check
pnpm test # TS unit tests (Vitest)
make test # Go tests
make check # Full verification pipeline
```
apps/web/
├── app/ # Routing layer (thin shells — import from features/)
├── features/ # Business logic, organized by domain
├── shared/ # Cross-feature utilities (api client, types, logger)
├── test/ # Shared test utilities and setup
├── public/ # Static assets
```
See CLAUDE.md for the complete command reference.
**`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 state, components, config | `useIssueStore`, icons, pickers, status/priority config |
| `features/inbox/` | Inbox notification state | `useInboxStore` |
| `features/realtime/` | WebSocket connection + sync | `WSProvider`, `useWSEvent`, `useRealtimeSync` |
| `features/modals/` | Modal registry and state | Modal store and components |
| `features/skills/` | Skill management | Skill components |
**`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
- **Zustand** for global client state — one store per feature domain (`features/auth/store.ts`, `features/workspace/store.ts`, `features/issues/store.ts`, `features/inbox/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.
### Import Aliases
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";
import { useInboxStore } from "@/features/inbox";
import { useWSEvent } from "@/features/realtime";
import { StatusIcon } from "@/features/issues/components";
```
Within a feature, use relative imports. Between features or to shared, use `@/`.
### Data Flow
```
Browser → ApiClient (shared/api) → REST API (Chi handlers) → sqlc queries → PostgreSQL
Browser ← WSClient (shared/api) ← WebSocket ← Hub.Broadcast() ← Handlers/TaskService
```
### Backend Structure (`server/`)
- **Entry points** (`cmd/`): `server` (HTTP API), `multica` (CLI — daemon, agent management, config), `migrate`
- **Handlers** (`internal/handler/`): One file per domain (issue, comment, agent, auth, daemon, etc.). Each handler holds `Queries`, `DB`, `Hub`, and `TaskService`.
- **Real-time** (`internal/realtime/`): Hub manages WebSocket clients. Server broadcasts events; inbound WS message routing is still TODO.
- **Auth** (`internal/auth/` + `internal/middleware/`): JWT (HS256). Middleware sets `X-User-ID` and `X-User-Email` headers. Login creates user on-the-fly if not found.
- **Task lifecycle** (`internal/service/task.go`): Orchestrates agent work — enqueue → claim → start → complete/fail. Syncs issue status automatically and broadcasts WS events at each transition.
- **Agent SDK** (`pkg/agent/`): Unified `Backend` interface for executing prompts via Claude Code or Codex. Each backend spawns its CLI and streams results via `Session.Messages` + `Session.Result` channels.
- **Daemon** (`internal/daemon/`): Local agent runtime — auto-detects available CLIs (claude, codex), registers runtimes, polls for tasks, routes by provider.
- **CLI** (`internal/cli/`): Shared helpers for the `multica` CLI — API client, config management, output formatting.
- **Events** (`internal/events/`): Internal event bus for decoupled communication between handlers and services.
- **Logging** (`internal/logger/`): Structured logging via slog. `LOG_LEVEL` env var controls level (debug, info, warn, error).
- **Database**: PostgreSQL with pgvector extension (`pgvector/pgvector:pg17`). 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).
### Multi-tenancy
All queries filter by `workspace_id`. Membership checks gate access. `X-Workspace-ID` header routes requests to the correct workspace.
### Agent Assignees
Assignees are polymorphic — can be a member or an agent. `assignee_type` + `assignee_id` on issues. Agents render with distinct styling (purple background, robot icon).
## Commands
```bash
# One-click setup & run
make setup # First-time: ensure shared DB, create app DB, migrate
make start # Start backend + frontend together
make stop # Stop app processes for the current checkout
make db-down # Stop the shared PostgreSQL container
# Frontend
pnpm install
pnpm dev:web # Next.js dev server (port 3000)
pnpm build # Build frontend
pnpm typecheck # TypeScript check
pnpm lint # ESLint via Next.js
pnpm test # TS tests (Vitest)
# Backend (Go)
make dev # Run Go server (port 8080)
make daemon # Run local daemon
make build # Build server + CLI binaries to server/bin/
make cli ARGS="..." # Run multica CLI (e.g. make cli ARGS="config")
make test # Go tests
make sqlc # Regenerate sqlc code after editing SQL in server/pkg/db/queries/
make migrate-up # Run database migrations
make migrate-down # Rollback migrations
# Run a single Go test
cd server && go test ./internal/handler/ -run TestName
# Run a single TS test
pnpm --filter @multica/web exec vitest run src/path/to/file.test.ts
# Run a single E2E test (requires backend + frontend running)
pnpm exec playwright test e2e/tests/specific-test.spec.ts
# Infrastructure
make db-up # Start shared PostgreSQL (pgvector/pg17 image)
make db-down # Stop shared PostgreSQL
```
### CI Requirements
CI runs on Node 22 and Go 1.24 with a `pgvector/pgvector:pg17` PostgreSQL service. See `.github/workflows/ci.yml`.
### Worktree Support
All checkouts share one PostgreSQL container. Isolation is at the database level — each worktree gets its own DB name and unique ports via `.env.worktree`. Main checkouts use `.env`.
```bash
make worktree-env # Generate .env.worktree with unique DB/ports
make setup-worktree # Setup using .env.worktree
make start-worktree # Start using .env.worktree
```
## Coding Rules
- TypeScript strict mode is enabled; keep types explicit.
- TypeScript in `apps/web` uses 2-space indentation, double quotes, and semicolons.
- Prefer PascalCase for React components, camelCase for hooks and helpers, and colocated test files such as `page.test.tsx`.
- Go code follows standard Go conventions (gofmt, go vet). Use domain-oriented filenames like `issue.go` or `cmd_issue.go`.
- Do not hand-edit generated code in `server/pkg/db/generated/`.
- Keep comments in code **English only**.
- Prefer existing patterns/components over introducing parallel abstractions.
- Unless the user explicitly asks for backwards compatibility, do **not** add compatibility layers, fallback paths, dual-write logic, legacy adapters, or temporary shims.
- If a flow or API is being replaced and the product is not yet live, prefer removing the old path instead of preserving both old and new behavior.
- Treat compatibility code as a maintenance cost, not a default safety mechanism. Avoid "just in case" branches that make the codebase harder to reason about.
- Avoid broad refactors unless required by the task.
## UI/UX Rules
- Prefer shadcn components over custom implementations. Install missing components via `npx shadcn add`.
- **Feature-specific components** → `features/<domain>/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.
- When unsure about interaction or state design, ask — the user will provide direction.
## Testing Rules
- **TypeScript**: Vitest with Testing Library. Shared test setup lives in `apps/web/test/`. Mock external/third-party dependencies only.
- **Go**: Standard `go test`. Tests should create their own fixture data in a test database.
- End-to-end tests live in `e2e/*.spec.ts`; `make check` will start missing services automatically, while direct Playwright runs expect the app to already be running.
- Add or update tests whenever you change handlers, CLI commands, daemon behavior, or SQL-backed flows.
## Commit & Pull Request Rules
- Use atomic commits grouped by logical intent.
- Conventional format with scopes:
- `feat(web): ...`, `feat(cli): ...`
- `fix(web): ...`, `fix(cli): ...`
- `refactor(daemon): ...`
- `test(cli): ...`
- `docs: ...`
- `chore(scope): ...`
- Keep PRs focused and include a short description, linked issue or PR number when relevant, screenshots for UI work, and notes for migrations, env changes, or CLI surface changes.
- Before opening a PR, run `make check` or the relevant frontend/backend subset.
## Minimum Pre-Push Checks
```bash
make check # Runs all checks: typecheck, unit tests, Go tests, E2E
```
Run verification only when the user explicitly asks for it.
For targeted checks when requested:
```bash
pnpm typecheck # TypeScript type errors only
pnpm test # TS unit tests only (Vitest)
make test # Go tests only
pnpm exec playwright test # E2E only (requires backend + frontend running)
```
## AI Agent Verification Loop
After writing or modifying code, always run the full verification pipeline:
```bash
make check
```
This runs all checks in sequence:
1. TypeScript typecheck (`pnpm typecheck`)
2. TypeScript unit tests (`pnpm test`)
3. Go tests (`go test ./...`)
4. E2E tests (auto-starts backend + frontend if needed, runs Playwright)
**Workflow:**
- Write code to satisfy the requirement
- Run `make check`
- If any step fails, read the error output, fix the code, and re-run `make check`
- Repeat until all checks pass
- Only then consider the task complete
**Quick iteration:** If you know only TypeScript or Go is affected, run individual checks first for faster feedback, then finish with a full `make check` before marking work complete.
## E2E Test Patterns
E2E tests should be self-contained. Use the `TestApiClient` fixture for data setup/teardown:
```typescript
import { loginAsDefault, createTestApi } from "./helpers";
import type { TestApiClient } from "./fixtures";
let api: TestApiClient;
test.beforeEach(async ({ page }) => {
api = await createTestApi(); // logged-in API client
await loginAsDefault(page); // browser session
});
test.afterEach(async () => {
await api.cleanup(); // delete any data created during the test
});
test("example", async ({ page }) => {
const issue = await api.createIssue("Test Issue"); // create via API
await page.goto(`/issues/${issue.id}`); // test via UI
// api.cleanup() in afterEach removes the issue
});
```

315
CLAUDE.md
View File

@@ -12,71 +12,119 @@ Multica is an AI-native task management platform — like Linear, but with AI ag
## Architecture
**Go backend + monorepo frontend (pnpm workspaces + Turborepo) with shared packages.**
**Go backend + standalone Next.js frontend.**
- `server/` — Go backend (Chi router, sqlc for DB, gorilla/websocket for real-time)
- `apps/web/` — Next.js frontend (App Router)
- `apps/desktop/` — Electron desktop app (electron-vite)
- `packages/core/` — Headless business logic (zero react-dom, all-platform reuse)
- `packages/ui/` — Atomic UI components (zero business logic)
- `packages/views/` — Shared business pages/components (zero next/* imports, zero react-router imports)
- `packages/tsconfig/` — Shared TypeScript configuration
- `apps/web/` — Next.js 16 frontend (App Router) — self-contained, no shared package dependencies
### Key Architectural Decisions
### Web App Structure (`apps/web/`)
**Internal Packages pattern** — all shared packages export raw `.ts`/`.tsx` files (no pre-compilation). The consuming app's bundler compiles them directly. This gives zero-config HMR and instant go-to-definition.
The frontend uses a **feature-based architecture** with four layers:
**Dependency direction:** `views/ → core/ + ui/`. Core and UI are independent of each other. No package imports from `next/*`, `react-router-dom`, or app-specific code.
```
apps/web/
├── app/ # Routing layer (thin shells — import from features/)
├── features/ # Business logic, organized by domain
├── shared/ # Cross-feature utilities (api client, types, logger)
```
**Platform bridge:** `packages/core/platform/` provides `CoreProvider` — initializes API client, auth/workspace stores, WS connection, and QueryClient. Each app wraps its root with `<CoreProvider>` and provides its own `NavigationAdapter` for routing.
**`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/`.
**pnpm catalog**`pnpm-workspace.yaml` defines `catalog:` for version pinning. All shared deps use `catalog:` references to guarantee a single version across all packages. When adding new shared deps (including test deps), add to catalog first.
**`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 state, components, config | `useIssueStore`, icons, pickers, status/priority config |
| `features/inbox/` | Inbox notification state | `useInboxStore` |
| `features/realtime/` | WebSocket connection + sync | `WSProvider`, `useWSEvent`, `useRealtimeSync` |
| `features/modals/` | Modal registry and state | Modal store and components |
| `features/skills/` | Skill management | Skill components |
**`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
The architecture relies on a strict split between server state and client state. Mixing them is the most common way to break it.
- **Zustand** for global client state — one store per feature domain (`features/auth/store.ts`, `features/workspace/store.ts`, `features/issues/store.ts`, `features/inbox/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.
- **TanStack Query owns all server state.** Issues, users, workspaces, inbox — anything fetched from the API lives in the Query cache. WS events keep it fresh via invalidation; no polling, no `staleTime` workarounds.
- **Zustand owns all client state.** UI selections, filters, drafts, modal state, navigation history. Stores live in `packages/core/` (never in `packages/views/`) so both apps share them.
- **React Context** is reserved for cross-cutting platform plumbing — `WorkspaceIdProvider`, `NavigationProvider`. Don't reach for it for general state.
- **Auth and workspace stores are the only stores allowed to call `api.*` directly**, because they manage critical state that must exist before queries can run. They're created via factory + injected dependencies, registered by the platform layer.
**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.
**Hard rules — these are how the architecture stays coherent:**
### Import Aliases
- **Never duplicate server data into Zustand.** If it came from the API, it belongs in the Query cache. Copying it into a store creates two sources of truth and they will drift.
- **Workspace-scoped queries must key on `wsId`.** This is what makes workspace switching automatic — the cache key changes, the right data appears, no manual invalidation needed.
- **Mutations are optimistic by default.** Apply the change locally, send the request, roll back on failure, invalidate on settle. The user shouldn't wait for the server.
- **WS events invalidate queries — they never write to stores directly.** This keeps the cache as the single source of truth and avoids race conditions.
- **Persist what's worth preserving across restarts** (user preferences, drafts, tab layout). **Don't persist ephemeral UI state** (modal open/close, transient selections) or server data.
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";
import { useInboxStore } from "@/features/inbox";
import { useWSEvent } from "@/features/realtime";
import { StatusIcon } from "@/features/issues/components";
```
**Common Zustand footguns to avoid:**
Within a feature, use relative imports. Between features or to shared, use `@/`.
- Selectors must return stable references. Returning a freshly built object or array on every call (e.g. `s => ({ a: s.a, b: s.b })` or `s => s.items.map(...)`) triggers infinite re-renders. Either select primitives separately or use shallow comparison.
- Hooks that need workspace context should accept `wsId` as a parameter, not call `useWorkspaceId()` internally — this lets them work outside the `WorkspaceIdProvider` (e.g. in a sidebar that renders before workspace is loaded).
### Data Flow
```
Browser → ApiClient (shared/api) → REST API (Chi handlers) → sqlc queries → PostgreSQL
Browser ← WSClient (shared/api) ← WebSocket ← Hub.Broadcast() ← Handlers/TaskService
```
### Backend Structure (`server/`)
- **Entry points** (`cmd/`): `server` (HTTP API), `multica` (CLI — daemon, agent management, config), `migrate`
- **Handlers** (`internal/handler/`): One file per domain (issue, comment, agent, auth, daemon, etc.). Each handler holds `Queries`, `DB`, `Hub`, and `TaskService`.
- **Real-time** (`internal/realtime/`): Hub manages WebSocket clients. Server broadcasts events; inbound WS message routing is still TODO.
- **Auth** (`internal/auth/` + `internal/middleware/`): JWT (HS256). Middleware sets `X-User-ID` and `X-User-Email` headers. Login creates user on-the-fly if not found.
- **Task lifecycle** (`internal/service/task.go`): Orchestrates agent work — enqueue → claim → start → complete/fail. Syncs issue status automatically and broadcasts WS events at each transition.
- **Agent SDK** (`pkg/agent/`): Unified `Backend` interface for executing prompts via Claude Code or Codex. Each backend spawns its CLI and streams results via `Session.Messages` + `Session.Result` channels.
- **Daemon** (`internal/daemon/`): Local agent runtime — auto-detects available CLIs (claude, codex), registers runtimes, polls for tasks, routes by provider.
- **CLI** (`internal/cli/`): Shared helpers for the `multica` CLI — API client, config management, output formatting.
- **Events** (`internal/events/`): Internal event bus for decoupled communication between handlers and services.
- **Logging** (`internal/logger/`): Structured logging via slog. `LOG_LEVEL` env var controls level (debug, info, warn, error).
- **Database**: PostgreSQL with pgvector extension (`pgvector/pgvector:pg17`). 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).
### Multi-tenancy
All queries filter by `workspace_id`. Membership checks gate access. `X-Workspace-ID` header routes requests to the correct workspace.
### Agent Assignees
Assignees are polymorphic — can be a member or an agent. `assignee_type` + `assignee_id` on issues. Agents render with distinct styling (purple background, robot icon).
## Commands
```bash
# One-command dev (auto-setup + start everything)
make dev # Auto-creates env, installs deps, starts DB, migrates, launches app
# Explicit setup & run (if you prefer separate steps)
# One-click setup & run
make setup # First-time: ensure shared DB, create app DB, migrate
make start # Start backend + frontend together
make stop # Stop app processes for the current checkout
make db-down # Stop the shared PostgreSQL container
# Frontend (all commands go through Turborepo)
# Frontend
pnpm install
pnpm dev:web # Next.js dev server (port 3000)
pnpm dev:desktop # Electron dev (electron-vite, HMR)
pnpm build # Build all frontend apps
pnpm typecheck # TypeScript check (all packages + apps via turbo)
pnpm lint # ESLint
pnpm test # TS tests (Vitest, all packages + apps via turbo)
pnpm build # Build frontend
pnpm typecheck # TypeScript check
pnpm lint # ESLint via Next.js
pnpm test # TS tests (Vitest)
# Backend (Go)
make server # Run Go server only (port 8080)
make dev # Run Go server (port 8080)
make daemon # Run local daemon
make build # Build server + CLI binaries to server/bin/
make cli ARGS="..." # Run multica CLI (e.g. make cli ARGS="config")
@@ -85,24 +133,15 @@ make sqlc # Regenerate sqlc code after editing SQL in server/pkg/db/
make migrate-up # Run database migrations
make migrate-down # Rollback migrations
# Run a single TS test (works for any package with a test script)
pnpm --filter @multica/views exec vitest run auth/login-page.test.tsx
pnpm --filter @multica/core exec vitest run runtimes/version.test.ts
pnpm --filter @multica/web exec vitest run app/\(auth\)/login/page.test.tsx
# Run a single Go test
cd server && go test ./internal/handler/ -run TestName
# Run a single TS test
pnpm --filter @multica/web exec vitest run src/path/to/file.test.ts
# Run a single E2E test (requires backend + frontend running)
pnpm exec playwright test e2e/tests/specific-test.spec.ts
# Desktop build & package
pnpm --filter @multica/desktop build # Compile TS → JS (reads .env.production)
pnpm --filter @multica/desktop package # Package into .app/.dmg/.exe (current platform only)
# shadcn — config lives in packages/ui/components.json (Base UI variant, base-nova style)
pnpm ui:add badge # Adds component to packages/ui/components/ui/
# Infrastructure
make db-up # Start shared PostgreSQL (pgvector/pg17 image)
make db-down # Stop shared PostgreSQL
@@ -116,8 +155,6 @@ CI runs on Node 22 and Go 1.26.1 with a `pgvector/pgvector:pg17` PostgreSQL serv
All checkouts share one PostgreSQL container. Isolation is at the database level — each worktree gets its own DB name and unique ports via `.env.worktree`. Main checkouts use `.env`.
`make dev` auto-detects worktrees and handles everything. For explicit control:
```bash
make worktree-env # Generate .env.worktree with unique DB/ports
make setup-worktree # Setup using .env.worktree
@@ -132,129 +169,43 @@ make start-worktree # Start using .env.worktree
- Prefer existing patterns/components over introducing parallel abstractions.
- Unless the user explicitly asks for backwards compatibility, do **not** add compatibility layers, fallback paths, dual-write logic, legacy adapters, or temporary shims.
- If a flow or API is being replaced and the product is not yet live, prefer removing the old path instead of preserving both old and new behavior.
- Treat compatibility code as a maintenance cost, not a default safety mechanism. Avoid "just in case" branches that make the codebase harder to reason about.
- Avoid broad refactors unless required by the task.
### Package Boundary Rules
These are hard constraints. Violating them breaks the cross-platform architecture:
- `packages/core/` — zero react-dom, zero localStorage (use StorageAdapter), zero process.env, zero UI libraries. **All shared Zustand stores live here**, even view-related ones (filters, view modes) — stores are pure state, not UI.
- `packages/ui/` — zero `@multica/core` imports (pure UI, no business logic).
- `packages/views/` — zero `next/*` imports, zero `react-router-dom` imports, zero stores. Use `NavigationAdapter` for all routing.
- `apps/web/platform/` — the only place for Next.js APIs (`next/navigation`).
- `apps/desktop/src/renderer/src/platform/` — the only place for react-router-dom navigation wiring.
### The No-Duplication Rule
**If the same logic exists in both apps, it must be extracted to a shared package.**
This applies to everything: components, hooks, guards, providers, utility functions. The decision process:
1. Does this code depend on Next.js or Electron APIs? → Keep in the respective app.
2. Does it depend on `react-router-dom` or `next/navigation`? → Keep in app's `platform/` layer.
3. Everything else → belongs in `packages/core/` (headless logic) or `packages/views/` (UI components).
When the two apps need different behavior for the same concept (e.g., different loading UI), extract the shared logic into a component with props/slots for the differences. Don't duplicate the logic.
### Cross-Platform Development Rules
When adding a new page or feature:
1. **New page component** → add to `packages/views/<domain>/`. Never import from `next/*` or `react-router-dom`.
2. **Wire it in both apps** → add a route in `apps/web/app/` (Next.js page file) AND in the desktop router.
3. **Navigation** → use `useNavigation().push()` or `<AppLink>`. Never use framework-specific link/router APIs in shared code.
4. **Shared guards/providers** → use `DashboardGuard` from `packages/views/layout/`. Don't create separate guard logic per app.
5. **Platform-specific UI** → if a feature is web-only or desktop-only, keep it in the respective app. Use props slots (`extra`, `topSlot`) on shared layout components to inject platform-specific UI.
6. **New hooks that need workspace context** → accept `wsId` as parameter instead of reading from `useWorkspaceId()` Context, so they work both inside and outside `WorkspaceIdProvider`.
### CSS Architecture
Both apps share the same CSS foundation from `packages/ui/styles/`.
- **Design tokens** → use semantic tokens (`bg-background`, `text-muted-foreground`). Never use hardcoded Tailwind colors (`text-red-500`, `bg-gray-100`).
- **Shared styles** → `packages/ui/styles/`. Never duplicate scrollbar styling, keyframes, or base layer rules in app CSS.
- **`@source` directives** → both apps scan shared packages so Tailwind sees all class names.
## UI/UX Rules
- Prefer shadcn components over custom implementations. Install via `pnpm ui:add <component>` from project root — adds to `packages/ui/components/ui/`. All components use Base UI primitives (`@base-ui/react`), not Radix.
- Use shadcn design tokens for styling. Avoid hardcoded color values.
- Do not introduce extra state (useState, context, reducers) unless explicitly required by the design.
- Prefer shadcn components over custom implementations. Install missing components via `npx shadcn add`.
- **Feature-specific components** → `features/<domain>/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.
- **If a component is identical between web and desktop, it belongs in a shared package.** Do not copy-paste between apps.
- When unsure about interaction or state design, ask — the user will provide direction.
## Testing Rules
### Where to write tests
Tests follow the code, not the app. This is the most important testing principle in this monorepo:
| What you're testing | Where the test lives | Why |
|---|---|---|
| Shared business logic (stores, queries, hooks) | `packages/core/*.test.ts` | No DOM needed, pure logic |
| Shared UI components (pages, forms, modals) | `packages/views/*.test.tsx` | jsdom, no framework mocks |
| Platform-specific wiring (cookies, redirects, searchParams) | `apps/web/*.test.tsx` or `apps/desktop/` | Needs framework-specific mocks |
| End-to-end user flows | `e2e/*.spec.ts` | Real browser, real backend |
**Never test shared component behavior in an app's test file.** If a test requires mocking `next/navigation` or `react-router-dom` to test a component from `@multica/views`, the test is in the wrong place — move it to `packages/views/` and mock `@multica/core` instead.
### Test infrastructure
- `packages/core/` — Vitest, Node environment (no DOM)
- `packages/views/` — Vitest, jsdom environment, `@testing-library/react`
- `apps/web/` — Vitest, jsdom environment, framework-specific mocks
- `e2e/` — Playwright
- `server/` — Go standard `go test`
All test deps are in the pnpm catalog for unified versioning.
### Mocking conventions
- Mock `@multica/core` stores with `vi.hoisted()` + `Object.assign(selectorFn, { getState })` pattern (Zustand stores are both callable and have `.getState()`).
- Mock `@multica/core/api` for API calls.
- In `packages/views/` tests: never mock `next/*` or `react-router-dom` — those don't exist here.
- In `apps/web/` tests: mock framework-specific APIs only for platform-specific behavior.
### TDD workflow
1. Write failing test in the **correct package** first.
2. Write implementation.
3. Run `pnpm test` (Turborepo discovers all packages).
4. Green → done.
### Go tests
Standard `go test`. Tests should create their own fixture data in a test database.
### E2E tests
E2E tests should be self-contained. Use the `TestApiClient` fixture for data setup/teardown:
```typescript
import { loginAsDefault, createTestApi } from "./helpers";
import type { TestApiClient } from "./fixtures";
let api: TestApiClient;
test.beforeEach(async ({ page }) => {
api = await createTestApi();
await loginAsDefault(page);
});
test.afterEach(async () => {
await api.cleanup();
});
test("example", async ({ page }) => {
const issue = await api.createIssue("Test Issue");
await page.goto(`/issues/${issue.id}`);
});
```
- **TypeScript**: Vitest. Mock external/third-party dependencies only.
- **Go**: Standard `go test`. Tests should create their own fixture data in a test database.
## Commit Rules
- Use atomic commits grouped by logical intent.
- Conventional format: `feat(scope)`, `fix(scope)`, `refactor(scope)`, `docs`, `test(scope)`, `chore(scope)`.
- Conventional format:
- `feat(scope): ...`
- `fix(scope): ...`
- `refactor(scope): ...`
- `docs: ...`
- `test(scope): ...`
- `chore(scope): ...`
## CLI Release
**Prerequisite:** A CLI release must accompany every Production deployment. When deploying to Production, always release a new CLI version as part of the process.
1. Create a tag on the `main` branch: `git tag v0.x.x`
2. Push the tag: `git push origin v0.x.x`
3. GitHub Actions automatically triggers `release.yml`: runs Go tests → GoReleaser builds multi-platform binaries → publishes to GitHub Releases + Homebrew tap
By default, bump the patch version each release (e.g. `v0.1.12``v0.1.13`), unless the user specifies a specific version.
## Minimum Pre-Push Checks
@@ -267,7 +218,7 @@ Run verification only when the user explicitly asks for it.
For targeted checks when requested:
```bash
pnpm typecheck # TypeScript type errors only
pnpm test # TS unit tests only (Vitest, all packages)
pnpm test # TS unit tests only (Vitest)
make test # Go tests only
pnpm exec playwright test # E2E only (requires backend + frontend running)
```
@@ -280,29 +231,43 @@ After writing or modifying code, always run the full verification pipeline:
make check
```
This runs all checks in sequence:
1. TypeScript typecheck (`pnpm typecheck`)
2. TypeScript unit tests (`pnpm test`)
3. Go tests (`go test ./...`)
4. E2E tests (auto-starts backend + frontend if needed, runs Playwright)
**Workflow:**
- Write code to satisfy the requirement
- Run `make check`
- If any step fails, read the error output, fix the code, and re-run
- If any step fails, read the error output, fix the code, and re-run `make check`
- Repeat until all checks pass
- Only then consider the task complete
**Quick iteration:** If you know only TypeScript or Go is affected, run individual checks first for faster feedback, then finish with a full `make check` before marking work complete.
## CLI Release
## E2E Test Patterns
**Prerequisite:** A CLI release must accompany every Production deployment.
E2E tests should be self-contained. Use the `TestApiClient` fixture for data setup/teardown:
1. Create a tag on the `main` branch: `git tag v0.x.x`
2. Push the tag: `git push origin v0.x.x`
3. GitHub Actions automatically triggers `release.yml`: runs Go tests → GoReleaser builds multi-platform binaries → publishes to GitHub Releases + Homebrew tap
```typescript
import { loginAsDefault, createTestApi } from "./helpers";
import type { TestApiClient } from "./fixtures";
By default, bump the patch version each release (e.g. `v0.1.12``v0.1.13`), unless the user specifies a specific version.
let api: TestApiClient;
## Multi-tenancy
test.beforeEach(async ({ page }) => {
api = await createTestApi(); // logged-in API client
await loginAsDefault(page); // browser session
});
All queries filter by `workspace_id`. Membership checks gate access. `X-Workspace-ID` header routes requests to the correct workspace.
test.afterEach(async () => {
await api.cleanup(); // delete any data created during the test
});
## Agent Assignees
Assignees are polymorphic — can be a member or an agent. `assignee_type` + `assignee_id` on issues. Agents render with distinct styling (purple background, robot icon).
test("example", async ({ page }) => {
const issue = await api.createIssue("Test Issue"); // create via API
await page.goto(`/issues/${issue.id}`); // test via UI
// api.cleanup() in afterEach removes the issue
});
```

View File

@@ -8,7 +8,7 @@ The `multica` CLI connects your local machine to Multica. It handles authenticat
```bash
brew tap multica-ai/tap
brew install multica
brew install multica-cli
```
### Build from Source
@@ -30,16 +30,6 @@ This auto-detects your installation method (Homebrew or manual) and upgrades acc
## Quick Start
```bash
# One-command setup: configure, authenticate, and start the daemon
multica setup
# For self-hosted (local) deployments:
multica setup self-host
```
Or step by step:
```bash
# 1. Authenticate (opens browser for login)
multica login
@@ -135,9 +125,6 @@ The daemon auto-detects these AI CLIs on your PATH:
|-----|---------|-------------|
| [Claude Code](https://docs.anthropic.com/en/docs/claude-code) | `claude` | Anthropic's coding agent |
| [Codex](https://github.com/openai/codex) | `codex` | OpenAI's coding agent |
| OpenCode | `opencode` | Open-source coding agent |
| OpenClaw | `openclaw` | Open-source coding agent |
| Hermes | `hermes` | Nous Research coding agent |
You need at least one installed. The daemon registers each detected CLI as an available runtime.
@@ -172,50 +159,34 @@ Agent-specific overrides:
| `MULTICA_CLAUDE_MODEL` | Override the Claude model used |
| `MULTICA_CODEX_PATH` | Custom path to the `codex` binary |
| `MULTICA_CODEX_MODEL` | Override the Codex model used |
| `MULTICA_OPENCODE_PATH` | Custom path to the `opencode` binary |
| `MULTICA_OPENCODE_MODEL` | Override the OpenCode model used |
| `MULTICA_OPENCLAW_PATH` | Custom path to the `openclaw` binary |
| `MULTICA_OPENCLAW_MODEL` | Override the OpenClaw model used |
| `MULTICA_HERMES_PATH` | Custom path to the `hermes` binary |
| `MULTICA_HERMES_MODEL` | Override the Hermes model used |
### Self-Hosted Server
When connecting to a self-hosted Multica instance, the easiest approach is:
When connecting to a self-hosted Multica instance, point the CLI to your server before logging in:
```bash
# One command — configures for localhost, authenticates, starts daemon
multica setup self-host
# Or for on-premise with custom domains:
multica setup self-host --server-url https://api.example.com --app-url https://app.example.com
```
Or configure manually:
```bash
# Set URLs individually
multica config set server_url http://localhost:8080
multica config set app_url http://localhost:3000
# For production with TLS:
# multica config set server_url https://api.example.com
# multica config set app_url https://app.example.com
export MULTICA_APP_URL=https://app.example.com
export MULTICA_SERVER_URL=wss://api.example.com/ws
multica login
multica daemon start
```
Or set them persistently:
```bash
multica config set app_url https://app.example.com
multica config set server_url wss://api.example.com/ws
```
### Profiles
Profiles let you run multiple daemons on the same machine — for example, one for production and one for a staging server.
```bash
# Set up a staging profile
multica setup self-host --profile staging --server-url https://api-staging.example.com --app-url https://staging.example.com
# Start its daemon
multica daemon start --profile staging
# Start a daemon for the staging server
multica --profile staging login
multica --profile staging daemon start
# Default profile runs separately
multica daemon start
@@ -335,24 +306,6 @@ multica issue run-messages <task-id> --since 42 --output json
The `runs` command shows all past and current executions for an issue, including running tasks. The `run-messages` command shows the detailed message log (tool calls, thinking, text, errors) for a single run. Use `--since` for efficient polling of in-progress runs.
## Setup
```bash
# One-command setup for Multica Cloud: configure, authenticate, and start the daemon
multica setup
# For local self-hosted deployments
multica setup self-host
# Custom ports
multica setup self-host --port 9090 --frontend-port 4000
# On-premise with custom domains
multica setup self-host --server-url https://api.example.com --app-url https://app.example.com
```
`multica setup` configures the CLI, opens your browser for authentication, and starts the daemon — all in one step. Use `multica setup self-host` to connect to a self-hosted server instead of Multica Cloud.
## Configuration
### View Config
@@ -366,7 +319,7 @@ Shows config file path, server URL, app URL, and default workspace.
### Set Values
```bash
multica config set server_url https://api.example.com
multica config set server_url wss://api.example.com/ws
multica config set app_url https://app.example.com
multica config set workspace_id <workspace-id>
```

View File

@@ -1,194 +0,0 @@
# Multica CLI — Installation Guide for AI Agents
> **Audience:** This document is designed for AI agents (Claude Code, Codex, etc.) to read and execute step-by-step. Each step includes the exact shell commands to run, expected outcomes, and error handling.
## One-Line Instruction for Users
Give this instruction to your AI agent:
```
Fetch https://github.com/multica-ai/multica/blob/main/CLI_INSTALL.md and follow the instructions to install Multica CLI, log in, and start the daemon on this machine.
```
---
## Step 1: Check if Multica CLI is already installed
Run:
```bash
multica version
```
- **If it prints a version string** (e.g. `multica v0.x.x`): skip to **Step 3**.
- **If command not found**: continue to **Step 2**.
---
## Step 2: Install the Multica CLI
> **Windows users:** Skip to [Option C: Windows (PowerShell)](#option-c-windows-powershell) below.
### Option A: Homebrew (preferred — macOS/Linux)
Check if Homebrew is available:
```bash
which brew
```
If `brew` is found, install via Homebrew:
```bash
brew tap multica-ai/tap && brew install multica
```
Then verify:
```bash
multica version
```
If the version prints successfully, skip to **Step 3**.
### Option B: Download from GitHub Releases (macOS/Linux, no Homebrew)
If Homebrew is not available, download the binary directly.
Detect OS and architecture, then download the correct archive:
```bash
OS=$(uname -s | tr '[:upper:]' '[:lower:]') # "darwin" or "linux"
ARCH=$(uname -m) # "x86_64" or "arm64"
# Normalize architecture name
if [ "$ARCH" = "x86_64" ]; then
ARCH="amd64"
fi
# Get the latest release tag from GitHub
LATEST=$(curl -sI https://github.com/multica-ai/multica/releases/latest | grep -i '^location:' | sed 's/.*tag\///' | tr -d '\r\n')
# Download and extract
curl -sL "https://github.com/multica-ai/multica/releases/download/${LATEST}/multica_${OS}_${ARCH}.tar.gz" -o /tmp/multica.tar.gz
tar -xzf /tmp/multica.tar.gz -C /tmp multica
sudo mv /tmp/multica /usr/local/bin/multica
rm /tmp/multica.tar.gz
```
Verify:
```bash
multica version
```
**If this fails:**
- Check that `/usr/local/bin` is in `$PATH`.
- On Linux, you may need `chmod +x /usr/local/bin/multica`.
- If `sudo` is not available, install to a user-writable directory: `mv /tmp/multica ~/.local/bin/multica` and ensure `~/.local/bin` is in `$PATH`.
### Option C: Windows (PowerShell)
Run in PowerShell (no admin required):
```powershell
irm https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.ps1 | iex
```
This downloads the latest Windows binary from GitHub Releases, installs it to `%USERPROFILE%\.multica\bin\`, and adds it to your user PATH.
Verify:
```powershell
multica version
```
**If this fails:**
- Restart your terminal so the updated PATH takes effect.
- If you use Scoop, the installer will use it automatically: `scoop bucket add multica https://github.com/multica-ai/scoop-bucket.git && scoop install multica`
- If your execution policy blocks the script: `Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned` then re-run.
---
## Step 3: Log in
Run:
```bash
multica login
```
**Important:** This command opens a browser window for OAuth authentication. Tell the user:
> "A browser window will open for Multica login. Please complete the authentication in your browser, then come back here."
Wait for the command to complete. It will automatically discover and watch all workspaces the user belongs to.
Verify:
```bash
multica auth status
```
Expected output should show the authenticated user and server URL.
**If login fails:**
- If no browser is available (headless environment), the user can generate a Personal Access Token at `https://app.multica.ai/settings` and run: `multica login --token`
- If the server URL needs to be customized: `multica config set server_url <url>` before logging in.
---
## Step 4: Start the daemon
First, check if the daemon is already running:
```bash
multica daemon status
```
- **If status is "running"**: skip to **Step 5**.
- **If status is "stopped"**: start it:
```bash
multica daemon start
```
Wait 3 seconds, then verify:
```bash
multica daemon status
```
Expected output should show `running` status with detected agents (e.g. `claude`, `codex`, `opencode`, `openclaw`, `hermes`).
**If daemon fails to start:**
- Check logs: `multica daemon logs`
- If a port conflict occurs, the daemon may already be running under a different profile.
- If no agents are detected, ensure at least one AI CLI (`claude`, `codex`, `opencode`, `openclaw`, or `hermes`) is installed and on the `$PATH`.
---
## Step 5: Verify everything is working
Run:
```bash
multica daemon status
```
Confirm:
1. Status is `running`
2. At least one agent is listed (e.g. `claude`, `codex`, `opencode`, `openclaw`, or `hermes`)
3. At least one workspace is being watched
If the agents list is empty, tell the user:
> "The Multica daemon is running but no AI agent CLIs were detected. Please install at least one supported CLI (`claude`, `codex`, `opencode`, `openclaw`, or `hermes`), then restart the daemon with `multica daemon stop && multica daemon start`."
---
## Summary
When all steps are complete, inform the user:
> "Multica CLI is installed and the daemon is running. Agents in your workspaces can now execute tasks on this machine. You can manage workspaces with `multica workspace list` and view daemon logs with `multica daemon logs -f`."

View File

@@ -94,52 +94,59 @@ FORCE=1 make worktree-env
## First-Time Setup
### Quick Start (recommended)
### Main Checkout
From any checkout (main or worktree):
```bash
make dev
```
This single command:
- auto-detects whether you're in a main checkout or a worktree
- creates the appropriate env file (`.env` or `.env.worktree`) if it doesn't exist
- checks that prerequisites (Node.js, pnpm, Go, Docker) are installed
- installs JavaScript dependencies
- ensures the shared PostgreSQL container is running
- creates the application database if it does not exist
- runs all migrations
- starts both backend and frontend
### Explicit Setup (advanced)
If you prefer separate control over setup and startup:
#### Main Checkout
From the main checkout:
```bash
cp .env.example .env
make setup-main
```
What `make setup-main` does:
- installs JavaScript dependencies with `pnpm install`
- ensures the shared PostgreSQL container is running
- creates the application database if it does not exist
- runs all migrations against that database
Start the app:
```bash
make start-main
```
Stop:
Stop the app processes:
```bash
make stop-main
```
#### Worktree
This does not stop PostgreSQL.
### Worktree
From the worktree directory:
```bash
make worktree-env
make setup-worktree
```
What `make setup-worktree` does:
- uses `.env.worktree`
- ensures the shared PostgreSQL container is running
- creates the worktree database if it does not exist
- runs migrations against the worktree database
Start the worktree app:
```bash
make start-worktree
```
Stop:
Stop the worktree app processes:
```bash
make stop-worktree
@@ -164,15 +171,17 @@ Use a worktree when you want isolated data and separate app ports.
```bash
git worktree add ../multica-feature -b feat/my-change main
cd ../multica-feature
make dev
make worktree-env
make setup-worktree
make start-worktree
```
After that, day-to-day commands are:
```bash
make dev # start (re-runs setup if needed, idempotent)
make stop-worktree # stop
make check-worktree # verify
make start-worktree
make stop-worktree
make check-worktree
```
## Running Main and Worktree at the Same Time
@@ -415,7 +424,9 @@ Warning:
### Stable Main Environment
```bash
make dev
cp .env.example .env
make setup-main
make start-main
```
### Feature Worktree
@@ -423,7 +434,9 @@ make dev
```bash
git worktree add ../multica-feature -b feat/my-change main
cd ../multica-feature
make dev
make worktree-env
make setup-worktree
make start-worktree
```
### Return to a Previously Configured Worktree

View File

@@ -30,9 +30,7 @@ COPY --from=builder /src/server/bin/server .
COPY --from=builder /src/server/bin/multica .
COPY --from=builder /src/server/bin/migrate .
COPY server/migrations/ ./migrations/
COPY docker/entrypoint.sh .
RUN sed -i 's/\r$//' entrypoint.sh && chmod +x entrypoint.sh
EXPOSE 8080
ENTRYPOINT ["./entrypoint.sh"]
ENTRYPOINT ["./server"]

View File

@@ -1,70 +0,0 @@
# --- Dependencies ---
FROM node:22-alpine AS deps
RUN corepack enable && corepack prepare pnpm@10.28.2 --activate
WORKDIR /app
# Copy workspace config and all package.json files for dependency resolution
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json turbo.json .npmrc ./
COPY apps/web/package.json apps/web/
COPY packages/core/package.json packages/core/
COPY packages/ui/package.json packages/ui/
COPY packages/views/package.json packages/views/
COPY packages/tsconfig/package.json packages/tsconfig/
COPY packages/eslint-config/package.json packages/eslint-config/
RUN pnpm install --frozen-lockfile
# --- Build ---
FROM node:22-alpine AS builder
RUN corepack enable && corepack prepare pnpm@10.28.2 --activate
WORKDIR /app
# Copy installed dependencies (preserves pnpm symlink structure)
COPY --from=deps /app ./
# Copy source
COPY package.json turbo.json pnpm-workspace.yaml ./
COPY apps/web/ apps/web/
COPY packages/ packages/
# Re-link after source overlay (fixes any symlinks overwritten by COPY)
RUN pnpm install --frozen-lockfile --offline
# Set build-time env: tells Next.js rewrites to proxy API calls to the backend service
ARG REMOTE_API_URL=http://backend:8080
ARG NEXT_PUBLIC_GOOGLE_CLIENT_ID
ENV REMOTE_API_URL=$REMOTE_API_URL
ENV NEXT_PUBLIC_GOOGLE_CLIENT_ID=$NEXT_PUBLIC_GOOGLE_CLIENT_ID
ENV STANDALONE=true
# Build the web app (standalone output for minimal runtime)
RUN pnpm --filter @multica/web build
# --- Runtime ---
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
# Copy standalone output (includes traced node_modules)
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/standalone ./
# Copy static files (not included in standalone)
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/static ./apps/web/.next/static
# Copy public assets
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/public ./apps/web/public
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
CMD ["node", "apps/web/server.js"]

221
LICENSE
View File

@@ -1,44 +1,199 @@
# Open Source License
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
Multica is licensed under a modified version of the Apache License 2.0, with the following additional conditions:
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Multica may be utilized commercially, including as a backend service for
other applications or as a task management platform for enterprises.
Should the conditions below be met, a commercial license must be obtained
from the producer:
1. Definitions.
a. Hosted or embedded service: Unless explicitly authorized by Multica
in writing, you may not use the Multica source code to provide a
hosted service to third parties, or embed Multica as a component of
a product or service that is sold, licensed, or otherwise
commercially distributed to third parties.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
- This restriction applies to offering Multica (in whole or
substantial part) as a SaaS platform, a managed service, or as
an integrated component within another commercial offering.
- Internal use within a single organization (including multiple
workspaces) does not require a commercial license.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
b. LOGO and copyright information: In the process of using Multica's
frontend, you may not remove or modify the LOGO or copyright
information in the Multica console or applications. This restriction
is inapplicable to uses of Multica that do not involve its frontend.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
- Frontend Definition: For the purposes of this license, the
"frontend" of Multica includes all components located in the
`apps/web/` directory when running Multica from the raw source
code, or the "web" image when running Multica with Docker.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
2. As a contributor, you should agree that:
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
a. The producer can adjust the open-source agreement to be more strict
or relaxed as deemed necessary.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
b. Your contributed code may be used for commercial purposes, including
but not limited to its cloud business operations.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
Apart from the specific conditions mentioned above, all other rights and
restrictions follow the Apache License 2.0. Detailed information about the
Apache License 2.0 can be found at http://www.apache.org/licenses/LICENSE-2.0.
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
© 2025 Multica, Inc.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to the Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by the Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding any notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. Please also get an
"Implied Patent License" from your patent counsel.
Copyright 2025 Multica
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View File

@@ -1,4 +1,4 @@
.PHONY: dev server daemon cli multica build test migrate-up migrate-down sqlc seed clean setup start stop check worktree-env setup-main start-main stop-main check-main setup-worktree start-worktree stop-worktree check-worktree db-up db-down selfhost selfhost-stop
.PHONY: dev daemon cli multica build test migrate-up migrate-down sqlc seed clean setup start stop check worktree-env setup-main start-main stop-main check-main setup-worktree start-worktree stop-worktree check-worktree db-up db-down
MAIN_ENV_FILE ?= .env
WORKTREE_ENV_FILE ?= .env.worktree
@@ -36,53 +36,6 @@ define REQUIRE_ENV
fi
endef
# ---------- Self-hosting (Docker Compose) ----------
# One-command self-host: create env, start Docker Compose, wait for health
selfhost:
@if [ ! -f .env ]; then \
echo "==> Creating .env from .env.example..."; \
cp .env.example .env; \
JWT=$$(openssl rand -hex 32); \
if [ "$$(uname)" = "Darwin" ]; then \
sed -i '' "s/^JWT_SECRET=.*/JWT_SECRET=$$JWT/" .env; \
else \
sed -i "s/^JWT_SECRET=.*/JWT_SECRET=$$JWT/" .env; \
fi; \
echo "==> Generated random JWT_SECRET"; \
fi
@echo "==> Starting Multica via Docker Compose..."
docker compose -f docker-compose.selfhost.yml up -d --build
@echo "==> Waiting for backend to be ready..."
@for i in $$(seq 1 30); do \
if curl -sf http://localhost:$${PORT:-8080}/health > /dev/null 2>&1; then \
break; \
fi; \
sleep 2; \
done
@if curl -sf http://localhost:$${PORT:-8080}/health > /dev/null 2>&1; then \
echo ""; \
echo "✓ Multica is running!"; \
echo " Frontend: http://localhost:$${FRONTEND_PORT:-3000}"; \
echo " Backend: http://localhost:$${PORT:-8080}"; \
echo ""; \
echo "Log in with any email + verification code: 888888"; \
echo ""; \
echo "Next — install the CLI and connect your machine:"; \
echo " brew install multica-ai/tap/multica"; \
echo " multica setup self-host"; \
else \
echo ""; \
echo "Services are still starting. Check logs:"; \
echo " docker compose -f docker-compose.selfhost.yml logs"; \
fi
# Stop all Docker Compose self-host services
selfhost-stop:
@echo "==> Stopping Multica services..."
docker compose -f docker-compose.selfhost.yml down
@echo "✓ All services stopped."
# ---------- One-click commands ----------
# First-time setup: install deps, start DB, run migrations
@@ -116,12 +69,7 @@ stop:
@echo "Stopping services..."
@-lsof -ti:$(PORT) | xargs kill -9 2>/dev/null
@-lsof -ti:$(FRONTEND_PORT) | xargs kill -9 2>/dev/null
@case "$(DATABASE_URL)" in \
""|*@localhost:*|*@localhost/*|*@127.0.0.1:*|*@127.0.0.1/*|*@\[::1\]:*|*@\[::1\]/*) \
echo "✓ App processes stopped. Shared PostgreSQL is still running on localhost:$(POSTGRES_PORT)." ;; \
*) \
echo "✓ App processes stopped. Remote PostgreSQL was not affected." ;; \
esac
@echo "✓ App processes stopped. Shared PostgreSQL is still running on localhost:5432."
# Full verification: typecheck + unit tests + Go tests + E2E
check:
@@ -150,12 +98,8 @@ check-main:
@ENV_FILE=$(MAIN_ENV_FILE) bash scripts/check.sh
setup-worktree:
@if [ ! -f "$(WORKTREE_ENV_FILE)" ]; then \
echo "==> Generating $(WORKTREE_ENV_FILE) with unique ports..."; \
bash scripts/init-worktree-env.sh $(WORKTREE_ENV_FILE); \
else \
echo "==> Using existing $(WORKTREE_ENV_FILE)"; \
fi
@echo "==> Generating $(WORKTREE_ENV_FILE) with unique ports..."
@FORCE=1 bash scripts/init-worktree-env.sh $(WORKTREE_ENV_FILE)
@$(MAKE) setup ENV_FILE=$(WORKTREE_ENV_FILE)
start-worktree:
@@ -169,12 +113,8 @@ check-worktree:
# ---------- Individual commands ----------
# One-command dev: auto-setup env/deps/db/migrations, then start all services
# Go server
dev:
@bash scripts/dev.sh
# Go server only
server:
$(REQUIRE_ENV)
@bash scripts/ensure-postgres.sh "$(ENV_FILE)"
cd server && go run ./cmd/server
@@ -190,17 +130,14 @@ multica:
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
DATE ?= $(shell date -u '+%Y-%m-%dT%H:%M:%SZ')
build:
cd server && go build -o bin/server ./cmd/server
cd server && go build -ldflags "-X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.date=$(DATE)" -o bin/multica ./cmd/multica
cd server && go build -o bin/migrate ./cmd/migrate
cd server && go build -ldflags "-X main.version=$(VERSION) -X main.commit=$(COMMIT)" -o bin/multica ./cmd/multica
test:
$(REQUIRE_ENV)
@bash scripts/ensure-postgres.sh "$(ENV_FILE)"
cd server && go run ./cmd/migrate up
cd server && go test ./...
# Database

131
README.md
View File

@@ -14,13 +14,14 @@
**Your next 10 hires won't be human.**
The open-source managed agents platform.<br/>
Turn coding agents into real teammates — assign tasks, track progress, compound skills.
Open-source platform that turns coding agents into real teammates.<br/>
Assign tasks, track progress, compound skills — manage your human + agent workforce in one place.
[![CI](https://github.com/multica-ai/multica/actions/workflows/ci.yml/badge.svg)](https://github.com/multica-ai/multica/actions/workflows/ci.yml)
[![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
[![GitHub stars](https://img.shields.io/github/stars/multica-ai/multica?style=flat)](https://github.com/multica-ai/multica/stargazers)
[Website](https://multica.ai) · [Cloud](https://multica.ai/app) · [X](https://x.com/MulticaAI) · [Self-Hosting](SELF_HOSTING.md) · [Contributing](CONTRIBUTING.md)
[Website](https://multica.ai) · [Cloud](https://multica.ai/app) · [Self-Hosting](SELF_HOSTING.md) · [Contributing](CONTRIBUTING.md)
**English | [简体中文](README.zh-CN.md)**
@@ -30,7 +31,7 @@ Turn coding agents into real teammates — assign tasks, track progress, compoun
Multica turns coding agents into real teammates. Assign issues to an agent like you'd assign to a colleague — they'll pick up the work, write code, report blockers, and update statuses autonomously.
No more copy-pasting prompts. No more babysitting runs. Your agents show up on the board, participate in conversations, and compound reusable skills over time. Think of it as open-source infrastructure for managed agents — vendor-neutral, self-hosted, and designed for human + AI teams. Works with **Claude Code**, **Codex**, **OpenClaw**, and **OpenCode**.
No more copy-pasting prompts. No more babysitting runs. Your agents show up on the board, participate in conversations, and compound reusable skills over time. Works with **Claude Code** and **Codex**.
<p align="center">
<img src="docs/assets/hero-screenshot.png" alt="Multica board view" width="800">
@@ -38,56 +39,63 @@ No more copy-pasting prompts. No more babysitting runs. Your agents show up on t
## Features
Multica manages the full agent lifecycle: from task assignment to execution monitoring to skill reuse.
- **Agents as Teammates** — assign to an agent like you'd assign to a colleague. They have profiles, show up on the board, post comments, create issues, and report blockers proactively.
- **Autonomous Execution** — set it and forget it. Full task lifecycle management (enqueue, claim, start, complete/fail) with real-time progress streaming via WebSocket.
- **Reusable Skills** — every solution becomes a reusable skill for the whole team. Deployments, migrations, code reviews — skills compound your team's capabilities over time.
- **Unified Runtimes** — one dashboard for all your compute. Local daemons and cloud runtimes, auto-detection of available CLIs, real-time monitoring.
- **Multi-Workspace** — organize work across teams with workspace-level isolation. Each workspace has its own agents, issues, and settings.
---
## Quick Install
```bash
curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash
```
Installs the Multica CLI on macOS and Linux. Works with Homebrew or downloads the binary directly.
**Windows (PowerShell):**
```powershell
irm https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.ps1 | iex
```
Then configure, authenticate, and start the daemon in one command:
```bash
multica setup # Connect to Multica Cloud, log in, start daemon
```
> **Self-hosting?** Add `--with-server` to deploy a full Multica server on your machine:
>
> ```bash
> curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash -s -- --with-server
> multica setup self-host
> ```
>
> Requires Docker. See the [Self-Hosting Guide](SELF_HOSTING.md) for details.
---
## Getting Started
### 1. Set up and start the daemon
### Multica Cloud
The fastest way to get started — no setup required: **[multica.ai](https://multica.ai)**
### Self-Host with Docker
```bash
multica setup # Configure, authenticate, and start the daemon
git clone https://github.com/multica-ai/multica.git
cd multica
cp .env.example .env
# Edit .env — at minimum, change JWT_SECRET
docker compose up -d # Start PostgreSQL
cd server && go run ./cmd/migrate up && cd .. # Run migrations
make start # Start the app
```
The daemon runs in the background and auto-detects agent CLIs (`claude`, `codex`, `openclaw`, `opencode`) on your PATH.
See the [Self-Hosting Guide](SELF_HOSTING.md) for full instructions.
## CLI
The `multica` CLI connects your local machine to Multica — authenticate, manage workspaces, and run the agent daemon.
```bash
# Install
brew tap multica-ai/tap
brew install multica
# Authenticate and start
multica login
multica daemon start
```
The daemon auto-detects available agent CLIs (`claude`, `codex`) on your PATH. When an agent is assigned a task, the daemon creates an isolated environment, runs the agent, and reports results back.
See the [CLI and Daemon Guide](CLI_AND_DAEMON.md) for the full command reference, daemon configuration, and advanced usage.
## Quickstart
Once you have the CLI installed (or signed up for [Multica Cloud](https://multica.ai)), follow these steps to assign your first task to an agent:
### 1. Log in and start the daemon
```bash
multica login # Authenticate with your Multica account
multica daemon start # Start the local agent runtime
```
The daemon runs in the background and keeps your machine connected to Multica. It auto-detects agent CLIs (`claude`, `codex`) available on your PATH.
### 2. Verify your runtime
@@ -97,32 +105,13 @@ Open your workspace in the Multica web app. Navigate to **Settings → Runtimes*
### 3. Create an agent
Go to **Settings → Agents** and click **New Agent**. Pick the runtime you just connected and choose a provider (Claude Code, Codex, OpenClaw, or OpenCode). Give your agent a name — this is how it will appear on the board, in comments, and in assignments.
Go to **Settings → Agents** and click **New Agent**. Pick the runtime you just connected and choose a provider (Claude Code or Codex). Give your agent a name — this is how it will appear on the board, in comments, and in assignments.
### 4. Assign your first task
Create an issue from the board (or via `multica issue create`), then assign it to your new agent. The agent will automatically pick up the task, execute it on your runtime, and report progress — just like a human teammate.
---
## CLI
The `multica` CLI connects your local machine to Multica — authenticate, manage workspaces, and run the agent daemon.
| Command | Description |
|---------|-------------|
| `multica login` | Authenticate (opens browser) |
| `multica daemon start` | Start the local agent runtime |
| `multica daemon status` | Check daemon status |
| `multica setup` | One-command setup for Multica Cloud (configure + login + start daemon) |
| `multica setup self-host` | Same, but for self-hosted deployments |
| `multica issue list` | List issues in your workspace |
| `multica issue create` | Create a new issue |
| `multica update` | Update to the latest version |
See the [CLI and Daemon Guide](CLI_AND_DAEMON.md) for the full command reference.
---
That's it! Your agent is now part of the team. 🎉
## Architecture
@@ -134,8 +123,7 @@ See the [CLI and Daemon Guide](CLI_AND_DAEMON.md) for the full command reference
┌──────┴───────┐
│ Agent Daemon │ (runs on your machine)
│Claude/Codex/
│OpenClaw/Code │
Claude/Codex │
└──────────────┘
```
@@ -144,7 +132,7 @@ See the [CLI and Daemon Guide](CLI_AND_DAEMON.md) for the full command reference
| Frontend | Next.js 16 (App Router) |
| Backend | Go (Chi router, sqlc, gorilla/websocket) |
| Database | PostgreSQL 17 with pgvector |
| Agent Runtime | Local daemon executing Claude Code, Codex, OpenClaw, or OpenCode |
| Agent Runtime | Local daemon executing Claude Code or Codex |
## Development
@@ -153,9 +141,14 @@ For contributors working on the Multica codebase, see the [Contributing Guide](C
**Prerequisites:** [Node.js](https://nodejs.org/) v20+, [pnpm](https://pnpm.io/) v10.28+, [Go](https://go.dev/) v1.26+, [Docker](https://www.docker.com/)
```bash
make dev
pnpm install
cp .env.example .env
make setup
make start
```
`make dev` auto-detects your environment (main checkout or worktree), creates the env file, installs dependencies, sets up the database, runs migrations, and starts all services.
See [CONTRIBUTING.md](CONTRIBUTING.md) for the full development workflow, worktree support, testing, and troubleshooting.
## License
[Apache 2.0](LICENSE)

View File

@@ -14,13 +14,14 @@
**你的下一批员工,不是人类。**
开源的 Managed Agents 平台。<br/>
将编码 Agent 变成真正的队友——分配任务、跟踪进度、积累技能。
开源平台,将编码 Agent 变成真正的队友。<br/>
分配任务、跟踪进度、积累技能——在一个地方管理你的人类 + Agent 团队
[![CI](https://github.com/multica-ai/multica/actions/workflows/ci.yml/badge.svg)](https://github.com/multica-ai/multica/actions/workflows/ci.yml)
[![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
[![GitHub stars](https://img.shields.io/github/stars/multica-ai/multica?style=flat)](https://github.com/multica-ai/multica/stargazers)
[官网](https://multica.ai) · [云服务](https://multica.ai/app) · [X](https://x.com/MulticaAI) · [自部署指南](SELF_HOSTING.md) · [参与贡献](CONTRIBUTING.md)
[官网](https://multica.ai) · [云服务](https://multica.ai/app) · [自部署指南](SELF_HOSTING.md) · [参与贡献](CONTRIBUTING.md)
**[English](README.md) | 简体中文**
@@ -30,7 +31,7 @@
Multica 将编码 Agent 变成真正的队友。像分配给同事一样分配给 Agent——它们会自主接手工作、编写代码、报告阻塞问题、更新状态。
不再需要复制粘贴 prompt不再需要盯着运行过程。你的 Agent 出现在看板上、参与对话、随着时间积累可复用的技能。可以理解为开源的 Managed Agents 基础设施——厂商中立、可自部署、专为人类 + AI 团队设计。支持 **Claude Code**、**Codex**、**OpenClaw****OpenCode**
不再需要复制粘贴 prompt不再需要盯着运行过程。你的 Agent 出现在看板上、参与对话、随着时间积累可复用的技能。支持 **Claude Code****Codex**
<p align="center">
<img src="docs/assets/hero-screenshot.png" alt="Multica 看板视图" width="800">
@@ -38,58 +39,63 @@ Multica 将编码 Agent 变成真正的队友。像分配给同事一样分配
## 功能特性
Multica 管理完整的 Agent 生命周期:从任务分配到执行监控再到技能复用。
- **Agent 即队友** — 像分配给同事一样分配给 Agent。它们有个人档案、出现在看板上、发表评论、创建 Issue、主动报告阻塞问题。
- **自主执行** — 设置后无需管理。完整的任务生命周期管理(排队、认领、执行、完成/失败),通过 WebSocket 实时推送进度。
- **可复用技能** — 每个解决方案都成为全团队可复用的技能。部署、数据库迁移、代码审查——技能让团队能力随时间持续增长。
- **统一运行时** — 一个控制台管理所有算力。本地 daemon 和云端运行时,自动检测可用 CLI实时监控。
- **多工作区** — 按团队组织工作,工作区级别隔离。每个工作区有独立的 Agent、Issue 和设置。
---
## 快速开始
## 快速安装
### Multica 云服务
最快的上手方式,无需任何配置:**[multica.ai](https://multica.ai)**
### Docker 自部署
```bash
curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash
git clone https://github.com/multica-ai/multica.git
cd multica
cp .env.example .env
# 编辑 .env — 至少修改 JWT_SECRET
docker compose up -d # 启动 PostgreSQL
cd server && go run ./cmd/migrate up && cd .. # 运行数据库迁移
make start # 启动应用
```
安装 Multica CLI支持 macOS 和 Linux。有 Homebrew 用 Homebrew没有则直接下载二进制
完整部署文档请参阅 [自部署指南](SELF_HOSTING.md)
**Windows (PowerShell):**
## CLI
```powershell
irm https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.ps1 | iex
```
安装完成后,一条命令完成配置、认证和启动:
`multica` CLI 将你的本地机器连接到 Multica — 用于认证、管理工作区和运行 Agent daemon。
```bash
multica setup # 连接 Multica Cloud登录启动 daemon
# 安装
brew tap multica-ai/tap
brew install multica
# 认证并启动
multica login
multica daemon start
```
> **自部署?** 加上 `--with-server` 在本地部署完整的 Multica 服务:
>
> ```bash
> curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash -s -- --with-server
> multica setup self-host
> ```
>
> 需要 Docker。详见 [自部署指南](SELF_HOSTING.md)。
daemon 会自动检测 PATH 中可用的 Agent CLI`claude``codex`)。当 Agent 被分配任务时daemon 会创建隔离环境、运行 Agent、并将结果回传。
---
完整命令参考请参阅 [CLI 与 Daemon 指南](CLI_AND_DAEMON.md)。
## 快速上手
安装好 CLI或注册 [Multica 云服务](https://multica.ai))后,按以下步骤将第一个任务分配给 Agent
### 1. 配置并启动 daemon
### 1. 登录并启动 daemon
```bash
multica setup # 配置、认证、启动 daemon一条命令搞定
multica login # 使用你的 Multica 账号认证
multica daemon start # 启动本地 Agent 运行时
```
daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动检测 PATH 中可用的 Agent CLI`claude``codex``openclaw``opencode`)。
daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动检测 PATH 中可用的 Agent CLI`claude``codex`)。
### 2. 确认运行时已连接
@@ -99,7 +105,7 @@ daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动
### 3. 创建 Agent
进入 **设置 → Agents**,点击 **新建 Agent**。选择你刚连接的 Runtime选择 ProviderClaude Code、Codex、OpenClaw 或 OpenCode并为 Agent 起个名字——它将以这个名字出现在看板、评论和任务分配中。
进入 **设置 → Agents**,点击 **新建 Agent**。选择你刚连接的 Runtime选择 ProviderClaude CodeCodex),并为 Agent 起个名字——它将以这个名字出现在看板、评论和任务分配中。
### 4. 分配你的第一个任务
@@ -117,8 +123,7 @@ daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动
┌──────┴───────┐
│ Agent Daemon │ (运行在你的机器上)
│Claude/Codex/
│OpenClaw/Code │
Claude/Codex │
└──────────────┘
```
@@ -127,7 +132,7 @@ daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动
| 前端 | Next.js 16 (App Router) |
| 后端 | Go (Chi router, sqlc, gorilla/websocket) |
| 数据库 | PostgreSQL 17 with pgvector |
| Agent 运行时 | 本地 daemon 执行 Claude Code、Codex、OpenClaw 或 OpenCode |
| Agent 运行时 | 本地 daemon 执行 Claude CodeCodex |
## 开发

View File

@@ -1,8 +1,10 @@
# Self-Hosting Guide
Deploy Multica on your own infrastructure in minutes.
This guide walks you through deploying Multica on your own infrastructure.
## Architecture
## Architecture Overview
Multica has three components:
| Component | Description | Technology |
|-----------|-------------|------------|
@@ -10,154 +12,16 @@ Deploy Multica on your own infrastructure in minutes.
| **Frontend** | Web application | Next.js 16 |
| **Database** | Primary data store | PostgreSQL 17 with pgvector |
Each user who runs AI agents locally also installs the **`multica` CLI** and runs the **agent daemon** on their own machine.
Additionally, each user who wants to run AI agents locally installs the **`multica` CLI** and runs the **agent daemon** on their own machine.
## Quick Install (Recommended)
## Prerequisites
Two commands to set up everything — server, CLI, and configuration:
- Docker and Docker Compose (recommended), or:
- Go 1.26+ (to build from source)
- Node.js 20+ and pnpm 10.28+ (to build the frontend)
- PostgreSQL 17 with the pgvector extension
```bash
# 1. Install CLI + provision the self-host server
curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash -s -- --with-server
# 2. Configure CLI, authenticate, and start the daemon
multica setup self-host
```
This clones the repository, starts all services via Docker Compose, installs the `multica` CLI, then configures it for localhost.
Open http://localhost:3000, log in with any email + verification code **`888888`**.
> **Prerequisites:** Docker and Docker Compose must be installed. The script checks for this and provides install links if missing.
---
## Step-by-Step Setup (Alternative)
If you prefer to run each step manually:
### Step 1 — Start the Server
**Prerequisites:** Docker and Docker Compose.
```bash
git clone https://github.com/multica-ai/multica.git
cd multica
make selfhost
```
`make selfhost` automatically creates `.env` from the example, generates a random `JWT_SECRET`, and starts all services via Docker Compose.
Once ready:
- **Frontend:** http://localhost:3000
- **Backend API:** http://localhost:8080
> **Note:** If you prefer to run the Docker Compose steps manually, see [Manual Docker Compose Setup](#manual-docker-compose-setup) below.
### Step 2 — Log In
Open http://localhost:3000 in your browser. Enter any email address and use verification code **`888888`** to log in.
> This master code works in all non-production environments (i.e. when `APP_ENV` is not set to `production`). For production, configure an email provider — see [Advanced Configuration](SELF_HOSTING_ADVANCED.md#email-required-for-authentication).
### Step 3 — Install CLI & Start Daemon
The daemon runs on your local machine (not inside Docker). It detects installed AI agent CLIs, registers them with the server, and executes tasks when agents are assigned work.
Each team member who wants to run AI agents locally needs to:
### a) Install the CLI and an AI agent
```bash
brew tap multica-ai/tap
brew install multica
```
You also need at least one AI agent CLI installed:
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code) (`claude` on PATH)
- [Codex](https://github.com/openai/codex) (`codex` on PATH)
- [OpenClaw](https://github.com/openclaw/openclaw) (`openclaw` on PATH)
- [OpenCode](https://github.com/anomalyco/opencode) (`opencode` on PATH)
- [Hermes](https://github.com/NousResearch/hermes) (`hermes` on PATH)
### b) One-command setup
```bash
multica setup self-host
```
This automatically:
1. Configures the CLI to connect to `localhost` (ports 8080/3000)
2. Opens your browser for authentication
3. Discovers your workspaces
4. Starts the daemon in the background
For on-premise deployments with custom domains:
```bash
multica setup self-host --server-url https://api.example.com --app-url https://app.example.com
```
To verify the daemon is running:
```bash
multica daemon status
```
> **Alternative:** If you prefer manual steps, see [Manual CLI Configuration](#manual-cli-configuration) below.
### Step 4 — Verify & Start Using
1. Open your workspace in the web app at http://localhost:3000
2. Navigate to **Settings → Runtimes** — you should see your machine listed
3. Go to **Settings → Agents** and create a new agent
4. Create an issue and assign it to your agent — it will pick up the task automatically
## Stopping Services
If you installed via the install script:
```bash
curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash -s -- --stop
```
If you cloned the repo manually:
```bash
# Stop the Docker Compose services (backend, frontend, database)
make selfhost-stop
# Stop the local daemon
multica daemon stop
```
## Switching to Multica Cloud
If you've been self-hosting and want to switch your CLI to [Multica Cloud](https://multica.ai):
```bash
multica setup
```
This reconfigures the CLI for multica.ai, re-authenticates, and restarts the daemon. You will be prompted before overwriting the existing configuration.
> Your local Docker services are unaffected. Stop them separately if you no longer need them.
## Rebuilding After Updates
```bash
git pull
make selfhost
```
Migrations run automatically on backend startup.
---
## Manual Docker Compose Setup
If you prefer running Docker Compose steps manually instead of `make selfhost`:
## Quick Start (Docker Compose)
```bash
git clone https://github.com/multica-ai/multica.git
@@ -165,43 +29,250 @@ cd multica
cp .env.example .env
```
Edit `.env` — at minimum, change `JWT_SECRET`:
Edit `.env` with your production values (see [Configuration](#configuration) below), then:
```bash
JWT_SECRET=$(openssl rand -hex 32)
# Start PostgreSQL
docker compose up -d
# Build the backend
make build
# Run database migrations
DATABASE_URL="your-database-url" ./server/bin/migrate up
# Start the backend server
DATABASE_URL="your-database-url" PORT=8080 ./server/bin/server
```
Then start everything:
For the frontend:
```bash
docker compose -f docker-compose.selfhost.yml up -d
pnpm install
pnpm build
# Start the frontend (production mode)
cd apps/web
REMOTE_API_URL=http://localhost:8080 pnpm start
```
## Manual CLI Configuration
## Configuration
If you prefer configuring the CLI step by step instead of `multica setup`:
All configuration is done via environment variables. Copy `.env.example` as a starting point.
### Required Variables
| Variable | Description | Example |
|----------|-------------|---------|
| `DATABASE_URL` | PostgreSQL connection string | `postgres://multica:multica@localhost:5432/multica?sslmode=disable` |
| `JWT_SECRET` | **Must change from default.** Secret key for signing JWT tokens. Use a long random string. | `openssl rand -hex 32` |
| `FRONTEND_ORIGIN` | URL where the frontend is served (used for CORS) | `https://app.example.com` |
### Email (Required for Authentication)
Multica uses email-based magic link authentication via [Resend](https://resend.com).
| Variable | Description |
|----------|-------------|
| `RESEND_API_KEY` | Your Resend API key |
| `RESEND_FROM_EMAIL` | Sender email address (default: `noreply@multica.ai`) |
### Google OAuth (Optional)
| Variable | Description |
|----------|-------------|
| `GOOGLE_CLIENT_ID` | Google OAuth client ID |
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret |
| `GOOGLE_REDIRECT_URI` | OAuth callback URL (e.g. `https://app.example.com/auth/callback`) |
### File Storage (Optional)
For file uploads and attachments, configure S3 and CloudFront:
| Variable | Description |
|----------|-------------|
| `S3_BUCKET` | S3 bucket name |
| `S3_REGION` | AWS region (default: `us-west-2`) |
| `CLOUDFRONT_DOMAIN` | CloudFront distribution domain |
| `CLOUDFRONT_KEY_PAIR_ID` | CloudFront key pair ID for signed URLs |
| `CLOUDFRONT_PRIVATE_KEY` | CloudFront private key (PEM format) |
| `COOKIE_DOMAIN` | Domain for CloudFront auth cookies |
### Server
| Variable | Default | Description |
|----------|---------|-------------|
| `PORT` | `8080` | Backend server port |
| `FRONTEND_PORT` | `3000` | Frontend port |
| `CORS_ALLOWED_ORIGINS` | Value of `FRONTEND_ORIGIN` | Comma-separated list of allowed origins |
| `LOG_LEVEL` | `info` | Log level: `debug`, `info`, `warn`, `error` |
### CLI / Daemon
These are configured on each user's machine, not on the server:
| Variable | Default | Description |
|----------|---------|-------------|
| `MULTICA_SERVER_URL` | `ws://localhost:8080/ws` | WebSocket URL for daemon → server connection |
| `MULTICA_APP_URL` | `http://localhost:3000` | Frontend URL for CLI login flow |
| `MULTICA_DAEMON_POLL_INTERVAL` | `3s` | How often the daemon polls for tasks |
| `MULTICA_DAEMON_HEARTBEAT_INTERVAL` | `15s` | Heartbeat frequency |
## Database Setup
Multica requires PostgreSQL 17 with the pgvector extension.
### Using the Included Docker Compose
```bash
# Point CLI to your local server
multica config set server_url http://localhost:8080
multica config set app_url http://localhost:3000
# Login (opens browser)
multica login
# Start the daemon
multica daemon start
docker compose up -d postgres
```
For production deployments with TLS:
This starts a `pgvector/pgvector:pg17` container on port 5432 with default credentials (`multica`/`multica`).
### Using Your Own PostgreSQL
Ensure the pgvector extension is available:
```sql
CREATE EXTENSION IF NOT EXISTS vector;
```
### Running Migrations
Migrations must be run before starting the server:
```bash
multica config set app_url https://app.example.com
multica config set server_url https://api.example.com
multica login
multica daemon start
# Using the built binary
./server/bin/migrate up
# Or from source
cd server && go run ./cmd/migrate up
```
## Advanced Configuration
## Reverse Proxy
For environment variables, manual setup (without Docker), reverse proxy configuration, database setup, and more, see the [Advanced Configuration Guide](SELF_HOSTING_ADVANCED.md).
In production, put a reverse proxy in front of both the backend and frontend to handle TLS and routing.
### Caddy (Recommended)
```
app.example.com {
reverse_proxy localhost:3000
}
api.example.com {
reverse_proxy localhost:8080
}
```
### Nginx
```nginx
# Frontend
server {
listen 443 ssl;
server_name app.example.com;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
location / {
proxy_pass http://localhost:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# Backend API
server {
listen 443 ssl;
server_name api.example.com;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
location / {
proxy_pass http://localhost:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# WebSocket support
location /ws {
proxy_pass http://localhost:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 86400;
}
}
```
When using separate domains for frontend and backend, set these environment variables accordingly:
```bash
# Backend
FRONTEND_ORIGIN=https://app.example.com
CORS_ALLOWED_ORIGINS=https://app.example.com
# Frontend
REMOTE_API_URL=https://api.example.com
NEXT_PUBLIC_API_URL=https://api.example.com
NEXT_PUBLIC_WS_URL=wss://api.example.com/ws
```
## Health Check
The backend exposes a health check endpoint:
```
GET /health
→ {"status":"ok"}
```
Use this for load balancer health checks or monitoring.
## Setting Up the Agent Daemon
Each team member who wants to run AI agents locally needs to:
1. **Install the CLI**
```bash
brew tap multica-ai/tap
brew install multica-cli
```
2. **Install an AI agent CLI** — at least one of:
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code) (`claude` on PATH)
- [Codex](https://github.com/openai/codex) (`codex` on PATH)
3. **Authenticate and start**
```bash
# Point CLI to your server
export MULTICA_APP_URL=https://app.example.com
export MULTICA_SERVER_URL=wss://api.example.com/ws
# Login (opens browser)
multica login
# Start the daemon
multica daemon start
```
The daemon auto-detects installed agent CLIs and registers itself with the server. When an agent is assigned a task in Multica, the daemon picks it up, creates an isolated workspace, runs the agent, and reports results back.
## Upgrading
1. Pull the latest code or image
2. Run migrations: `./server/bin/migrate up`
3. Restart the backend and frontend
Migrations are forward-only and safe to run on a live database. They are idempotent — running them multiple times has no effect.

View File

@@ -1,239 +0,0 @@
# Self-Hosting — Advanced Configuration
This document covers advanced configuration for self-hosted Multica deployments. For the quick start guide, see [SELF_HOSTING.md](SELF_HOSTING.md).
## Configuration
All configuration is done via environment variables. Copy `.env.example` as a starting point.
### Required Variables
| Variable | Description | Example |
|----------|-------------|---------|
| `DATABASE_URL` | PostgreSQL connection string | `postgres://multica:multica@localhost:5432/multica?sslmode=disable` |
| `JWT_SECRET` | **Must change from default.** Secret key for signing JWT tokens. Use a long random string. | `openssl rand -hex 32` |
| `FRONTEND_ORIGIN` | URL where the frontend is served (used for CORS) | `https://app.example.com` |
### Email (Required for Authentication)
Multica uses email-based magic link authentication via [Resend](https://resend.com).
| Variable | Description |
|----------|-------------|
| `RESEND_API_KEY` | Your Resend API key |
| `RESEND_FROM_EMAIL` | Sender email address (default: `noreply@multica.ai`) |
> **Note:** For local/development deployments without email configured, you can use the master verification code `888888` to log in.
### Google OAuth (Optional)
| Variable | Description |
|----------|-------------|
| `GOOGLE_CLIENT_ID` | Google OAuth client ID |
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret |
| `GOOGLE_REDIRECT_URI` | OAuth callback URL (e.g. `https://app.example.com/auth/callback`) |
### File Storage (Optional)
For file uploads and attachments, configure S3 and CloudFront:
| Variable | Description |
|----------|-------------|
| `S3_BUCKET` | S3 bucket name |
| `S3_REGION` | AWS region (default: `us-west-2`) |
| `CLOUDFRONT_DOMAIN` | CloudFront distribution domain |
| `CLOUDFRONT_KEY_PAIR_ID` | CloudFront key pair ID for signed URLs |
| `CLOUDFRONT_PRIVATE_KEY` | CloudFront private key (PEM format) |
| `COOKIE_DOMAIN` | Domain for CloudFront auth cookies |
### Server
| Variable | Default | Description |
|----------|---------|-------------|
| `PORT` | `8080` | Backend server port |
| `FRONTEND_PORT` | `3000` | Frontend port |
| `CORS_ALLOWED_ORIGINS` | Value of `FRONTEND_ORIGIN` | Comma-separated list of allowed origins |
| `LOG_LEVEL` | `info` | Log level: `debug`, `info`, `warn`, `error` |
### CLI / Daemon
These are configured on each user's machine, not on the server:
| Variable | Default | Description |
|----------|---------|-------------|
| `MULTICA_SERVER_URL` | `ws://localhost:8080/ws` | WebSocket URL for daemon → server connection |
| `MULTICA_APP_URL` | `http://localhost:3000` | Frontend URL for CLI login flow |
| `MULTICA_DAEMON_POLL_INTERVAL` | `3s` | How often the daemon polls for tasks |
| `MULTICA_DAEMON_HEARTBEAT_INTERVAL` | `15s` | Heartbeat frequency |
Agent-specific overrides:
| Variable | Description |
|----------|-------------|
| `MULTICA_CLAUDE_PATH` | Custom path to the `claude` binary |
| `MULTICA_CLAUDE_MODEL` | Override the Claude model used |
| `MULTICA_CODEX_PATH` | Custom path to the `codex` binary |
| `MULTICA_CODEX_MODEL` | Override the Codex model used |
| `MULTICA_OPENCODE_PATH` | Custom path to the `opencode` binary |
| `MULTICA_OPENCODE_MODEL` | Override the OpenCode model used |
| `MULTICA_OPENCLAW_PATH` | Custom path to the `openclaw` binary |
| `MULTICA_OPENCLAW_MODEL` | Override the OpenClaw model used |
| `MULTICA_HERMES_PATH` | Custom path to the `hermes` binary |
| `MULTICA_HERMES_MODEL` | Override the Hermes model used |
## Database Setup
Multica requires PostgreSQL 17 with the pgvector extension.
### Using Docker Compose (Recommended)
The `docker-compose.selfhost.yml` includes PostgreSQL. No separate setup needed.
### Using Your Own PostgreSQL
If you prefer to use an existing PostgreSQL instance, ensure the pgvector extension is available:
```sql
CREATE EXTENSION IF NOT EXISTS vector;
```
Set `DATABASE_URL` in your `.env` and remove the `postgres` service from the compose file.
### Running Migrations Manually
The Docker Compose setup runs migrations automatically. If you need to run them manually:
```bash
# Using the built binary
./server/bin/migrate up
# Or from source
cd server && go run ./cmd/migrate up
```
## Manual Setup (Without Docker Compose)
If you prefer to build and run services manually:
**Prerequisites:** Go 1.26+, Node.js 20+, pnpm 10.28+, PostgreSQL 17 with pgvector.
```bash
# Start your PostgreSQL (or use: docker compose up -d postgres)
# Build the backend
make build
# Run database migrations
DATABASE_URL="your-database-url" ./server/bin/migrate up
# Start the backend server
DATABASE_URL="your-database-url" PORT=8080 JWT_SECRET="your-secret" ./server/bin/server
```
For the frontend:
```bash
pnpm install
pnpm build
# Start the frontend (production mode)
cd apps/web
REMOTE_API_URL=http://localhost:8080 pnpm start
```
## Reverse Proxy
In production, put a reverse proxy in front of both the backend and frontend to handle TLS and routing.
### Caddy (Recommended)
```
app.example.com {
reverse_proxy localhost:3000
}
api.example.com {
reverse_proxy localhost:8080
}
```
### Nginx
```nginx
# Frontend
server {
listen 443 ssl;
server_name app.example.com;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
location / {
proxy_pass http://localhost:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# Backend API
server {
listen 443 ssl;
server_name api.example.com;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
location / {
proxy_pass http://localhost:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# WebSocket support
location /ws {
proxy_pass http://localhost:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 86400;
}
}
```
When using separate domains for frontend and backend, set these environment variables accordingly:
```bash
# Backend
FRONTEND_ORIGIN=https://app.example.com
CORS_ALLOWED_ORIGINS=https://app.example.com
# Frontend (set before building the frontend image)
REMOTE_API_URL=https://api.example.com
NEXT_PUBLIC_API_URL=https://api.example.com
NEXT_PUBLIC_WS_URL=wss://api.example.com/ws
```
## Health Check
The backend exposes a health check endpoint:
```
GET /health
→ {"status":"ok"}
```
Use this for load balancer health checks or monitoring.
## Upgrading
```bash
git pull
docker compose -f docker-compose.selfhost.yml up -d --build
```
Migrations run automatically on backend startup. They are idempotent — running them multiple times has no effect.

View File

@@ -1,76 +0,0 @@
# Self-Hosting Setup (for AI Agents)
This document is designed for AI agents to execute. Follow these steps exactly to deploy a local Multica instance and connect to it.
## Prerequisites
- Docker and Docker Compose installed
- Homebrew installed (for CLI)
- At least one AI agent CLI on PATH: `claude` or `codex`
## Install
```bash
# Install CLI + provision self-host server
curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash -s -- --with-server
# Configure CLI for localhost, authenticate, and start daemon
multica setup self-host
```
Wait for the server output `✓ Multica server is running and CLI is ready!` before running `multica setup self-host`.
**Expected result:**
- Frontend at http://localhost:3000
- Backend at http://localhost:8080
- `multica` CLI installed and configured for localhost
## Alternative: Manual Setup
```bash
git clone https://github.com/multica-ai/multica.git
cd multica
make selfhost
brew install multica-ai/tap/multica
multica setup self-host
```
The `multica setup self-host` command will:
1. Configure CLI to connect to localhost:8080 / localhost:3000
2. Open a browser for login — use verification code `888888` with any email
3. Discover workspaces automatically
4. Start the daemon in the background
## Verification
```bash
multica daemon status
```
Should show `running` with detected agents.
## Stopping
```bash
# Stop the daemon
multica daemon stop
# Stop all Docker services
cd multica
make selfhost-stop
```
## Custom Ports
If the default ports (8080/3000) are in use:
1. Edit `.env` and change `PORT` and `FRONTEND_PORT`
2. Run `make selfhost`
3. Run `multica setup self-host --port <PORT> --frontend-port <FRONTEND_PORT>`
## Troubleshooting
- **Backend not ready:** `docker compose -f docker-compose.selfhost.yml logs backend`
- **Frontend not ready:** `docker compose -f docker-compose.selfhost.yml logs frontend`
- **Daemon issues:** `multica daemon logs`
- **Health check:** `curl http://localhost:8080/health`

View File

@@ -1,6 +0,0 @@
node_modules
dist
out
.DS_Store
.eslintcache
*.log*

View File

@@ -1,35 +0,0 @@
appId: ai.multica.desktop
productName: Multica
directories:
buildResources: build
files:
- "!**/.vscode/*"
- "!src/*"
- "!electron.vite.config.*"
- "!{.eslintignore,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}"
- "!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}"
protocols:
- name: Multica
schemes:
- multica
asarUnpack:
- resources/**
mac:
entitlementsInherit: build/entitlements.mac.plist
target:
- dmg
- zip
artifactName: ${name}-${version}-${arch}.${ext}
notarize: false
dmg:
artifactName: ${name}-${version}.${ext}
linux:
target:
- AppImage
- deb
artifactName: ${name}-${version}-${arch}.${ext}
win:
target:
- nsis
artifactName: ${name}-${version}-setup.${ext}
npmRebuild: false

View File

@@ -1,41 +0,0 @@
import { resolve } from "path";
import { defineConfig, externalizeDepsPlugin } from "electron-vite";
import { loadEnv } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), "");
const remoteApi = env.VITE_REMOTE_API;
const remoteWs = remoteApi?.replace(/^https/, "wss").replace(/^http/, "ws");
return {
main: {
plugins: [externalizeDepsPlugin()],
},
preload: {
plugins: [externalizeDepsPlugin()],
},
renderer: {
server: {
port: 5173,
strictPort: true,
...(remoteApi && {
proxy: {
"/api": { target: remoteApi, changeOrigin: true },
"/auth": { target: remoteApi, changeOrigin: true },
"/uploads": { target: remoteApi, changeOrigin: true },
"/ws": { target: remoteWs, changeOrigin: true, ws: true },
},
}),
},
plugins: [react(), tailwindcss()],
resolve: {
alias: {
"@": resolve("src/renderer/src"),
},
dedupe: ["react", "react-dom"],
},
},
};
});

View File

@@ -1,6 +0,0 @@
import reactConfig from "@multica/eslint-config/react";
export default [
...reactConfig,
{ ignores: ["out/", "dist/"] },
];

View File

@@ -1,51 +0,0 @@
{
"name": "@multica/desktop",
"version": "0.1.0",
"private": true,
"main": "./out/main/index.js",
"scripts": {
"dev": "electron-vite dev",
"dev:remote": "electron-vite dev --mode remote",
"build": "electron-vite build",
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
"typecheck": "pnpm run typecheck:node && pnpm run typecheck:web",
"preview": "electron-vite preview",
"package": "electron-builder",
"lint": "eslint .",
"postinstall": "electron-builder install-app-deps"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@electron-toolkit/preload": "^3.0.2",
"@electron-toolkit/utils": "^4.0.0",
"@multica/core": "workspace:*",
"@multica/ui": "workspace:*",
"@multica/views": "workspace:*",
"@fontsource/geist-mono": "^5.2.7",
"@fontsource/geist-sans": "^5.2.5",
"react-router-dom": "^7.6.0",
"shadcn": "^4.1.0",
"sonner": "^2.0.7",
"tw-animate-css": "^1.4.0"
},
"devDependencies": {
"@electron-toolkit/tsconfig": "^2.0.0",
"@multica/tsconfig": "workspace:*",
"@tailwindcss/vite": "^4",
"@types/node": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"@vitejs/plugin-react": "^5.1.1",
"electron": "^39.2.6",
"electron-builder": "^26.0.12",
"electron-vite": "^5.0.0",
"react": "catalog:",
"react-dom": "catalog:",
"tailwindcss": "^4",
"typescript": "catalog:"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

View File

@@ -1,142 +0,0 @@
import { app, shell, BrowserWindow, ipcMain } from "electron";
import { join } from "path";
import { electronApp, optimizer, is } from "@electron-toolkit/utils";
const PROTOCOL = "multica";
let mainWindow: BrowserWindow | null = null;
// --- Deep link helpers ---------------------------------------------------
function handleDeepLink(url: string): void {
try {
const parsed = new URL(url);
if (parsed.protocol !== `${PROTOCOL}:`) return;
// multica://auth/callback?token=<jwt>
if (parsed.hostname === "auth" && parsed.pathname === "/callback") {
const token = parsed.searchParams.get("token");
if (token && mainWindow) {
mainWindow.webContents.send("auth:token", token);
}
}
} catch {
// Ignore malformed URLs
}
}
// --- Window creation -----------------------------------------------------
function createWindow(): void {
mainWindow = new BrowserWindow({
width: 1280,
height: 800,
minWidth: 900,
minHeight: 600,
titleBarStyle: "hiddenInset",
trafficLightPosition: { x: 16, y: 13 },
show: false,
autoHideMenuBar: true,
webPreferences: {
preload: join(__dirname, "../preload/index.js"),
sandbox: false,
webSecurity: false,
},
});
// Strip Origin header from WebSocket upgrade requests so the server's
// origin whitelist doesn't reject connections from localhost dev origins.
mainWindow.webContents.session.webRequest.onBeforeSendHeaders(
{ urls: ["wss://*/*", "ws://*/*"] },
(details, callback) => {
delete details.requestHeaders["Origin"];
callback({ requestHeaders: details.requestHeaders });
},
);
mainWindow.on("ready-to-show", () => {
mainWindow?.show();
});
mainWindow.webContents.setWindowOpenHandler((details) => {
shell.openExternal(details.url);
return { action: "deny" };
});
if (is.dev && process.env["ELECTRON_RENDERER_URL"]) {
mainWindow.loadURL(process.env["ELECTRON_RENDERER_URL"]);
} else {
mainWindow.loadFile(join(__dirname, "../renderer/index.html"));
}
}
// --- Protocol registration -----------------------------------------------
if (process.defaultApp) {
// In dev, register with the path to the electron binary + app path
app.setAsDefaultProtocolClient(PROTOCOL, process.execPath, [
app.getAppPath(),
]);
} else {
app.setAsDefaultProtocolClient(PROTOCOL);
}
// --- Single instance lock ------------------------------------------------
const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) {
app.quit();
} else {
// Windows/Linux: second instance passes deep link via argv
app.on("second-instance", (_event, argv) => {
if (mainWindow) {
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.focus();
}
// On Windows the deep link URL is the last argv entry
const deepLinkUrl = argv.find((arg) => arg.startsWith(`${PROTOCOL}://`));
if (deepLinkUrl) handleDeepLink(deepLinkUrl);
});
app.whenReady().then(() => {
electronApp.setAppUserModelId("ai.multica.desktop");
app.on("browser-window-created", (_, window) => {
optimizer.watchWindowShortcuts(window);
});
// IPC: open URL in default browser (used by renderer for Google login)
ipcMain.handle("shell:openExternal", (_event, url: string) => {
return shell.openExternal(url);
});
createWindow();
// macOS: deep link arrives via open-url event
app.on("open-url", (_event, url) => {
if (mainWindow) {
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.focus();
}
handleDeepLink(url);
});
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
});
});
// Check argv for deep link on cold start (Windows/Linux)
const deepLinkArg = process.argv.find((arg) =>
arg.startsWith(`${PROTOCOL}://`),
);
if (deepLinkArg) {
app.whenReady().then(() => handleDeepLink(deepLinkArg));
}
}
app.on("window-all-closed", () => {
if (process.platform !== "darwin") app.quit();
});

View File

@@ -1,17 +0,0 @@
import { ElectronAPI } from "@electron-toolkit/preload";
interface DesktopAPI {
/** Listen for auth token delivered via deep link. Returns an unsubscribe function. */
onAuthToken: (callback: (token: string) => void) => () => void;
/** Open a URL in the default browser. */
openExternal: (url: string) => Promise<void>;
}
declare global {
interface Window {
electron: ElectronAPI;
desktopAPI: DesktopAPI;
}
}
export {};

View File

@@ -1,26 +0,0 @@
import { contextBridge, ipcRenderer } from "electron";
import { electronAPI } from "@electron-toolkit/preload";
const desktopAPI = {
/** Listen for auth token delivered via deep link */
onAuthToken: (callback: (token: string) => void) => {
const handler = (_event: Electron.IpcRendererEvent, token: string) =>
callback(token);
ipcRenderer.on("auth:token", handler);
return () => {
ipcRenderer.removeListener("auth:token", handler);
};
},
/** Open a URL in the default browser */
openExternal: (url: string) => ipcRenderer.invoke("shell:openExternal", url),
};
if (process.contextIsolated) {
contextBridge.exposeInMainWorld("electron", electronAPI);
contextBridge.exposeInMainWorld("desktopAPI", desktopAPI);
} else {
// @ts-expect-error - fallback for non-isolated context
window.electron = electronAPI;
// @ts-expect-error - fallback for non-isolated context
window.desktopAPI = desktopAPI;
}

View File

@@ -1,12 +0,0 @@
<!doctype html>
<html lang="en" class="h-full">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Multica</title>
</head>
<body class="h-full overflow-hidden antialiased font-sans">
<div id="root" class="h-full"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -1,56 +0,0 @@
import { useEffect } from "react";
import { CoreProvider } from "@multica/core/platform";
import { useAuthStore } from "@multica/core/auth";
import { useWorkspaceStore } from "@multica/core/workspace";
import { api } from "@multica/core/api";
import { ThemeProvider } from "@multica/ui/components/common/theme-provider";
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
import { Toaster } from "sonner";
import { DesktopLoginPage } from "./pages/login";
import { DesktopShell } from "./components/desktop-layout";
function AppContent() {
const user = useAuthStore((s) => s.user);
const isLoading = useAuthStore((s) => s.isLoading);
// Listen for auth token delivered via deep link (multica://auth/callback?token=...)
useEffect(() => {
return window.desktopAPI.onAuthToken(async (token) => {
try {
await useAuthStore.getState().loginWithToken(token);
const wsList = await api.listWorkspaces();
const lastWsId = localStorage.getItem("multica_workspace_id");
useWorkspaceStore.getState().hydrateWorkspace(wsList, lastWsId);
} catch {
// Token invalid or expired — user stays on login page
}
});
}, []);
if (isLoading) {
return (
<div className="flex h-screen items-center justify-center">
<MulticaIcon className="size-6 animate-pulse" />
</div>
);
}
if (!user) return <DesktopLoginPage />;
return <DesktopShell />;
}
const remoteProxy = Boolean(import.meta.env.VITE_REMOTE_API);
export default function App() {
return (
<ThemeProvider>
<CoreProvider
apiBaseUrl={remoteProxy ? "" : (import.meta.env.VITE_API_URL || "http://localhost:8080")}
wsUrl={remoteProxy ? "ws://localhost:5173/ws" : (import.meta.env.VITE_WS_URL || "ws://localhost:8080/ws")}
>
<AppContent />
</CoreProvider>
<Toaster />
</ThemeProvider>
);
}

View File

@@ -1,102 +0,0 @@
import { useEffect } from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { useTabHistory } from "@/hooks/use-tab-history";
import { useActiveTitleSync } from "@/hooks/use-tab-sync";
import { useTabStore, resolveRouteIcon } from "@/stores/tab-store";
import { SidebarProvider } from "@multica/ui/components/ui/sidebar";
import { ModalRegistry } from "@multica/views/modals/registry";
import { AppSidebar, DashboardGuard } from "@multica/views/layout";
import { SearchCommand, SearchTrigger } from "@multica/views/search";
import { ChatFab, ChatWindow } from "@multica/views/chat";
import { DesktopNavigationProvider } from "@/platform/navigation";
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
import { TabBar } from "./tab-bar";
import { TabContent } from "./tab-content";
function SidebarTopBar() {
const { canGoBack, canGoForward, goBack, goForward } = useTabHistory();
return (
<div
className="h-12 shrink-0 flex items-center justify-end px-2"
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
>
<div
className="flex items-center gap-0.5"
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
>
<button
onClick={goBack}
disabled={!canGoBack}
className="flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-30 disabled:pointer-events-none"
>
<ChevronLeft className="size-4" />
</button>
<button
onClick={goForward}
disabled={!canGoForward}
className="flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-30 disabled:pointer-events-none"
>
<ChevronRight className="size-4" />
</button>
</div>
</div>
);
}
function useInternalLinkHandler() {
useEffect(() => {
const handler = (e: Event) => {
const path = (e as CustomEvent).detail?.path;
if (!path) return;
const icon = resolveRouteIcon(path);
const store = useTabStore.getState();
const tabId = store.openTab(path, path, icon);
store.setActiveTab(tabId);
};
window.addEventListener("multica:navigate", handler);
return () => window.removeEventListener("multica:navigate", handler);
}, []);
}
export function DesktopShell() {
useInternalLinkHandler();
useActiveTitleSync();
return (
<DesktopNavigationProvider>
<DashboardGuard
loginPath="/login"
loadingFallback={
<div className="flex h-screen items-center justify-center">
<MulticaIcon className="size-6 animate-pulse" />
</div>
}
>
<div className="flex h-screen">
<SidebarProvider className="flex-1">
<AppSidebar topSlot={<SidebarTopBar />} searchSlot={<SearchTrigger />} />
{/* Right side: header + content container */}
<div className="flex flex-1 min-w-0 flex-col">
{/* Tab bar + drag region */}
<header
className="h-12 shrink-0"
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
>
<TabBar />
</header>
{/* Content area with inset styling — relative so ChatWindow/ChatFab are constrained here */}
<div className="relative flex flex-1 min-h-0 flex-col overflow-hidden mr-2 mb-2 ml-0.5 rounded-xl shadow-sm bg-background">
<TabContent />
<ChatWindow />
<ChatFab />
</div>
</div>
</SidebarProvider>
</div>
<ModalRegistry />
<SearchCommand />
</DashboardGuard>
</DesktopNavigationProvider>
);
}

View File

@@ -1,188 +0,0 @@
import {
Inbox,
CircleUser,
ListTodo,
Bot,
Monitor,
BookOpenText,
Settings,
X,
Plus,
type LucideIcon,
} from "lucide-react";
import {
DndContext,
PointerSensor,
useSensor,
useSensors,
closestCenter,
type DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext,
horizontalListSortingStrategy,
useSortable,
} from "@dnd-kit/sortable";
import {
restrictToHorizontalAxis,
restrictToParentElement,
} from "@dnd-kit/modifiers";
import { CSS } from "@dnd-kit/utilities";
import { cn } from "@multica/ui/lib/utils";
import { useTabStore, resolveRouteIcon, type Tab } from "@/stores/tab-store";
const TAB_ICONS: Record<string, LucideIcon> = {
Inbox,
CircleUser,
ListTodo,
Bot,
Monitor,
BookOpenText,
Settings,
};
function SortableTabItem({ tab, isActive, isOnly }: { tab: Tab; isActive: boolean; isOnly: boolean }) {
const setActiveTab = useTabStore((s) => s.setActiveTab);
const closeTab = useTabStore((s) => s.closeTab);
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: tab.id });
const Icon = TAB_ICONS[tab.icon];
const style = {
transform: CSS.Transform.toString(transform),
transition,
WebkitAppRegion: "no-drag",
zIndex: isDragging ? 10 : undefined,
} as React.CSSProperties;
const handleClick = () => {
if (isActive) return;
setActiveTab(tab.id);
// No navigate() — Activity handles visibility
};
const handleClose = (e: React.MouseEvent) => {
e.stopPropagation();
closeTab(tab.id);
// No navigate() — store handles activeTabId switch
};
// Stop pointer down on close so it doesn't start a drag on the parent button.
const stopDragOnClose = (e: React.PointerEvent) => {
e.stopPropagation();
};
return (
<button
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
onClick={handleClick}
className={cn(
"group flex h-7 w-40 items-center gap-1.5 rounded-md px-2 text-xs transition-colors",
"select-none cursor-default",
isActive
? "bg-sidebar-accent font-medium text-sidebar-accent-foreground"
: "bg-sidebar-accent/50 text-muted-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
isDragging && "opacity-60",
)}
>
{Icon && <Icon className="size-3.5 shrink-0" />}
<span
className="min-w-0 flex-1 overflow-hidden whitespace-nowrap text-left"
style={{
maskImage: "linear-gradient(to right, black calc(100% - 12px), transparent)",
WebkitMaskImage: "linear-gradient(to right, black calc(100% - 12px), transparent)",
}}
>
{tab.title}
</span>
{!isOnly && (
<span
onClick={handleClose}
onPointerDown={stopDragOnClose}
className="hidden size-3.5 shrink-0 items-center justify-center rounded-sm text-muted-foreground transition-colors group-hover:flex hover:bg-muted-foreground/20 hover:text-foreground"
>
<X className="size-2.5" />
</span>
)}
</button>
);
}
function NewTabButton() {
const addTab = useTabStore((s) => s.addTab);
const setActiveTab = useTabStore((s) => s.setActiveTab);
const handleClick = () => {
const path = "/issues";
const tabId = addTab(path, "Issues", resolveRouteIcon(path));
setActiveTab(tabId);
// No navigate() — new tab's router starts at /issues automatically
};
return (
<button
onClick={handleClick}
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
className="flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground/70 transition-colors hover:bg-muted/50 hover:text-muted-foreground"
>
<Plus className="size-3.5" />
</button>
);
}
export function TabBar() {
const tabs = useTabStore((s) => s.tabs);
const activeTabId = useTabStore((s) => s.activeTabId);
const moveTab = useTabStore((s) => s.moveTab);
// distance: 5 — pointer must move 5px to start a drag, otherwise it's a click.
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 5 },
}),
);
const tabIds = tabs.map((t) => t.id);
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const from = tabs.findIndex((t) => t.id === active.id);
const to = tabs.findIndex((t) => t.id === over.id);
if (from !== -1 && to !== -1) moveTab(from, to);
};
return (
<div className="flex h-full items-center gap-0.5 px-2 justify-start">
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
modifiers={[restrictToHorizontalAxis, restrictToParentElement]}
onDragEnd={handleDragEnd}
>
<SortableContext items={tabIds} strategy={horizontalListSortingStrategy}>
{tabs.map((tab) => (
<SortableTabItem
key={tab.id}
tab={tab}
isActive={tab.id === activeTabId}
isOnly={tabs.length === 1}
/>
))}
</SortableContext>
</DndContext>
<NewTabButton />
</div>
);
}

View File

@@ -1,43 +0,0 @@
import { Activity, useEffect } from "react";
import { RouterProvider } from "react-router-dom";
import { useTabStore } from "@/stores/tab-store";
import { TabNavigationProvider } from "@/platform/navigation";
import { useTabRouterSync } from "@/hooks/use-tab-router-sync";
/** Inner wrapper rendered inside each tab's RouterProvider. */
function TabRouterInner({ tabId }: { tabId: string }) {
const tab = useTabStore((s) => s.tabs.find((t) => t.id === tabId));
useTabRouterSync(tabId, tab!.router);
return null;
}
/**
* Renders all tabs using Activity for state preservation.
* Only the active tab is visible; hidden tabs keep their DOM and React state.
*/
export function TabContent() {
const tabs = useTabStore((s) => s.tabs);
const activeTabId = useTabStore((s) => s.activeTabId);
// Sync document.title when switching tabs
useEffect(() => {
const tab = tabs.find((t) => t.id === activeTabId);
if (tab) document.title = tab.title;
}, [activeTabId, tabs]);
return (
<>
{tabs.map((tab) => (
<Activity
key={tab.id}
mode={tab.id === activeTabId ? "visible" : "hidden"}
>
<TabNavigationProvider router={tab.router}>
<RouterProvider router={tab.router} />
<TabRouterInner tabId={tab.id} />
</TabNavigationProvider>
</Activity>
))}
</>
);
}

View File

@@ -1 +0,0 @@
/// <reference types="vite/client" />

View File

@@ -1,24 +0,0 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@import "@multica/ui/styles/tokens.css";
@import "@multica/ui/styles/base.css";
@custom-variant dark (&:is(.dark *));
/* Geist font: define CSS variables that tokens.css @theme inline references.
Web app gets these from next/font/google; desktop must set them explicitly. */
:root {
--font-sans: "Geist Sans", ui-sans-serif, system-ui, -apple-system, sans-serif;
--font-mono: "Geist Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
}
@source "../../../../../packages/ui/**/*.tsx";
@source "../../../../../packages/core/**/*.{ts,tsx}";
@source "../../../../../packages/views/**/*.{ts,tsx}";
@source "./**/*.tsx";
/* Desktop-specific: override sidebar container padding for traffic light layout */
[data-slot="sidebar-container"] {
padding: 0 !important;
}

View File

@@ -1,8 +0,0 @@
import { useEffect } from "react";
/** Sets document.title. The tab system observes this automatically. */
export function useDocumentTitle(title: string) {
useEffect(() => {
if (title) document.title = title;
}, [title]);
}

View File

@@ -1,40 +0,0 @@
import { useCallback } from "react";
import type { DataRouter } from "react-router-dom";
import { useTabStore } from "@/stores/tab-store";
/**
* Shared hint map so useTabRouterSync can distinguish back vs forward POP.
* Set before calling router.navigate(-1 | 1), read in the synchronous subscription.
*/
export const popDirectionHints = new Map<DataRouter, "back" | "forward">();
/**
* Per-tab back/forward navigation derived from the active tab's history state.
* Replaces the old global useNavigationHistory() hook.
*/
export function useTabHistory() {
// Return the actual tab object from the store — stable reference.
// Do NOT create a new object in the selector (causes infinite re-renders).
const activeTab = useTabStore((s) =>
s.tabs.find((t) => t.id === s.activeTabId),
);
const canGoBack = (activeTab?.historyIndex ?? 0) > 0;
const canGoForward =
(activeTab?.historyIndex ?? 0) < (activeTab?.historyLength ?? 1) - 1;
const goBack = useCallback(() => {
if (!activeTab || activeTab.historyIndex <= 0) return;
popDirectionHints.set(activeTab.router, "back");
activeTab.router.navigate(-1);
}, [activeTab]);
const goForward = useCallback(() => {
if (!activeTab || activeTab.historyIndex >= activeTab.historyLength - 1)
return;
popDirectionHints.set(activeTab.router, "forward");
activeTab.router.navigate(1);
}, [activeTab]);
return { canGoBack, canGoForward, goBack, goForward };
}

View File

@@ -1,49 +0,0 @@
import { useEffect, useRef } from "react";
import type { DataRouter } from "react-router-dom";
import { useTabStore, resolveRouteIcon } from "@/stores/tab-store";
import { popDirectionHints } from "./use-tab-history";
/**
* Subscribe to a tab's memory router and sync path + history tracking
* back into the tab store.
*
* Called once per tab inside its RouterProvider subtree.
*/
export function useTabRouterSync(tabId: string, router: DataRouter) {
const indexRef = useRef(0);
const lengthRef = useRef(1);
useEffect(() => {
// Sync initial state
const initialPath = router.state.location.pathname;
const store = useTabStore.getState();
store.updateTab(tabId, { path: initialPath, icon: resolveRouteIcon(initialPath) });
const unsubscribe = router.subscribe((state) => {
const { pathname } = state.location;
const action = state.historyAction;
if (action === "PUSH") {
indexRef.current += 1;
lengthRef.current = indexRef.current + 1;
} else if (action === "POP") {
// Determine direction from the hint set by goBack/goForward
const hint = popDirectionHints.get(router);
popDirectionHints.delete(router);
if (hint === "forward") {
indexRef.current = Math.min(indexRef.current + 1, lengthRef.current - 1);
} else {
// Default to back
indexRef.current = Math.max(0, indexRef.current - 1);
}
}
// REPLACE: index and length stay the same
const store = useTabStore.getState();
store.updateTab(tabId, { path: pathname, icon: resolveRouteIcon(pathname) });
store.updateTabHistory(tabId, indexRef.current, lengthRef.current);
});
return unsubscribe;
}, [tabId, router]);
}

View File

@@ -1,29 +0,0 @@
import { useEffect } from "react";
import { useTabStore } from "@/stores/tab-store";
/**
* Watches document.title via MutationObserver and updates the active tab's title.
*
* Pages set document.title via TitleSync (route handle.title) or useDocumentTitle().
* This observer picks up the change and syncs it to the tab store.
*/
export function useActiveTitleSync() {
useEffect(() => {
const observer = new MutationObserver(() => {
const title = document.title;
if (!title) return;
const { tabs, activeTabId } = useTabStore.getState();
const activeTab = tabs.find((t) => t.id === activeTabId);
if (activeTab && activeTab.title !== title) {
useTabStore.getState().updateTab(activeTabId, { title });
}
});
const titleEl = document.querySelector("title");
if (titleEl) {
observer.observe(titleEl, { childList: true, characterData: true, subtree: true });
}
return () => observer.disconnect();
}, []);
}

View File

@@ -1,11 +0,0 @@
import ReactDOM from "react-dom/client";
import App from "./App";
import "@fontsource/geist-sans/400.css";
import "@fontsource/geist-sans/500.css";
import "@fontsource/geist-sans/600.css";
import "@fontsource/geist-sans/700.css";
import "@fontsource/geist-mono/400.css";
import "@fontsource/geist-mono/700.css";
import "./globals.css";
ReactDOM.createRoot(document.getElementById("root")!).render(<App />);

View File

@@ -1,17 +0,0 @@
import { useParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { IssueDetail } from "@multica/views/issues/components";
import { useWorkspaceId } from "@multica/core/hooks";
import { issueDetailOptions } from "@multica/core/issues/queries";
import { useDocumentTitle } from "@/hooks/use-document-title";
export function IssueDetailPage() {
const { id } = useParams<{ id: string }>();
const wsId = useWorkspaceId();
const { data: issue } = useQuery(issueDetailOptions(wsId, id!));
useDocumentTitle(issue ? `${issue.identifier}: ${issue.title}` : "Issue");
if (!id) return null;
return <IssueDetail issueId={id} />;
}

View File

@@ -1,34 +0,0 @@
import { LoginPage } from "@multica/views/auth";
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
const WEB_URL = import.meta.env.VITE_WEB_URL || "http://localhost:3000";
export function DesktopLoginPage() {
const lastWorkspaceId = localStorage.getItem("multica_workspace_id");
const handleGoogleLogin = () => {
// Open web login page in the default browser with platform=desktop flag.
// The web callback will redirect back via multica:// deep link with the token.
window.desktopAPI.openExternal(
`${WEB_URL}/login?platform=desktop`,
);
};
return (
<div className="flex h-screen flex-col">
{/* Traffic light inset */}
<div
className="h-[38px] shrink-0"
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
/>
<LoginPage
logo={<MulticaIcon bordered size="lg" />}
lastWorkspaceId={lastWorkspaceId}
onSuccess={() => {
// Auth store update triggers AppContent re-render → shows DesktopShell
}}
onGoogleLogin={handleGoogleLogin}
/>
</div>
);
}

View File

@@ -1,17 +0,0 @@
import { useParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { ProjectDetail } from "@multica/views/projects/components";
import { useWorkspaceId } from "@multica/core/hooks";
import { projectDetailOptions } from "@multica/core/projects/queries";
import { useDocumentTitle } from "@/hooks/use-document-title";
export function ProjectDetailPage() {
const { id } = useParams<{ id: string }>();
const wsId = useWorkspaceId();
const { data: project } = useQuery(projectDetailOptions(wsId, id!));
useDocumentTitle(project ? `${project.icon || "📁"} ${project.title}` : "Project");
if (!id) return null;
return <ProjectDetail projectId={id} />;
}

View File

@@ -1,116 +0,0 @@
import { useEffect, useMemo, useState } from "react";
import type { DataRouter } from "react-router-dom";
import {
NavigationProvider,
type NavigationAdapter,
} from "@multica/views/navigation";
import { useAuthStore } from "@multica/core/auth";
import { useTabStore, resolveRouteIcon } from "@/stores/tab-store";
/**
* Root-level navigation provider for components outside the per-tab RouterProviders
* (sidebar, search dialog, modals, etc.).
*
* Reads from the active tab's memory router via router.subscribe().
* Does NOT use any react-router hooks — it's above all RouterProviders.
*/
export function DesktopNavigationProvider({
children,
}: {
children: React.ReactNode;
}) {
const activeTab = useTabStore((s) => s.tabs.find((t) => t.id === s.activeTabId));
const [pathname, setPathname] = useState(activeTab?.path ?? "/issues");
// Subscribe to the active tab's router for pathname updates
useEffect(() => {
if (!activeTab) return;
setPathname(activeTab.router.state.location.pathname);
return activeTab.router.subscribe((state) => {
setPathname(state.location.pathname);
});
}, [activeTab?.id]); // eslint-disable-line react-hooks/exhaustive-deps
const adapter: NavigationAdapter = useMemo(
() => ({
push: (path: string) => {
if (path === "/login") {
// DashboardGuard token expired — force back to login screen
useAuthStore.getState().logout();
return;
}
const tab = useTabStore.getState().tabs.find(
(t) => t.id === useTabStore.getState().activeTabId,
);
tab?.router.navigate(path);
},
replace: (path: string) => {
const tab = useTabStore.getState().tabs.find(
(t) => t.id === useTabStore.getState().activeTabId,
);
tab?.router.navigate(path, { replace: true });
},
back: () => {
const tab = useTabStore.getState().tabs.find(
(t) => t.id === useTabStore.getState().activeTabId,
);
tab?.router.navigate(-1);
},
pathname,
searchParams: new URLSearchParams(),
openInNewTab: (path: string, title?: string) => {
const icon = resolveRouteIcon(path);
const store = useTabStore.getState();
const tabId = store.openTab(path, title ?? path, icon);
store.setActiveTab(tabId);
},
getShareableUrl: (path: string) => `https://www.multica.ai${path}`,
}),
[pathname],
);
return <NavigationProvider value={adapter}>{children}</NavigationProvider>;
}
/**
* Per-tab navigation provider rendered inside each tab's Activity wrapper.
* Subscribes to the tab's own router for up-to-date pathname.
*
* This is what @multica/views page components read via useNavigation().
*/
export function TabNavigationProvider({
router,
children,
}: {
router: DataRouter;
children: React.ReactNode;
}) {
const [location, setLocation] = useState(router.state.location);
useEffect(() => {
setLocation(router.state.location);
return router.subscribe((state) => {
setLocation(state.location);
});
}, [router]);
const adapter: NavigationAdapter = useMemo(
() => ({
push: (path: string) => router.navigate(path),
replace: (path: string) => router.navigate(path, { replace: true }),
back: () => router.navigate(-1),
pathname: location.pathname,
searchParams: new URLSearchParams(location.search),
openInNewTab: (path: string, title?: string) => {
const icon = resolveRouteIcon(path);
const store = useTabStore.getState();
const newTabId = store.openTab(path, title ?? path, icon);
store.setActiveTab(newTabId);
},
getShareableUrl: (path: string) => `https://www.multica.ai${path}`,
}),
[router, location],
);
return <NavigationProvider value={adapter}>{children}</NavigationProvider>;
}

View File

@@ -1,99 +0,0 @@
import { useEffect } from "react";
import {
createMemoryRouter,
Navigate,
Outlet,
useMatches,
} from "react-router-dom";
import type { RouteObject } from "react-router-dom";
import { IssueDetailPage } from "./pages/issue-detail-page";
import { ProjectDetailPage } from "./pages/project-detail-page";
import { IssuesPage } from "@multica/views/issues/components";
import { ProjectsPage } from "@multica/views/projects/components";
import { MyIssuesPage } from "@multica/views/my-issues";
import { RuntimesPage } from "@multica/views/runtimes";
import { SkillsPage } from "@multica/views/skills";
import { AgentsPage } from "@multica/views/agents";
import { InboxPage } from "@multica/views/inbox";
import { SettingsPage } from "@multica/views/settings";
/**
* Sets document.title from the deepest matched route's handle.title.
* The tab system observes document.title via MutationObserver.
* Pages with dynamic titles (e.g. issue detail) override by setting
* document.title directly via useDocumentTitle().
*/
function TitleSync() {
const matches = useMatches();
const title = [...matches]
.reverse()
.find((m) => (m.handle as { title?: string })?.title)
?.handle as { title?: string } | undefined;
useEffect(() => {
if (title?.title) document.title = title.title;
}, [title?.title]);
return null;
}
/** Wrapper that renders route children + TitleSync */
function PageShell() {
return (
<>
<TitleSync />
<Outlet />
</>
);
}
/** Route definitions shared by all tabs (no layout wrapper). */
export const appRoutes: RouteObject[] = [
{
element: <PageShell />,
children: [
{ index: true, element: <Navigate to="/issues" replace /> },
{ path: "issues", element: <IssuesPage />, handle: { title: "Issues" } },
{
path: "issues/:id",
element: <IssueDetailPage />,
handle: { title: "Issue" },
},
{
path: "projects",
element: <ProjectsPage />,
handle: { title: "Projects" },
},
{
path: "projects/:id",
element: <ProjectDetailPage />,
handle: { title: "Project" },
},
{
path: "my-issues",
element: <MyIssuesPage />,
handle: { title: "My Issues" },
},
{
path: "runtimes",
element: <RuntimesPage />,
handle: { title: "Runtimes" },
},
{ path: "skills", element: <SkillsPage />, handle: { title: "Skills" } },
{ path: "agents", element: <AgentsPage />, handle: { title: "Agents" } },
{ path: "inbox", element: <InboxPage />, handle: { title: "Inbox" } },
{
path: "settings",
element: <SettingsPage />,
handle: { title: "Settings" },
},
],
},
];
/** Create an independent memory router for a tab. */
export function createTabRouter(initialPath: string) {
return createMemoryRouter(appRoutes, {
initialEntries: [initialPath],
});
}

View File

@@ -1,196 +0,0 @@
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import { arrayMove } from "@dnd-kit/sortable";
import { createPersistStorage, defaultStorage } from "@multica/core/platform";
import { createSafeId } from "@multica/core/utils";
import type { DataRouter } from "react-router-dom";
import { createTabRouter } from "../routes";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface Tab {
id: string;
path: string;
title: string;
icon: string;
router: DataRouter;
historyIndex: number;
historyLength: number;
}
interface TabStore {
tabs: Tab[];
activeTabId: string;
/** Open a background tab. Deduplicates by path. Returns the tab id. */
openTab: (path: string, title: string, icon: string) => string;
/** Always create a new tab (no dedup). Returns the tab id. */
addTab: (path: string, title: string, icon: string) => string;
/** Close a tab. Disposes router. */
closeTab: (tabId: string) => void;
/** Switch to a tab by id. */
setActiveTab: (tabId: string) => void;
/** Update a tab's metadata (path, title, icon — partial). */
updateTab: (tabId: string, patch: Partial<Pick<Tab, "path" | "title" | "icon">>) => void;
/** Update a tab's history tracking. */
updateTabHistory: (tabId: string, historyIndex: number, historyLength: number) => void;
/** Reorder tabs by moving one from fromIndex to toIndex. Preserves router/history. */
moveTab: (fromIndex: number, toIndex: number) => void;
}
// ---------------------------------------------------------------------------
// Route → icon mapping (title comes from document.title, not from here)
// ---------------------------------------------------------------------------
const ROUTE_ICONS: Record<string, string> = {
"/inbox": "Inbox",
"/my-issues": "CircleUser",
"/issues": "ListTodo",
"/projects": "FolderKanban",
"/agents": "Bot",
"/runtimes": "Monitor",
"/skills": "BookOpenText",
"/settings": "Settings",
};
/** Resolve a route icon. Title is NOT determined here — it comes from document.title. */
export function resolveRouteIcon(pathname: string): string {
return ROUTE_ICONS[pathname]
?? (pathname.startsWith("/issues/") ? "ListTodo" : undefined)
?? (pathname.startsWith("/projects/") ? "FolderKanban" : undefined)
?? "ListTodo";
}
// ---------------------------------------------------------------------------
// Store
// ---------------------------------------------------------------------------
const DEFAULT_PATH = "/issues";
function createId(): string {
return createSafeId();
}
function makeTab(path: string, title: string, icon: string): Tab {
return {
id: createId(),
path,
title,
icon,
router: createTabRouter(path),
historyIndex: 0,
historyLength: 1,
};
}
const initialTab = makeTab(DEFAULT_PATH, "Issues", resolveRouteIcon(DEFAULT_PATH));
export const useTabStore = create<TabStore>()(
persist(
(set, get) => ({
tabs: [initialTab],
activeTabId: initialTab.id,
openTab(path, title, icon) {
const { tabs } = get();
const existing = tabs.find((t) => t.path === path);
if (existing) return existing.id;
const tab = makeTab(path, title, icon);
set({ tabs: [...tabs, tab] });
return tab.id;
},
addTab(path, title, icon) {
const tab = makeTab(path, title, icon);
set((s) => ({ tabs: [...s.tabs, tab] }));
return tab.id;
},
closeTab(tabId) {
const { tabs, activeTabId } = get();
const closingTab = tabs.find((t) => t.id === tabId);
// Never close the last tab — replace with default
if (tabs.length === 1) {
closingTab?.router.dispose();
const fresh = makeTab(DEFAULT_PATH, "Issues", resolveRouteIcon(DEFAULT_PATH));
set({ tabs: [fresh], activeTabId: fresh.id });
return;
}
const idx = tabs.findIndex((t) => t.id === tabId);
if (idx === -1) return;
closingTab?.router.dispose();
const next = tabs.filter((t) => t.id !== tabId);
if (tabId === activeTabId) {
const newActive = next[Math.min(idx, next.length - 1)];
set({ tabs: next, activeTabId: newActive.id });
} else {
set({ tabs: next });
}
},
setActiveTab(tabId) {
set({ activeTabId: tabId });
},
updateTab(tabId, patch) {
set((s) => ({
tabs: s.tabs.map((t) =>
t.id === tabId ? { ...t, ...patch } : t,
),
}));
},
updateTabHistory(tabId, historyIndex, historyLength) {
set((s) => ({
tabs: s.tabs.map((t) =>
t.id === tabId ? { ...t, historyIndex, historyLength } : t,
),
}));
},
moveTab(fromIndex, toIndex) {
if (fromIndex === toIndex) return;
set((s) => ({ tabs: arrayMove(s.tabs, fromIndex, toIndex) }));
},
}),
{
name: "multica_tabs",
version: 1,
storage: createJSONStorage(() => createPersistStorage(defaultStorage)),
partialize: (state) => ({
tabs: state.tabs.map(
({ router, historyIndex, historyLength, ...rest }) => rest,
),
activeTabId: state.activeTabId,
}),
merge: (persistedState, currentState) => {
const persisted = persistedState as
| Pick<TabStore, "tabs" | "activeTabId">
| undefined;
if (!persisted?.tabs?.length) return currentState;
const tabs: Tab[] = persisted.tabs.map((tab) => ({
...tab,
router: createTabRouter(tab.path),
historyIndex: 0,
historyLength: 1,
}));
// Validate activeTabId — fall back to first tab if stale
const activeTabId = tabs.some((t) => t.id === persisted.activeTabId)
? persisted.activeTabId
: tabs[0].id;
return { ...currentState, tabs, activeTabId };
},
},
),
);

View File

@@ -1,4 +0,0 @@
{
"files": [],
"references": [{ "path": "./tsconfig.node.json" }, { "path": "./tsconfig.web.json" }]
}

View File

@@ -1,8 +0,0 @@
{
"extends": "@electron-toolkit/tsconfig/tsconfig.node.json",
"include": ["electron.vite.config.*", "src/main/**/*", "src/preload/**/*"],
"compilerOptions": {
"composite": true,
"types": ["electron-vite/node"]
}
}

View File

@@ -1,20 +0,0 @@
{
"extends": "@electron-toolkit/tsconfig/tsconfig.web.json",
"include": [
"src/renderer/src/env.d.ts",
"src/renderer/src/**/*",
"src/renderer/src/**/*.tsx",
"src/preload/*.d.ts"
],
"compilerOptions": {
"composite": true,
"noImplicitAny": true,
"jsx": "react-jsx",
"baseUrl": ".",
"paths": {
"@/*": [
"src/renderer/src/*"
]
}
}
}

View File

@@ -1,3 +0,0 @@
.next/
.source/
node_modules/

View File

@@ -1,7 +0,0 @@
import type { ReactNode } from "react";
import { HomeLayout } from "fumadocs-ui/layouts/home";
import { baseOptions } from "@/app/layout.config";
export default function Layout({ children }: { children: ReactNode }) {
return <HomeLayout {...baseOptions}>{children}</HomeLayout>;
}

View File

@@ -1,29 +0,0 @@
import Link from "next/link";
export default function HomePage() {
return (
<main className="flex min-h-screen flex-col items-center justify-center gap-6 text-center px-4">
<h1 className="text-4xl font-bold tracking-tight sm:text-5xl">
Multica Documentation
</h1>
<p className="max-w-2xl text-lg text-fd-muted-foreground">
The open-source managed agents platform. Turn coding agents into real
teammates assign tasks, track progress, compound skills.
</p>
<div className="flex gap-4">
<Link
href="/docs"
className="inline-flex items-center rounded-md bg-fd-primary px-6 py-3 text-sm font-medium text-fd-primary-foreground transition-colors hover:bg-fd-primary/90"
>
Get Started
</Link>
<Link
href="https://github.com/multica-ai/multica"
className="inline-flex items-center rounded-md border border-fd-border px-6 py-3 text-sm font-medium transition-colors hover:bg-fd-accent"
>
GitHub
</Link>
</div>
</main>
);
}

View File

@@ -1,4 +0,0 @@
import { source } from "@/lib/source";
import { createFromSource } from "fumadocs-core/search/server";
export const { GET } = createFromSource(source);

View File

@@ -1,47 +0,0 @@
import { source } from "@/lib/source";
import {
DocsPage,
DocsBody,
DocsDescription,
DocsTitle,
} from "fumadocs-ui/page";
import { notFound } from "next/navigation";
import defaultMdxComponents from "fumadocs-ui/mdx";
import type { Metadata } from "next";
export default async function Page(props: {
params: Promise<{ slug?: string[] }>;
}) {
const params = await props.params;
const page = source.getPage(params.slug);
if (!page) notFound();
const MDX = page.data.body;
return (
<DocsPage toc={page.data.toc}>
<DocsTitle>{page.data.title}</DocsTitle>
<DocsDescription>{page.data.description}</DocsDescription>
<DocsBody>
<MDX components={{ ...defaultMdxComponents }} />
</DocsBody>
</DocsPage>
);
}
export async function generateStaticParams() {
return source.generateParams();
}
export async function generateMetadata(props: {
params: Promise<{ slug?: string[] }>;
}): Promise<Metadata> {
const params = await props.params;
const page = source.getPage(params.slug);
if (!page) notFound();
return {
title: page.data.title,
description: page.data.description,
};
}

View File

@@ -1,12 +0,0 @@
import { DocsLayout } from "fumadocs-ui/layouts/docs";
import type { ReactNode } from "react";
import { baseOptions } from "@/app/layout.config";
import { source } from "@/lib/source";
export default function Layout({ children }: { children: ReactNode }) {
return (
<DocsLayout tree={source.pageTree} {...baseOptions}>
{children}
</DocsLayout>
);
}

View File

@@ -1,3 +0,0 @@
@import "tailwindcss";
@import "fumadocs-ui/css/neutral.css";
@import "fumadocs-ui/css/preset.css";

View File

@@ -1,25 +0,0 @@
import type { BaseLayoutProps } from "fumadocs-ui/layouts/shared";
import { BookOpen, Terminal, Rocket, Code } from "lucide-react";
export const baseOptions: BaseLayoutProps = {
nav: {
title: (
<span className="font-semibold text-base">Multica Docs</span>
),
},
links: [
{
text: "Documentation",
url: "/docs",
active: "nested-url",
},
{
text: "GitHub",
url: "https://github.com/multica-ai/multica",
},
{
text: "Cloud",
url: "https://multica.ai",
},
],
};

View File

@@ -1,23 +0,0 @@
import "./global.css";
import { RootProvider } from "fumadocs-ui/provider";
import type { ReactNode } from "react";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: {
template: "%s | Multica Docs",
default: "Multica Docs",
},
description:
"Documentation for Multica — the open-source managed agents platform.",
};
export default function Layout({ children }: { children: ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<body>
<RootProvider>{children}</RootProvider>
</body>
</html>
);
}

View File

@@ -1,90 +0,0 @@
---
title: CLI Installation
description: Install the Multica CLI and start the agent daemon.
---
## Installation
### Homebrew (macOS/Linux)
```bash
brew tap multica-ai/tap
brew install multica
```
### Build from Source
```bash
git clone https://github.com/multica-ai/multica.git
cd multica
make build
cp server/bin/multica /usr/local/bin/multica
```
### Download from GitHub Releases
If Homebrew is not available, download the binary directly:
```bash
OS=$(uname -s | tr '[:upper:]' '[:lower:]') # "darwin" or "linux"
ARCH=$(uname -m) # "x86_64" or "arm64"
# Normalize architecture name
if [ "$ARCH" = "x86_64" ]; then
ARCH="amd64"
fi
# Get the latest release tag from GitHub
LATEST=$(curl -sI https://github.com/multica-ai/multica/releases/latest \
| grep -i '^location:' | sed 's/.*tag\///' | tr -d '\r\n')
# Download and extract
curl -sL "https://github.com/multica-ai/multica/releases/download/${LATEST}/multica_${OS}_${ARCH}.tar.gz" \
-o /tmp/multica.tar.gz
tar -xzf /tmp/multica.tar.gz -C /tmp multica
sudo mv /tmp/multica /usr/local/bin/multica
rm /tmp/multica.tar.gz
```
### Update
```bash
multica update
```
This auto-detects your installation method (Homebrew or manual) and upgrades accordingly.
## Quick Start
```bash
# One command: configure, authenticate, and start the daemon
multica setup
```
This configures the CLI for Multica Cloud, opens your browser for login, discovers your workspaces, and starts the agent daemon.
For self-hosted servers, use `multica setup self-host` instead. See [Self-Hosting](/docs/getting-started/self-hosting) for details.
## Verify
```bash
multica daemon status
```
Confirm:
1. Status is `running`
2. At least one agent is listed (e.g. `claude`, `codex`, `opencode`, `openclaw`, or `hermes`)
3. At least one workspace is being watched
If the agents list is empty, install at least one supported AI agent CLI:
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code) (`claude`)
- [Codex](https://github.com/openai/codex) (`codex`)
- OpenCode (`opencode`)
- OpenClaw (`openclaw`)
- Hermes (`hermes`)
Then restart the daemon:
```bash
multica daemon stop && multica daemon start
```

View File

@@ -1,4 +0,0 @@
{
"title": "CLI & Daemon",
"pages": ["installation", "reference"]
}

View File

@@ -1,317 +0,0 @@
---
title: CLI Reference
description: Complete command reference for the Multica CLI and agent daemon.
---
The `multica` CLI connects your local machine to Multica. It handles authentication, workspace management, issue tracking, and runs the agent daemon that executes AI tasks locally.
## Authentication
### Browser Login
```bash
multica login
```
Opens your browser for OAuth authentication, creates a 90-day personal access token, and auto-configures your workspaces.
### Token Login
```bash
multica login --token
```
Authenticate by pasting a personal access token directly. Useful for headless environments.
### Check Status
```bash
multica auth status
```
Shows your current server, user, and token validity.
### Logout
```bash
multica auth logout
```
Removes the stored authentication token.
## Agent Daemon
The daemon is the local agent runtime. It detects available AI CLIs on your machine, registers them with the Multica server, and executes tasks when agents are assigned work.
### Start
```bash
multica daemon start
```
By default, the daemon runs in the background and logs to `~/.multica/daemon.log`.
To run in the foreground (useful for debugging):
```bash
multica daemon start --foreground
```
### Stop
```bash
multica daemon stop
```
### Status
```bash
multica daemon status
multica daemon status --output json
```
Shows PID, uptime, detected agents, and watched workspaces.
### Logs
```bash
multica daemon logs # Last 50 lines
multica daemon logs -f # Follow (tail -f)
multica daemon logs -n 100 # Last 100 lines
```
### Supported Agents
The daemon auto-detects these AI CLIs on your PATH:
| CLI | Command | Description |
|-----|---------|-------------|
| [Claude Code](https://docs.anthropic.com/en/docs/claude-code) | `claude` | Anthropic's coding agent |
| [Codex](https://github.com/openai/codex) | `codex` | OpenAI's coding agent |
| OpenCode | `opencode` | Open-source coding agent |
| OpenClaw | `openclaw` | Open-source coding agent |
| Hermes | `hermes` | Nous Research coding agent |
You need at least one installed. The daemon registers each detected CLI as an available runtime.
### How It Works
1. On start, the daemon detects installed agent CLIs and registers a runtime for each agent in each watched workspace
2. It polls the server at a configurable interval (default: 3s) for claimed tasks
3. When a task arrives, it creates an isolated workspace directory, spawns the agent CLI, and streams results back
4. Heartbeats are sent periodically (default: 15s) so the server knows the daemon is alive
5. On shutdown, all runtimes are deregistered
### Configuration
Daemon behavior is configured via flags or environment variables:
| Setting | Flag | Env Variable | Default |
|---------|------|--------------|---------|
| Poll interval | `--poll-interval` | `MULTICA_DAEMON_POLL_INTERVAL` | `3s` |
| Heartbeat interval | `--heartbeat-interval` | `MULTICA_DAEMON_HEARTBEAT_INTERVAL` | `15s` |
| Agent timeout | `--agent-timeout` | `MULTICA_AGENT_TIMEOUT` | `2h` |
| Max concurrent tasks | `--max-concurrent-tasks` | `MULTICA_DAEMON_MAX_CONCURRENT_TASKS` | `20` |
| Daemon ID | `--daemon-id` | `MULTICA_DAEMON_ID` | hostname |
| Device name | `--device-name` | `MULTICA_DAEMON_DEVICE_NAME` | hostname |
| Runtime name | `--runtime-name` | `MULTICA_AGENT_RUNTIME_NAME` | `Local Agent` |
| Workspaces root | — | `MULTICA_WORKSPACES_ROOT` | `~/multica_workspaces` |
Agent-specific overrides:
| Variable | Description |
|----------|-------------|
| `MULTICA_CLAUDE_PATH` | Custom path to the `claude` binary |
| `MULTICA_CLAUDE_MODEL` | Override the Claude model used |
| `MULTICA_CODEX_PATH` | Custom path to the `codex` binary |
| `MULTICA_CODEX_MODEL` | Override the Codex model used |
| `MULTICA_OPENCODE_PATH` | Custom path to the `opencode` binary |
| `MULTICA_OPENCODE_MODEL` | Override the OpenCode model used |
| `MULTICA_OPENCLAW_PATH` | Custom path to the `openclaw` binary |
| `MULTICA_OPENCLAW_MODEL` | Override the OpenClaw model used |
| `MULTICA_HERMES_PATH` | Custom path to the `hermes` binary |
| `MULTICA_HERMES_MODEL` | Override the Hermes model used |
### Self-Hosted Server
When connecting to a self-hosted Multica instance, point the CLI to your server before logging in:
```bash
export MULTICA_APP_URL=https://app.example.com
export MULTICA_SERVER_URL=wss://api.example.com/ws
multica login
multica daemon start
```
Or set them persistently:
```bash
multica config set app_url https://app.example.com
multica config set server_url wss://api.example.com/ws
```
### Profiles
Profiles let you run multiple daemons on the same machine — for example, one for production and one for a staging server.
```bash
# Set up a staging profile
multica setup self-host --profile staging --server-url https://api-staging.example.com --app-url https://staging.example.com
# Start its daemon
multica daemon start --profile staging
# Default profile runs separately
multica daemon start
```
Each profile gets its own config directory (`~/.multica/profiles/<name>/`), daemon state, health port, and workspace root.
## Workspaces
### List Workspaces
```bash
multica workspace list
```
Watched workspaces are marked with `*`. The daemon only processes tasks for watched workspaces.
### Watch / Unwatch
```bash
multica workspace watch <workspace-id>
multica workspace unwatch <workspace-id>
```
### Get Details
```bash
multica workspace get <workspace-id>
multica workspace get <workspace-id> --output json
```
### List Members
```bash
multica workspace members <workspace-id>
```
## Issues
### List Issues
```bash
multica issue list
multica issue list --status in_progress
multica issue list --priority urgent --assignee "Agent Name"
multica issue list --limit 20 --output json
```
Available filters: `--status`, `--priority`, `--assignee`, `--limit`.
### Get Issue
```bash
multica issue get <id>
multica issue get <id> --output json
```
### Create Issue
```bash
multica issue create --title "Fix login bug" --description "..." --priority high --assignee "Lambda"
```
Flags: `--title` (required), `--description`, `--status`, `--priority`, `--assignee`, `--parent`, `--due-date`.
### Update Issue
```bash
multica issue update <id> --title "New title" --priority urgent
```
### Assign Issue
```bash
multica issue assign <id> --to "Lambda"
multica issue assign <id> --unassign
```
### Change Status
```bash
multica issue status <id> in_progress
```
Valid statuses: `backlog`, `todo`, `in_progress`, `in_review`, `done`, `blocked`, `cancelled`.
### Comments
```bash
# List comments
multica issue comment list <issue-id>
# Add a comment
multica issue comment add <issue-id> --content "Looks good, merging now"
# Reply to a specific comment
multica issue comment add <issue-id> --parent <comment-id> --content "Thanks!"
# Delete a comment
multica issue comment delete <comment-id>
```
### Execution History
```bash
# List all execution runs for an issue
multica issue runs <issue-id>
multica issue runs <issue-id> --output json
# View messages for a specific execution run
multica issue run-messages <task-id>
multica issue run-messages <task-id> --output json
# Incremental fetch (only messages after a given sequence number)
multica issue run-messages <task-id> --since 42 --output json
```
## Configuration
### View Config
```bash
multica config show
```
Shows config file path, server URL, app URL, and default workspace.
### Set Values
```bash
multica config set server_url wss://api.example.com/ws
multica config set app_url https://app.example.com
multica config set workspace_id <workspace-id>
```
## Other Commands
```bash
multica version # Show CLI version and commit hash
multica update # Update to latest version
multica agent list # List agents in the current workspace
```
## Output Formats
Most commands support `--output` with two formats:
- `table` — human-readable table (default for list commands)
- `json` — structured JSON (useful for scripting and automation)
```bash
multica issue list --output json
multica daemon status --output json
```

View File

@@ -1,75 +0,0 @@
---
title: Architecture
description: Technical architecture of the Multica platform.
---
## Overview
Multica is a Go backend + monorepo frontend (pnpm workspaces + Turborepo) with shared packages.
```
┌──────────────┐ ┌──────────────┐ ┌──────────────────┐
│ Next.js │────>│ Go Backend │────>│ PostgreSQL │
│ Frontend │<────│ (Chi + WS) │<────│ (pgvector) │
└──────────────┘ └──────┬───────┘ └──────────────────┘
┌──────┴───────┐
│ Agent Daemon │ (runs on your machine)
│Claude/Codex/ │
│OpenClaw/Code │
└──────────────┘
```
## Project Structure
| Directory | Purpose | Technology |
|-----------|---------|------------|
| `server/` | Go backend | Chi router, sqlc for DB, gorilla/websocket |
| `apps/web/` | Next.js frontend | App Router |
| `apps/desktop/` | Electron desktop app | electron-vite |
| `apps/docs/` | Documentation site | Fumadocs |
| `packages/core/` | Headless business logic | Zero react-dom, all-platform reuse |
| `packages/ui/` | Atomic UI components | Zero business logic, shadcn-based |
| `packages/views/` | Shared business pages | Zero next/\*, zero react-router imports |
| `packages/tsconfig/` | Shared TypeScript config | — |
| `packages/eslint-config/` | Shared ESLint config | — |
## Backend Structure
- **Entry points** (`cmd/`): `server` (HTTP API), `multica` (CLI + daemon), `migrate`
- **Handlers** (`internal/handler/`): One file per domain (issue, comment, agent, auth, daemon)
- **Real-time** (`internal/realtime/`): Hub manages WebSocket clients, server broadcasts events
- **Auth** (`internal/auth/` + `internal/middleware/`): JWT (HS256), middleware sets `X-User-ID` and `X-User-Email` headers
- **Task lifecycle** (`internal/service/task.go`): enqueue → claim → start → complete/fail
- **Agent SDK** (`pkg/agent/`): Unified `Backend` interface for executing prompts via Claude Code or Codex
- **Daemon** (`internal/daemon/`): Auto-detects CLIs, registers runtimes, polls for tasks
- **Database**: PostgreSQL 17 with pgvector, sqlc generates code from SQL in `pkg/db/queries/`
## Frontend Architecture
### Internal Packages Pattern
All shared packages export raw `.ts`/`.tsx` files (no pre-compilation). The consuming app's bundler compiles them directly. This gives zero-config HMR and instant go-to-definition.
### Package Boundaries
- `packages/core/` — zero react-dom, zero localStorage, zero UI libs. All Zustand stores live here.
- `packages/ui/` — pure UI components, zero business logic.
- `packages/views/` — zero `next/*`, zero `react-router-dom`. Uses `NavigationAdapter` for routing.
### State Management
- **TanStack Query** owns all server state (issues, users, workspaces)
- **Zustand** owns all client state (UI selections, filters, drafts)
- **React Context** reserved for cross-cutting plumbing (`WorkspaceIdProvider`, `NavigationProvider`)
### Data Flow
```
Browser → ApiClient (shared/api) → REST API (Chi handlers) → sqlc queries → PostgreSQL
Browser ← WSClient (shared/api) ← WebSocket ← Hub.Broadcast() ← Handlers/TaskService
```
## Multi-tenancy
All queries filter by `workspace_id`. Membership checks gate access. `X-Workspace-ID` header routes requests to the correct workspace.

View File

@@ -1,178 +0,0 @@
---
title: Contributing
description: Local development workflow for contributors working on the Multica codebase.
---
## Development Model
Local development uses one shared PostgreSQL container and one database per checkout.
- The main checkout usually uses `.env` and `POSTGRES_DB=multica`
- Each Git worktree uses its own `.env.worktree`
- Every checkout connects to the same PostgreSQL host: `localhost:5432`
- Isolation happens at the database level, not by starting a separate Docker Compose project
- Backend and frontend ports are still unique per worktree
## Prerequisites
- Node.js `v20+`
- `pnpm` `v10.28+`
- Go `v1.26+`
- Docker
## First-Time Setup
### Main Checkout
```bash
cp .env.example .env
make setup-main
```
What `make setup-main` does:
- Installs JavaScript dependencies with `pnpm install`
- Ensures the shared PostgreSQL container is running
- Creates the application database if it does not exist
- Runs all migrations against that database
Start the app:
```bash
make start-main
```
### Worktree
From the worktree directory:
```bash
make worktree-env
make setup-worktree
```
Start the worktree app:
```bash
make start-worktree
```
## Daily Workflow
### Main Checkout
```bash
make start-main
make stop-main
make check-main
```
### Feature Worktree
```bash
git worktree add ../multica-feature -b feat/my-change main
cd ../multica-feature
make worktree-env
make setup-worktree
make start-worktree
```
Day-to-day:
```bash
make start-worktree
make stop-worktree
make check-worktree
```
## Running Main and Worktree Simultaneously
This is a first-class workflow. Both checkouts use the same PostgreSQL container but different databases and ports:
| | Main | Worktree |
|---|---|---|
| Database | `multica` | `multica_my_feature_702` |
| Backend port | `8080` | generated (e.g. `18782`) |
| Frontend port | `3000` | generated (e.g. `13702`) |
## Commands
```bash
# Frontend (all commands go through Turborepo)
pnpm install
pnpm dev:web # Next.js dev server (port 3000)
pnpm dev:desktop # Electron dev (electron-vite, HMR)
pnpm build # Build all frontend apps
pnpm typecheck # TypeScript check
pnpm lint # ESLint
pnpm test # TS tests (Vitest)
# Backend (Go)
make dev # Run Go server (port 8080)
make daemon # Run local daemon
make build # Build server + CLI binaries
make test # Go tests
make sqlc # Regenerate sqlc code
make migrate-up # Run database migrations
make migrate-down # Rollback migrations
```
## Testing
Run all local checks:
```bash
make check
```
This runs:
1. TypeScript typecheck
2. TypeScript unit tests
3. Go tests
4. Playwright E2E tests
## Troubleshooting
### Missing Env File
Create the expected env file:
```bash
# Main checkout
cp .env.example .env
# Worktree
make worktree-env
```
### Check Which Database a Checkout Uses
```bash
cat .env # or .env.worktree
```
Look for `POSTGRES_DB`, `DATABASE_URL`, `PORT`, `FRONTEND_PORT`.
### List All Local Databases
```bash
docker compose exec -T postgres psql -U multica -d postgres \
-At -c "select datname from pg_database order by datname;"
```
### Destructive Reset
Stop PostgreSQL and keep local databases:
```bash
make db-down
```
Wipe all local PostgreSQL data:
```bash
docker compose down -v
```
> **Warning:** This deletes the shared Docker volume and all databases. After that you must run `make setup-main` or `make setup-worktree` again.

View File

@@ -1,4 +0,0 @@
{
"title": "Developers",
"pages": ["contributing", "architecture"]
}

View File

@@ -1,46 +0,0 @@
---
title: Cloud Quickstart
description: Get started with Multica Cloud — no setup required.
---
The fastest way to get started with Multica — no setup required.
## 1. Sign up
Go to [multica.ai](https://multica.ai) and create an account.
## 2. Install the CLI and start the daemon
Give this instruction to your AI agent (Claude Code, Codex, OpenClaw, OpenCode, etc.):
```
Fetch https://github.com/multica-ai/multica/blob/main/CLI_INSTALL.md and follow the instructions to install Multica CLI, log in, and start the daemon on this machine.
```
Or install manually:
```bash
# Install the CLI
curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash
# Configure, authenticate, and start the daemon
multica setup
```
The daemon auto-detects available agent CLIs (`claude`, `codex`, `openclaw`, `opencode`) on your PATH. When an agent is assigned a task, the daemon creates an isolated environment, runs the agent, and reports results back.
## 3. Verify your runtime
Open your workspace in the Multica web app. Navigate to **Settings → Runtimes** — you should see your machine listed as an active **Runtime**.
> **What is a Runtime?** A Runtime is a compute environment that can execute agent tasks. It can be your local machine (via the daemon) or a cloud instance. Each runtime reports which agent CLIs are available, so Multica knows where to route work.
## 4. Create an agent
Go to **Settings → Agents** and click **New Agent**. Pick the runtime you just connected and choose a provider (Claude Code, Codex, OpenClaw, or OpenCode). Give your agent a name — this is how it will appear on the board, in comments, and in assignments.
## 5. Assign your first task
Create an issue from the board (or via `multica issue create`), then assign it to your new agent. The agent will automatically pick up the task, execute it on your runtime, and report progress — just like a human teammate.
That's it! Your agent is now part of the team.

View File

@@ -1,4 +0,0 @@
{
"title": "Getting Started",
"pages": ["cloud-quickstart", "self-hosting"]
}

View File

@@ -1,390 +0,0 @@
---
title: Self-Hosting Guide
description: Deploy Multica on your own infrastructure.
---
## Architecture Overview
Multica has three components:
| Component | Description | Technology |
|-----------|-------------|------------|
| **Backend** | REST API + WebSocket server | Go (single binary) |
| **Frontend** | Web application | Next.js 16 |
| **Database** | Primary data store | PostgreSQL 17 with pgvector |
Each user who wants to run AI agents locally also installs the **`multica` CLI** and runs the **agent daemon** on their own machine.
## Prerequisites
- Docker and Docker Compose
## Quick Install
Two commands to set up everything:
```bash
# Install CLI + provision self-host server
curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash -s -- --with-server
# Configure CLI, authenticate, and start the daemon
multica setup self-host
```
This clones the repo, starts all services, installs the CLI, and configures it for localhost. Then open http://localhost:3000 — log in with any email + code **`888888`**.
<Callout>
For a step-by-step setup, see below.
</Callout>
## Step-by-Step Setup
### Step 1 — Start the Server
```bash
git clone https://github.com/multica-ai/multica.git
cd multica
make selfhost
```
`make selfhost` automatically creates `.env`, generates a random `JWT_SECRET`, and starts all services via Docker Compose.
Once ready:
- **Frontend:** http://localhost:3000
- **Backend API:** http://localhost:8080
<Callout>
If you prefer running the Docker Compose steps manually: `cp .env.example .env`, edit `JWT_SECRET`, then `docker compose -f docker-compose.selfhost.yml up -d`.
</Callout>
### Step 2 — Log In
Open http://localhost:3000. Enter any email address and use verification code **`888888`** to log in.
<Callout>
This master code works in all non-production environments (when `APP_ENV` is not set to `production`). For production, configure an email provider — see [Configuration](#configuration) below.
</Callout>
### Step 3 — Install CLI & Start Daemon
The daemon runs on your local machine (not inside Docker). It detects installed AI agent CLIs, registers them with the server, and executes tasks.
### a) Install the CLI and an AI agent
```bash
brew tap multica-ai/tap
brew install multica
```
You also need at least one AI agent CLI:
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code) (`claude` on PATH)
- [Codex](https://github.com/openai/codex) (`codex` on PATH)
- OpenCode (`opencode` on PATH)
- OpenClaw (`openclaw` on PATH)
- Hermes (`hermes` on PATH)
### b) One-command setup
```bash
multica setup self-host
```
This automatically:
1. Configures the CLI to connect to `localhost`
2. Opens your browser for authentication
3. Discovers your workspaces
4. Starts the daemon in the background
For on-premise deployments with custom domains:
```bash
multica setup self-host --server-url https://api.example.com --app-url https://app.example.com
```
Verify the daemon is running:
```bash
multica daemon status
```
<Callout>
Alternatively, configure step by step: `multica config set server_url http://localhost:8080 && multica config set app_url http://localhost:3000 && multica login && multica daemon start`
</Callout>
### Step 4 — Verify & Start Using
1. Open your workspace at http://localhost:3000
2. Navigate to **Settings → Runtimes** — you should see your machine listed
3. Go to **Settings → Agents** and create a new agent
4. Create an issue and assign it to your agent
## Stopping Services
```bash
# Stop Docker Compose services
make selfhost-stop
# Stop the local daemon
multica daemon stop
```
## Switching to Multica Cloud
If you've been self-hosting and want to switch your CLI to [Multica Cloud](https://multica.ai):
```bash
multica setup
```
This reconfigures the CLI for multica.ai, re-authenticates, and restarts the daemon. You will be prompted before overwriting the existing configuration.
<Callout>
Your local Docker services are unaffected. Stop them separately if you no longer need them.
</Callout>
## Rebuilding After Updates
```bash
git pull
make selfhost
```
Migrations run automatically on backend startup.
---
## Configuration
All configuration is done via environment variables. Copy `.env.example` as a starting point.
### Required Variables
| Variable | Description | Example |
|----------|-------------|---------|
| `DATABASE_URL` | PostgreSQL connection string | `postgres://multica:multica@localhost:5432/multica?sslmode=disable` |
| `JWT_SECRET` | **Must change from default.** Secret key for signing JWT tokens. Use a long random string. | `openssl rand -hex 32` |
| `FRONTEND_ORIGIN` | URL where the frontend is served (used for CORS) | `https://app.example.com` |
### Email (Required for Authentication)
Multica uses email-based magic link authentication via [Resend](https://resend.com).
| Variable | Description |
|----------|-------------|
| `RESEND_API_KEY` | Your Resend API key |
| `RESEND_FROM_EMAIL` | Sender email address (default: `noreply@multica.ai`) |
### Google OAuth (Optional)
| Variable | Description |
|----------|-------------|
| `GOOGLE_CLIENT_ID` | Google OAuth client ID |
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret |
| `GOOGLE_REDIRECT_URI` | OAuth callback URL (e.g. `https://app.example.com/auth/callback`) |
### File Storage (Optional)
For file uploads and attachments, configure S3 and CloudFront:
| Variable | Description |
|----------|-------------|
| `S3_BUCKET` | S3 bucket name |
| `S3_REGION` | AWS region (default: `us-west-2`) |
| `CLOUDFRONT_DOMAIN` | CloudFront distribution domain |
| `CLOUDFRONT_KEY_PAIR_ID` | CloudFront key pair ID for signed URLs |
| `CLOUDFRONT_PRIVATE_KEY` | CloudFront private key (PEM format) |
| `COOKIE_DOMAIN` | Domain for CloudFront auth cookies |
### Server
| Variable | Default | Description |
|----------|---------|-------------|
| `PORT` | `8080` | Backend server port |
| `FRONTEND_PORT` | `3000` | Frontend port |
| `CORS_ALLOWED_ORIGINS` | Value of `FRONTEND_ORIGIN` | Comma-separated list of allowed origins |
| `LOG_LEVEL` | `info` | Log level: `debug`, `info`, `warn`, `error` |
### CLI / Daemon
These are configured on each user's machine, not on the server:
| Variable | Default | Description |
|----------|---------|-------------|
| `MULTICA_SERVER_URL` | `ws://localhost:8080/ws` | WebSocket URL for daemon → server connection |
| `MULTICA_APP_URL` | `http://localhost:3000` | Frontend URL for CLI login flow |
| `MULTICA_DAEMON_POLL_INTERVAL` | `3s` | How often the daemon polls for tasks |
| `MULTICA_DAEMON_HEARTBEAT_INTERVAL` | `15s` | Heartbeat frequency |
Agent-specific overrides:
| Variable | Description |
|----------|-------------|
| `MULTICA_CLAUDE_PATH` | Custom path to the `claude` binary |
| `MULTICA_CLAUDE_MODEL` | Override the Claude model used |
| `MULTICA_CODEX_PATH` | Custom path to the `codex` binary |
| `MULTICA_CODEX_MODEL` | Override the Codex model used |
| `MULTICA_OPENCODE_PATH` | Custom path to the `opencode` binary |
| `MULTICA_OPENCODE_MODEL` | Override the OpenCode model used |
| `MULTICA_OPENCLAW_PATH` | Custom path to the `openclaw` binary |
| `MULTICA_OPENCLAW_MODEL` | Override the OpenClaw model used |
| `MULTICA_HERMES_PATH` | Custom path to the `hermes` binary |
| `MULTICA_HERMES_MODEL` | Override the Hermes model used |
## Database Setup
Multica requires PostgreSQL 17 with the pgvector extension.
### Using the Included Docker Compose
```bash
docker compose up -d postgres
```
This starts a `pgvector/pgvector:pg17` container on port 5432 with default credentials (`multica`/`multica`).
### Using Your Own PostgreSQL
Ensure the pgvector extension is available:
```sql
CREATE EXTENSION IF NOT EXISTS vector;
```
### Running Migrations
Migrations must be run before starting the server:
```bash
# Using the built binary
./server/bin/migrate up
# Or from source
cd server && go run ./cmd/migrate up
```
## Manual Setup (Without Docker Compose)
If you prefer to build and run services manually:
**Prerequisites:** Go 1.26+, Node.js 20+, pnpm 10.28+, PostgreSQL 17 with pgvector.
```bash
# Start your PostgreSQL (or use: docker compose up -d postgres)
# Build the backend
make build
# Run database migrations
DATABASE_URL="your-database-url" ./server/bin/migrate up
# Start the backend server
DATABASE_URL="your-database-url" PORT=8080 JWT_SECRET="your-secret" ./server/bin/server
```
For the frontend:
```bash
pnpm install
pnpm build
# Start the frontend (production mode)
cd apps/web
REMOTE_API_URL=http://localhost:8080 pnpm start
```
## Reverse Proxy
In production, put a reverse proxy in front of both the backend and frontend to handle TLS and routing.
### Caddy (Recommended)
```
app.example.com {
reverse_proxy localhost:3000
}
api.example.com {
reverse_proxy localhost:8080
}
```
### Nginx
```nginx
# Frontend
server {
listen 443 ssl;
server_name app.example.com;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
location / {
proxy_pass http://localhost:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# Backend API
server {
listen 443 ssl;
server_name api.example.com;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
location / {
proxy_pass http://localhost:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# WebSocket support
location /ws {
proxy_pass http://localhost:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 86400;
}
}
```
When using separate domains for frontend and backend, set these environment variables accordingly:
```bash
# Backend
FRONTEND_ORIGIN=https://app.example.com
CORS_ALLOWED_ORIGINS=https://app.example.com
# Frontend
REMOTE_API_URL=https://api.example.com
NEXT_PUBLIC_API_URL=https://api.example.com
NEXT_PUBLIC_WS_URL=wss://api.example.com/ws
```
## Health Check
The backend exposes a health check endpoint:
```
GET /health
→ {"status":"ok"}
```
Use this for load balancer health checks or monitoring.
## Upgrading
1. Pull the latest code or image
2. Run migrations: `./server/bin/migrate up`
3. Restart the backend and frontend
Migrations are forward-only and safe to run on a live database. They are idempotent — running them multiple times has no effect.

View File

@@ -1,50 +0,0 @@
---
title: Agents
description: How AI agents work in Multica — execution model, skills, and runtime guidelines.
---
## Agents as Teammates
In Multica, agents are first-class citizens. They have profiles, show up on the board, post comments, create issues, and report blockers proactively.
Assignees are polymorphic — an issue can be assigned to a member or an agent. The `assignee_type` + `assignee_id` fields on issues distinguish between the two. Agents render with distinct styling (purple background, robot icon).
## Agent Execution Model
When an agent is assigned a task in Multica:
1. The daemon detects the task assignment
2. It creates an isolated workspace directory
3. It spawns the appropriate agent CLI (Claude Code, Codex, OpenClaw, or OpenCode)
4. The agent executes autonomously, streaming progress back to Multica
5. Results are reported — success, failure, or blockers
The full task lifecycle is: **enqueue → claim → start → complete/fail**.
Real-time progress is streamed via WebSocket so you can follow along in the Multica UI.
## Supported Agent Providers
| Provider | CLI Command | Description |
|----------|-------------|-------------|
| Claude Code | `claude` | Anthropic's coding agent |
| Codex | `codex` | OpenAI's coding agent |
| OpenClaw | `openclaw` | Open-source coding agent |
| OpenCode | `opencode` | Open-source coding agent |
The daemon auto-detects which CLIs are available on your PATH and registers them as available runtimes.
## Reusable Skills
Every solution an agent creates can become a reusable skill for the whole team. Skills compound your team's capabilities over time:
- Deployments
- Migrations
- Code reviews
- Common patterns
Skills are shared across the workspace, so any agent (or human) can leverage them.
## Multi-Workspace Support
Each workspace has its own set of agents, issues, and settings. The daemon can watch multiple workspaces simultaneously, routing tasks to the appropriate agent based on workspace configuration.

View File

@@ -1,4 +0,0 @@
{
"title": "Guides",
"pages": ["quickstart", "agents"]
}

View File

@@ -1,30 +0,0 @@
---
title: Quickstart
description: Assign your first task to an agent in under 5 minutes.
---
Once you have the CLI installed (or signed up for [Multica Cloud](https://multica.ai)), follow these steps to assign your first task to an agent.
## 1. Set up and start the daemon
```bash
multica setup # Configure, authenticate, and start the daemon
```
This configures the CLI, opens your browser for login, discovers your workspaces, and starts the agent daemon in the background. It auto-detects agent CLIs (`claude`, `codex`, `openclaw`, `opencode`) available on your PATH.
## 2. Verify your runtime
Open your workspace in the Multica web app. Navigate to **Settings → Runtimes** — you should see your machine listed as an active **Runtime**.
> **What is a Runtime?** A Runtime is a compute environment that can execute agent tasks. It can be your local machine (via the daemon) or a cloud instance. Each runtime reports which agent CLIs are available, so Multica knows where to route work.
## 3. Create an agent
Go to **Settings → Agents** and click **New Agent**. Pick the runtime you just connected and choose a provider (Claude Code, Codex, OpenClaw, or OpenCode). Give your agent a name — this is how it will appear on the board, in comments, and in assignments.
## 4. Assign your first task
Create an issue from the board (or via `multica issue create`), then assign it to your new agent. The agent will automatically pick up the task, execute it on your runtime, and report progress — just like a human teammate.
That's it! Your agent is now part of the team.

View File

@@ -1,47 +0,0 @@
---
title: Introduction
description: Multica — the open-source managed agents platform. Turn coding agents into real teammates.
---
## What is Multica?
Multica turns coding agents into real teammates. Assign issues to an agent like you'd assign to a colleague — they'll pick up the work, write code, report blockers, and update statuses autonomously.
No more copy-pasting prompts. No more babysitting runs. Your agents show up on the board, participate in conversations, and compound reusable skills over time. Think of it as open-source infrastructure for managed agents — vendor-neutral, self-hosted, and designed for human + AI teams. Works with **Claude Code**, **Codex**, **OpenClaw**, and **OpenCode**.
## Features
- **Agents as Teammates** — assign to an agent like you'd assign to a colleague. They have profiles, show up on the board, post comments, create issues, and report blockers proactively.
- **Autonomous Execution** — set it and forget it. Full task lifecycle management (enqueue, claim, start, complete/fail) with real-time progress streaming via WebSocket.
- **Reusable Skills** — every solution becomes a reusable skill for the whole team. Deployments, migrations, code reviews — skills compound your team's capabilities over time.
- **Unified Runtimes** — one dashboard for all your compute. Local daemons and cloud runtimes, auto-detection of available CLIs, real-time monitoring.
- **Multi-Workspace** — organize work across teams with workspace-level isolation. Each workspace has its own agents, issues, and settings.
## Architecture
| Layer | Stack |
|-------|-------|
| Frontend | Next.js 16 (App Router) |
| Backend | Go (Chi router, sqlc, gorilla/websocket) |
| Database | PostgreSQL 17 with pgvector |
| Agent Runtime | Local daemon executing Claude Code, Codex, OpenClaw, or OpenCode |
```
┌──────────────┐ ┌──────────────┐ ┌──────────────────┐
│ Next.js │────>│ Go Backend │────>│ PostgreSQL │
│ Frontend │<────│ (Chi + WS) │<────│ (pgvector) │
└──────────────┘ └──────┬───────┘ └──────────────────┘
┌──────┴───────┐
│ Agent Daemon │ (runs on your machine)
│Claude/Codex/ │
│OpenClaw/Code │
└──────────────┘
```
## Next Steps
- [Cloud Quickstart](/docs/getting-started/cloud-quickstart)
- [Self-Hosting](/docs/getting-started/self-hosting)
- [CLI Installation](/docs/cli/installation)
- [Contributing](/docs/developers/contributing)

View File

@@ -1,10 +0,0 @@
{
"title": "Documentation",
"pages": [
"index",
"getting-started",
"cli",
"guides",
"developers"
]
}

View File

@@ -1,7 +0,0 @@
import { docs } from "@/.source";
import { loader } from "fumadocs-core/source";
export const source = loader({
baseUrl: "/docs",
source: docs.toFumadocsSource(),
});

View File

@@ -1,6 +0,0 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -1,10 +0,0 @@
import { createMDX } from "fumadocs-mdx/next";
const withMDX = createMDX();
/** @type {import('next').NextConfig} */
const config = {
reactStrictMode: true,
};
export default withMDX(config);

View File

@@ -1,29 +0,0 @@
{
"name": "@multica/docs",
"version": "0.2.0",
"private": true,
"type": "module",
"scripts": {
"dev": "next dev --port 4000",
"build": "fumadocs-mdx && next build",
"start": "next start",
"typecheck": "fumadocs-mdx && tsc --noEmit",
"postinstall": "fumadocs-mdx"
},
"dependencies": {
"fumadocs-core": "^15.5.2",
"fumadocs-mdx": "^12.0.3",
"fumadocs-ui": "^15.5.2",
"lucide-react": "catalog:",
"next": "^15.3.3",
"react": "catalog:",
"react-dom": "catalog:"
},
"devDependencies": {
"@tailwindcss/postcss": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"tailwindcss": "catalog:",
"typescript": "catalog:"
}
}

View File

@@ -1,7 +0,0 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

View File

@@ -1,9 +0,0 @@
import { defineDocs, defineConfig } from "fumadocs-mdx/config";
export const docs = defineDocs({
dir: "content/docs",
});
export default defineConfig({
mdxOptions: {},
});

View File

@@ -1,48 +0,0 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": [
"ESNext",
"DOM",
"DOM.Iterable"
],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"verbatimModuleSyntax": true,
"isolatedModules": true,
"declaration": false,
"declarationMap": false,
"sourceMap": true,
"noUncheckedIndexedAccess": true,
"resolveJsonModule": true,
"jsx": "preserve",
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./*"
]
},
"noEmit": true,
"allowJs": true,
"incremental": true
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
".source/**/*.ts"
],
"exclude": [
"node_modules"
]
}

View File

@@ -1,64 +1,36 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
function createWrapper() {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
);
}
const { mockSendCode, mockVerifyCode, mockHydrateWorkspace } = vi.hoisted(
() => ({
mockSendCode: vi.fn(),
mockVerifyCode: vi.fn(),
mockHydrateWorkspace: vi.fn(),
}),
);
// Mock next/navigation
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
useRouter: () => ({ push: vi.fn() }),
usePathname: () => "/login",
useSearchParams: () => new URLSearchParams(),
}));
// Mock auth store — shared LoginPage uses getState().sendCode/verifyCode,
// web wrapper uses useAuthStore((s) => s.user/isLoading)
vi.mock("@multica/core/auth", () => {
const authState = {
sendCode: mockSendCode,
verifyCode: mockVerifyCode,
user: null,
isLoading: false,
};
const useAuthStore = Object.assign(
(selector: (s: typeof authState) => unknown) => selector(authState),
{ getState: () => authState },
);
return { useAuthStore };
});
// Mock auth-cookie
vi.mock("@/features/auth/auth-cookie", () => ({
setLoggedInCookie: vi.fn(),
// Mock auth store
const mockSendCode = vi.fn();
const mockVerifyCode = vi.fn();
vi.mock("@/features/auth", () => ({
useAuthStore: (selector: (s: any) => any) =>
selector({
sendCode: mockSendCode,
verifyCode: mockVerifyCode,
}),
}));
// Mock workspace store — shared LoginPage uses getState().hydrateWorkspace
vi.mock("@multica/core/workspace", () => {
const wsState = { hydrateWorkspace: mockHydrateWorkspace };
const useWorkspaceStore = Object.assign(
(selector: (s: typeof wsState) => unknown) => selector(wsState),
{ getState: () => wsState },
);
return { useWorkspaceStore };
});
// Mock workspace store
const mockHydrateWorkspace = vi.fn();
vi.mock("@/features/workspace", () => ({
useWorkspaceStore: (selector: (s: any) => any) =>
selector({
hydrateWorkspace: mockHydrateWorkspace,
}),
}));
// Mock api
vi.mock("@multica/core/api", () => ({
vi.mock("@/shared/api", () => ({
api: {
listWorkspaces: vi.fn().mockResolvedValue([]),
verifyCode: vi.fn(),
@@ -75,10 +47,10 @@ describe("LoginPage", () => {
});
it("renders login form with email input and continue button", () => {
render(<LoginPage />, { wrapper: createWrapper() });
render(<LoginPage />);
expect(screen.getByText("Sign in to Multica")).toBeInTheDocument();
expect(screen.getByText("Enter your email to get a login code")).toBeInTheDocument();
expect(screen.getByText("Multica")).toBeInTheDocument();
expect(screen.getByText("Turn coding agents into real teammates")).toBeInTheDocument();
expect(screen.getByLabelText("Email")).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Continue" })
@@ -87,7 +59,7 @@ describe("LoginPage", () => {
it("does not call sendCode when email is empty", async () => {
const user = userEvent.setup();
render(<LoginPage />, { wrapper: createWrapper() });
render(<LoginPage />);
await user.click(screen.getByRole("button", { name: "Continue" }));
expect(mockSendCode).not.toHaveBeenCalled();
@@ -96,7 +68,7 @@ describe("LoginPage", () => {
it("calls sendCode with email on submit", async () => {
mockSendCode.mockResolvedValueOnce(undefined);
const user = userEvent.setup();
render(<LoginPage />, { wrapper: createWrapper() });
render(<LoginPage />);
await user.type(screen.getByLabelText("Email"), "test@multica.ai");
await user.click(screen.getByRole("button", { name: "Continue" }));
@@ -109,7 +81,7 @@ describe("LoginPage", () => {
it("shows 'Sending code...' while submitting", async () => {
mockSendCode.mockReturnValueOnce(new Promise(() => {}));
const user = userEvent.setup();
render(<LoginPage />, { wrapper: createWrapper() });
render(<LoginPage />);
await user.type(screen.getByLabelText("Email"), "test@multica.ai");
await user.click(screen.getByRole("button", { name: "Continue" }));
@@ -122,7 +94,7 @@ describe("LoginPage", () => {
it("shows verification code step after sending code", async () => {
mockSendCode.mockResolvedValueOnce(undefined);
const user = userEvent.setup();
render(<LoginPage />, { wrapper: createWrapper() });
render(<LoginPage />);
await user.type(screen.getByLabelText("Email"), "test@multica.ai");
await user.click(screen.getByRole("button", { name: "Continue" }));
@@ -135,7 +107,7 @@ describe("LoginPage", () => {
it("shows error when sendCode fails", async () => {
mockSendCode.mockRejectedValueOnce(new Error("Network error"));
const user = userEvent.setup();
render(<LoginPage />, { wrapper: createWrapper() });
render(<LoginPage />);
await user.type(screen.getByLabelText("Email"), "test@multica.ai");
await user.click(screen.getByRole("button", { name: "Continue" }));

View File

@@ -1,66 +1,328 @@
"use client";
import { Suspense, useEffect } from "react";
import { Suspense, useState, useEffect, useCallback } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import { useAuthStore } from "@multica/core/auth";
import { useWorkspaceStore } from "@multica/core/workspace";
import { setLoggedInCookie } from "@/features/auth/auth-cookie";
import { LoginPage, validateCliCallback } from "@multica/views/auth";
import { useAuthStore } from "@/features/auth";
import { useWorkspaceStore } from "@/features/workspace";
import { api } from "@/shared/api";
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
CardFooter,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import {
InputOTP,
InputOTPGroup,
InputOTPSlot,
} from "@/components/ui/input-otp";
import type { User } from "@/shared/types";
const googleClientId = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID;
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();
const user = useAuthStore((s) => s.user);
const isLoading = useAuthStore((s) => s.isLoading);
const sendCode = useAuthStore((s) => s.sendCode);
const verifyCode = useAuthStore((s) => s.verifyCode);
const hydrateWorkspace = useWorkspaceStore((s) => s.hydrateWorkspace);
const searchParams = useSearchParams();
const cliCallbackRaw = searchParams.get("cli_callback");
const cliState = searchParams.get("cli_state") || "";
const platform = searchParams.get("platform");
const nextUrl = searchParams.get("next") || "/issues";
// Already authenticated — redirect to dashboard (skip if CLI callback)
// Already authenticated — redirect to dashboard
useEffect(() => {
if (!isLoading && user && !cliCallbackRaw) {
router.replace(nextUrl);
if (!isLoading && user && !searchParams.get("cli_callback")) {
router.replace(searchParams.get("next") || "/issues");
}
}, [isLoading, user, router, nextUrl, cliCallbackRaw]);
}, [isLoading, user, router, searchParams]);
const lastWorkspaceId =
typeof window !== "undefined"
? localStorage.getItem("multica_workspace_id")
: null;
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<User | null>(null);
const handleSuccess = () => {
const ws = useWorkspaceStore.getState().workspace;
router.push(ws ? nextUrl : "/onboarding");
// 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;
const timer = setTimeout(() => setCooldown((c) => c - 1), 1000);
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) {
setError("Email is required");
return;
}
setError("");
setSubmitting(true);
try {
await sendCode(email);
setStep("code");
setCode("");
setCooldown(10);
} catch (err) {
setError(
err instanceof Error
? err.message
: "Failed to send code. Make sure the server is running."
);
} finally {
setSubmitting(false);
}
};
const handleVerifyCode = useCallback(
async (value: string) => {
if (value.length !== 6) return;
setError("");
setSubmitting(true);
try {
const cliCallback = searchParams.get("cli_callback");
if (cliCallback) {
if (!validateCliCallback(cliCallback)) {
setError("Invalid callback URL");
setSubmitting(false);
return;
}
const { token } = await api.verifyCode(email, value);
const cliState = searchParams.get("cli_state") || "";
redirectToCliCallback(cliCallback, token, cliState);
return;
}
await verifyCode(email, value);
const wsList = await api.listWorkspaces();
await hydrateWorkspace(wsList);
router.push(searchParams.get("next") || "/issues");
} catch (err) {
setError(
err instanceof Error ? err.message : "Invalid or expired code"
);
setCode("");
setSubmitting(false);
}
},
[email, verifyCode, hydrateWorkspace, router, searchParams]
);
const handleResend = async () => {
if (cooldown > 0) return;
setError("");
try {
await sendCode(email);
setCooldown(10);
} catch (err) {
setError(
err instanceof Error ? err.message : "Failed to resend code"
);
}
};
// CLI confirm step: user is already logged in, just authorize.
if (step === "cli_confirm" && existingUser) {
return (
<div className="flex min-h-screen items-center justify-center">
<Card className="w-full max-w-sm">
<CardHeader className="text-center">
<CardTitle className="text-2xl">Authorize CLI</CardTitle>
<CardDescription>
Allow the CLI to access Multica as{" "}
<span className="font-medium text-foreground">
{existingUser.email}
</span>
?
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<Button
onClick={handleCliAuthorize}
disabled={submitting}
className="w-full"
size="lg"
>
{submitting ? "Authorizing..." : "Authorize"}
</Button>
<Button
variant="ghost"
className="w-full"
onClick={() => {
setExistingUser(null);
setStep("email");
}}
>
Use a different account
</Button>
</CardContent>
</Card>
</div>
);
}
if (step === "code") {
return (
<div className="flex min-h-screen items-center justify-center">
<Card className="w-full max-w-sm">
<CardHeader className="text-center">
<CardTitle className="text-2xl">Check your email</CardTitle>
<CardDescription>
We sent a verification code to{" "}
<span className="font-medium text-foreground">{email}</span>
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col items-center gap-4">
<InputOTP
maxLength={6}
value={code}
onChange={(value) => {
setCode(value);
if (value.length === 6) handleVerifyCode(value);
}}
disabled={submitting}
>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<button
type="button"
onClick={handleResend}
disabled={cooldown > 0}
className="text-primary underline-offset-4 hover:underline disabled:text-muted-foreground disabled:no-underline disabled:cursor-not-allowed"
>
{cooldown > 0 ? `Resend in ${cooldown}s` : "Resend code"}
</button>
</div>
</CardContent>
<CardFooter>
<Button
variant="ghost"
className="w-full"
onClick={() => {
setStep("email");
setCode("");
setError("");
}}
>
Back
</Button>
</CardFooter>
</Card>
</div>
);
}
return (
<LoginPage
onSuccess={handleSuccess}
google={
googleClientId
? {
clientId: googleClientId,
redirectUri: `${window.location.origin}/auth/callback`,
state: platform === "desktop" ? "platform:desktop" : undefined,
}
: undefined
}
cliCallback={
cliCallbackRaw && validateCliCallback(cliCallbackRaw)
? { url: cliCallbackRaw, state: cliState }
: undefined
}
lastWorkspaceId={lastWorkspaceId}
onTokenObtained={setLoggedInCookie}
/>
<div className="flex min-h-screen items-center justify-center">
<Card className="w-full max-w-sm">
<CardHeader className="text-center">
<CardTitle className="text-2xl">Multica</CardTitle>
<CardDescription>Turn coding agents into real teammates</CardDescription>
</CardHeader>
<CardContent>
<form id="login-form" onSubmit={handleSendCode} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
</form>
</CardContent>
<CardFooter>
<Button
type="submit"
form="login-form"
disabled={submitting}
className="w-full"
size="lg"
>
{submitting ? "Sending code..." : "Continue"}
</Button>
</CardFooter>
</Card>
</div>
);
}
export default function Page() {
export default function LoginPage() {
return (
<Suspense fallback={null}>
<LoginPageContent />

View File

@@ -1,23 +0,0 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { useAuthStore } from "@multica/core/auth";
import { OnboardingWizard } from "@multica/views/onboarding";
export default function OnboardingPage() {
const router = useRouter();
const user = useAuthStore((s) => s.user);
const isLoading = useAuthStore((s) => s.isLoading);
// Redirect to login if not authenticated
useEffect(() => {
if (!isLoading && !user) router.replace("/login");
}, [isLoading, user, router]);
if (isLoading || !user) return null;
return (
<OnboardingWizard onComplete={() => router.push("/issues")} />
);
}

View File

@@ -0,0 +1,226 @@
"use client";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import {
Inbox,
ListTodo,
Bot,
Monitor,
ChevronDown,
Settings,
LogOut,
Plus,
Check,
BookOpenText,
SquarePen,
CircleUser,
} from "lucide-react";
import { WorkspaceAvatar } from "@/features/workspace";
import { useIssueDraftStore } from "@/features/issues/stores/draft-store";
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupContent,
SidebarHeader,
SidebarFooter,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarRail,
} from "@/components/ui/sidebar";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import { useAuthStore } from "@/features/auth";
import { useWorkspaceStore } from "@/features/workspace";
import { useInboxStore } from "@/features/inbox";
import { useModalStore } from "@/features/modals";
const primaryNav = [
{ href: "/inbox", label: "Inbox", icon: Inbox },
{ href: "/my-issues", label: "My Issues", icon: CircleUser },
{ href: "/issues", label: "Issues", icon: ListTodo },
];
const workspaceNav = [
{ href: "/agents", label: "Agents", icon: Bot },
{ href: "/runtimes", label: "Runtimes", icon: Monitor },
{ href: "/skills", label: "Skills", icon: BookOpenText },
{ href: "/settings", label: "Settings", icon: Settings },
];
function DraftDot() {
const hasDraft = useIssueDraftStore((s) => !!(s.draft.title || s.draft.description));
if (!hasDraft) return null;
return <span className="absolute top-0 right-0 size-1.5 rounded-full bg-brand" />;
}
export function AppSidebar() {
const pathname = usePathname();
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 unreadCount = useInboxStore((s) => s.unreadCount());
const logout = () => {
router.push("/");
authLogout();
useWorkspaceStore.getState().clearWorkspace();
};
return (
<Sidebar variant="inset">
{/* Workspace Switcher */}
<SidebarHeader className="py-3">
<div className="flex items-center gap-4">
<SidebarMenu className="min-w-0 flex-1">
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger
render={
<SidebarMenuButton>
<WorkspaceAvatar name={workspace?.name ?? "M"} size="sm" />
<span className="flex-1 truncate font-medium">
{workspace?.name ?? "Multica"}
</span>
<ChevronDown className="size-3 text-muted-foreground" />
</SidebarMenuButton>
}
/>
<DropdownMenuContent
className="w-52"
align="start"
side="bottom"
sideOffset={4}
>
<DropdownMenuGroup>
<DropdownMenuLabel className="text-xs text-muted-foreground">
{user?.email}
</DropdownMenuLabel>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup className="group/ws-section">
<DropdownMenuLabel className="flex items-center text-xs text-muted-foreground">
Workspaces
<Tooltip>
<TooltipTrigger
className="ml-auto opacity-0 group-hover/ws-section:opacity-100 transition-opacity rounded hover:bg-accent p-0.5"
onClick={() => useModalStore.getState().open("create-workspace")}
>
<Plus className="h-3.5 w-3.5" />
</TooltipTrigger>
<TooltipContent side="right">
Create workspace
</TooltipContent>
</Tooltip>
</DropdownMenuLabel>
{workspaces.map((ws) => (
<DropdownMenuItem
key={ws.id}
onClick={() => {
if (ws.id !== workspace?.id) {
switchWorkspace(ws.id);
}
}}
>
<WorkspaceAvatar name={ws.name} size="sm" />
<span className="flex-1 truncate">{ws.name}</span>
{ws.id === workspace?.id && (
<Check className="h-3.5 w-3.5 text-primary" />
)}
</DropdownMenuItem>
))}
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem variant="destructive" onClick={logout}>
<LogOut className="h-3.5 w-3.5" />
Log out
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
<Tooltip>
<TooltipTrigger
className="relative flex h-7 w-7 items-center justify-center rounded-lg bg-background text-foreground shadow-sm hover:bg-accent"
onClick={() => useModalStore.getState().open("create-issue")}
>
<SquarePen className="size-3.5" />
<DraftDot />
</TooltipTrigger>
<TooltipContent side="bottom">New issue</TooltipContent>
</Tooltip>
</div>
</SidebarHeader>
{/* Navigation */}
<SidebarContent>
<SidebarGroup>
<SidebarGroupContent>
<SidebarMenu className="gap-0.5">
{primaryNav.map((item) => {
const isActive = pathname === item.href;
return (
<SidebarMenuItem key={item.href}>
<SidebarMenuButton
isActive={isActive}
render={<Link href={item.href} />}
className="text-muted-foreground hover:not-data-active:bg-sidebar-accent/70 data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground"
>
<item.icon />
<span>{item.label}</span>
{item.label === "Inbox" && unreadCount > 0 && (
<span className="ml-auto text-xs">
{unreadCount > 99 ? "99+" : unreadCount}
</span>
)}
</SidebarMenuButton>
</SidebarMenuItem>
);
})}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<SidebarGroup>
<SidebarGroupContent>
<SidebarMenu className="gap-0.5">
{workspaceNav.map((item) => {
const isActive = pathname === item.href;
return (
<SidebarMenuItem key={item.href}>
<SidebarMenuButton
isActive={isActive}
render={<Link href={item.href} />}
className="text-muted-foreground hover:not-data-active:bg-sidebar-accent/70 data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground"
>
<item.icon />
<span>{item.label}</span>
</SidebarMenuButton>
</SidebarMenuItem>
);
})}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarFooter />
<SidebarRail />
</Sidebar>
);
}

View File

@@ -0,0 +1,12 @@
export default function AgentDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
return (
<div className="p-6">
<h1 className="text-2xl font-bold">Agent Detail</h1>
<p className="mt-2 text-muted-foreground">Agent status and task history</p>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,10 @@
export default function BoardPage() {
return (
<div className="p-6">
<h1 className="text-2xl font-bold">Board</h1>
<p className="mt-2 text-muted-foreground">
Kanban board view coming soon.
</p>
</div>
);
}

View File

@@ -1 +1,471 @@
export { InboxPage as default } from "@multica/views/inbox";
"use client";
import { useState, useEffect, useCallback } from "react";
import { useSearchParams } from "next/navigation";
import { useDefaultLayout } from "react-resizable-panels";
import { useInboxStore } from "@/features/inbox";
import { IssueDetail, StatusIcon, PriorityIcon } from "@/features/issues/components";
import { STATUS_CONFIG, PRIORITY_CONFIG } from "@/features/issues/config";
import { useActorName } from "@/features/workspace";
import { ActorAvatar } from "@/components/common/actor-avatar";
import { toast } from "sonner";
import {
ArrowRight,
MoreHorizontal,
Inbox,
CheckCheck,
Archive,
BookCheck,
ListChecks,
} from "lucide-react";
import type { InboxItem, InboxItemType, IssueStatus, IssuePriority } 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,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
import { api } from "@/shared/api";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const typeLabels: Record<InboxItemType, string> = {
issue_assigned: "Assigned",
unassigned: "Unassigned",
assignee_changed: "Assignee changed",
status_changed: "Status changed",
priority_changed: "Priority changed",
due_date_changed: "Due date changed",
new_comment: "New comment",
mentioned: "Mentioned",
review_requested: "Review requested",
task_completed: "Task completed",
task_failed: "Task failed",
agent_blocked: "Agent blocked",
agent_completed: "Agent completed",
reaction_added: "Reacted",
};
function timeAgo(dateStr: string): string {
const diff = Date.now() - new Date(dateStr).getTime();
const minutes = Math.floor(diff / 60000);
if (minutes < 1) return "just now";
if (minutes < 60) return `${minutes}m`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h`;
const days = Math.floor(hours / 24);
return `${days}d`;
}
function shortDate(dateStr: string): string {
if (!dateStr) return "";
return new Date(dateStr).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
});
}
// ---------------------------------------------------------------------------
// InboxDetailLabel — renders rich subtitle per notification type
// ---------------------------------------------------------------------------
function InboxDetailLabel({ item }: { item: InboxItem }) {
const { getActorName } = useActorName();
const details = item.details ?? {};
switch (item.type) {
case "status_changed": {
if (!details.to) return <span>{typeLabels[item.type]}</span>;
const label = STATUS_CONFIG[details.to as IssueStatus]?.label ?? details.to;
return (
<span className="inline-flex items-center gap-1">
Set status to
<StatusIcon status={details.to as IssueStatus} className="h-3 w-3" />
{label}
</span>
);
}
case "priority_changed": {
if (!details.to) return <span>{typeLabels[item.type]}</span>;
const label = PRIORITY_CONFIG[details.to as IssuePriority]?.label ?? details.to;
return (
<span className="inline-flex items-center gap-1">
Set priority to
<PriorityIcon priority={details.to as IssuePriority} className="h-3 w-3" />
{label}
</span>
);
}
case "issue_assigned": {
if (details.new_assignee_id) {
return <span>Assigned to {getActorName(details.new_assignee_type ?? "member", details.new_assignee_id)}</span>;
}
return <span>{typeLabels[item.type]}</span>;
}
case "unassigned":
return <span>Removed assignee</span>;
case "assignee_changed": {
if (details.new_assignee_id) {
return <span>Assigned to {getActorName(details.new_assignee_type ?? "member", details.new_assignee_id)}</span>;
}
return <span>{typeLabels[item.type]}</span>;
}
case "due_date_changed": {
if (details.to) return <span>Set due date to {shortDate(details.to)}</span>;
return <span>Removed due date</span>;
}
case "new_comment": {
if (item.body) return <span>{item.body}</span>;
return <span>{typeLabels[item.type]}</span>;
}
case "reaction_added": {
const emoji = details.emoji;
if (emoji) return <span>Reacted {emoji} to your comment</span>;
return <span>{typeLabels[item.type]}</span>;
}
default:
return <span>{typeLabels[item.type] ?? item.type}</span>;
}
}
// ---------------------------------------------------------------------------
// InboxListItem
// ---------------------------------------------------------------------------
function InboxListItem({
item,
isSelected,
onClick,
onArchive,
}: {
item: InboxItem;
isSelected: boolean;
onClick: () => void;
onArchive: () => void;
}) {
return (
<button
onClick={onClick}
className={`group flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors ${
isSelected ? "bg-accent" : "hover:bg-accent/50"
}`}
>
<ActorAvatar
actorType={item.actor_type ?? item.recipient_type}
actorId={item.actor_id ?? item.recipient_id}
size={28}
/>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-1.5">
{!item.read && (
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-brand" />
)}
<span
className={`truncate text-sm ${!item.read ? "font-medium" : "text-muted-foreground"}`}
>
{item.title}
</span>
</div>
<div className="flex shrink-0 items-center gap-1">
<span
role="button"
tabIndex={-1}
title="Archive"
onClick={(e) => {
e.stopPropagation();
onArchive();
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.stopPropagation();
onArchive();
}
}}
className="hidden rounded p-0.5 text-muted-foreground hover:bg-accent hover:text-foreground group-hover:inline-flex"
>
<Archive className="h-3.5 w-3.5" />
</span>
{item.issue_status && (
<StatusIcon status={item.issue_status} className="h-3.5 w-3.5 shrink-0" />
)}
</div>
</div>
<div className="mt-0.5 flex items-center justify-between gap-2">
<p className={`min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-xs ${item.read ? "text-muted-foreground/60" : "text-muted-foreground"}`}>
<InboxDetailLabel item={item} />
</p>
<span className={`shrink-0 text-xs ${item.read ? "text-muted-foreground/60" : "text-muted-foreground"}`}>
{timeAgo(item.created_at)}
</span>
</div>
</div>
</button>
);
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default function InboxPage() {
const searchParams = useSearchParams();
const urlIssue = searchParams.get("issue") ?? "";
const [selectedKey, setSelectedKeyState] = useState(() => urlIssue);
// Sync from URL when searchParams change (e.g. Next.js navigation)
useEffect(() => {
setSelectedKeyState(urlIssue);
}, [urlIssue]);
const setSelectedKey = useCallback((key: string) => {
setSelectedKeyState(key);
const url = key ? `/inbox?issue=${key}` : "/inbox";
window.history.replaceState(null, "", url);
}, []);
const items = useInboxStore((s) => s.dedupedItems());
const loading = useInboxStore((s) => s.loading);
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
id: "multica_inbox_layout",
});
const selected = items.find((i) => (i.issue_id ?? i.id) === selectedKey) ?? null;
const unreadCount = items.filter((i) => !i.read).length;
// Click-to-read: select + auto-mark-read
const handleSelect = async (item: InboxItem) => {
setSelectedKey(item.issue_id ?? item.id);
if (!item.read) {
useInboxStore.getState().markRead(item.id);
try {
await api.markInboxRead(item.id);
} catch {
// Rollback: refetch to get server truth
useInboxStore.getState().fetch();
toast.error("Failed to mark as read");
}
}
};
const handleArchive = async (id: string) => {
try {
await api.archiveInbox(id);
useInboxStore.getState().archive(id);
const archived = items.find((i) => i.id === id);
if (archived && (archived.issue_id ?? archived.id) === selectedKey) setSelectedKey("");
} catch {
toast.error("Failed to archive");
}
};
// Batch operations
const handleMarkAllRead = async () => {
try {
useInboxStore.getState().markAllRead();
await api.markAllInboxRead();
} catch {
toast.error("Failed to mark all as read");
useInboxStore.getState().fetch();
}
};
const handleArchiveAll = async () => {
try {
useInboxStore.getState().archiveAll();
setSelectedKey("");
await api.archiveAllInbox();
} catch {
toast.error("Failed to archive all");
useInboxStore.getState().fetch();
}
};
const handleArchiveAllRead = async () => {
try {
const readKeys = items.filter((i) => i.read).map((i) => i.issue_id ?? i.id);
useInboxStore.getState().archiveAllRead();
if (readKeys.includes(selectedKey)) setSelectedKey("");
await api.archiveAllReadInbox();
} catch {
toast.error("Failed to archive read items");
useInboxStore.getState().fetch();
}
};
const handleArchiveCompleted = async () => {
try {
await api.archiveCompletedInbox();
setSelectedKey("");
await useInboxStore.getState().fetch();
} catch {
toast.error("Failed to archive completed");
}
};
if (loading) {
return (
<ResizablePanelGroup orientation="horizontal" className="flex-1 min-h-0" defaultLayout={defaultLayout} onLayoutChanged={onLayoutChanged}>
<ResizablePanel id="list" defaultSize={320} minSize={240} maxSize={480} groupResizeBehavior="preserve-pixel-size">
<div className="flex flex-col border-r h-full">
<div className="flex h-12 shrink-0 items-center border-b px-4">
<Skeleton className="h-5 w-16" />
</div>
<div className="flex-1 min-h-0 overflow-y-auto space-y-1 p-2">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center gap-3 px-4 py-2.5">
<Skeleton className="h-7 w-7 shrink-0 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-1/2" />
</div>
</div>
))}
</div>
</div>
</ResizablePanel>
<ResizableHandle />
<ResizablePanel id="detail" minSize="40%">
<div className="p-6">
<Skeleton className="h-6 w-48" />
<Skeleton className="mt-4 h-4 w-32" />
</div>
</ResizablePanel>
</ResizablePanelGroup>
);
}
return (
<ResizablePanelGroup orientation="horizontal" className="flex-1 min-h-0" defaultLayout={defaultLayout} onLayoutChanged={onLayoutChanged}>
<ResizablePanel id="list" defaultSize={320} minSize={240} maxSize={480} groupResizeBehavior="preserve-pixel-size">
{/* Left column — inbox list */}
<div className="flex flex-col border-r h-full">
<div className="flex h-12 shrink-0 items-center justify-between border-b px-4">
<div className="flex items-center gap-2">
<h1 className="text-sm font-semibold">Inbox</h1>
{unreadCount > 0 && (
<span className="text-xs text-muted-foreground">
{unreadCount}
</span>
)}
</div>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="ghost"
size="icon-xs"
className="text-muted-foreground"
/>
}
>
<MoreHorizontal className="h-4 w-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-auto">
<DropdownMenuItem onClick={handleMarkAllRead}>
<CheckCheck className="h-4 w-4" />
Mark all as read
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleArchiveAll}>
<Archive className="h-4 w-4" />
Archive all
</DropdownMenuItem>
<DropdownMenuItem onClick={handleArchiveAllRead}>
<BookCheck className="h-4 w-4" />
Archive all read
</DropdownMenuItem>
<DropdownMenuItem onClick={handleArchiveCompleted}>
<ListChecks className="h-4 w-4" />
Archive completed
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="flex-1 min-h-0 overflow-y-auto">
{items.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
<Inbox className="mb-3 h-8 w-8 text-muted-foreground/50" />
<p className="text-sm">No notifications</p>
</div>
) : (
<div>
{items.map((item) => (
<InboxListItem
key={item.id}
item={item}
isSelected={(item.issue_id ?? item.id) === selectedKey}
onClick={() => handleSelect(item)}
onArchive={() => handleArchive(item.id)}
/>
))}
</div>
)}
</div>
</div>
</ResizablePanel>
<ResizableHandle />
<ResizablePanel id="detail" minSize="40%">
{/* Right column — detail */}
<div className="flex flex-col min-h-0 h-full">
{selected?.issue_id ? (
<IssueDetail
key={selected.id}
issueId={selected.issue_id}
defaultSidebarOpen={false}
layoutId="multica_inbox_issue_detail_layout"
highlightCommentId={selected.details?.comment_id ?? undefined}
onDelete={() => {
handleArchive(selected.id);
}}
/>
) : selected ? (
<div className="p-6">
<h2 className="text-lg font-semibold">{selected.title}</h2>
<p className="mt-1 text-sm text-muted-foreground">
{typeLabels[selected.type]} · {timeAgo(selected.created_at)}
</p>
{selected.body && (
<div className="mt-4 whitespace-pre-wrap text-sm leading-relaxed text-foreground/80">
{selected.body}
</div>
)}
<div className="mt-4">
<Button
variant="outline"
size="sm"
onClick={() => handleArchive(selected.id)}
>
<Archive className="mr-1.5 h-3.5 w-3.5" />
Archive
</Button>
</div>
</div>
) : (
<div className="flex h-full flex-col items-center justify-center text-muted-foreground">
<Inbox className="mb-3 h-10 w-10 text-muted-foreground/30" />
<p className="text-sm">
{items.length === 0
? "Your inbox is empty"
: "Select a notification to view details"}
</p>
</div>
)}
</div>
</ResizablePanel>
</ResizablePanelGroup>
);
}

View File

@@ -0,0 +1,383 @@
import { Suspense, forwardRef, useRef, useState, useImperativeHandle } from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, act, fireEvent } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { Issue, Comment, TimelineEntry } from "@/shared/types";
// Mock next/navigation
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn() }),
usePathname: () => "/issues/issue-1",
}));
// Mock next/link
vi.mock("next/link", () => ({
default: ({
children,
href,
...props
}: {
children: React.ReactNode;
href: string;
[key: string]: any;
}) => (
<a href={href} {...props}>
{children}
</a>
),
}));
// 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";
return "Unknown";
},
getActorInitials: (type: string, id: string) => {
if (type === "member") return "TU";
if (type === "agent") return "CA";
return "??";
},
getActorAvatarUrl: () => null,
}),
}));
// Mock issue store — supply a stable full issue object so storeIssue
// doesn't create a new reference each render (avoids infinite effect loop)
// and has all required fields for rendering.
const stableStoreIssues = vi.hoisted(() => [
{
id: "issue-1",
workspace_id: "ws-1",
number: 1,
identifier: "TES-1",
title: "Implement authentication",
description: "Add JWT auth to the backend",
status: "in_progress",
priority: "high",
assignee_type: "member",
assignee_id: "user-1",
creator_type: "member",
creator_id: "user-1",
parent_issue_id: null,
position: 0,
due_date: "2026-06-01T00:00:00Z",
created_at: "2026-01-15T00:00:00Z",
updated_at: "2026-01-20T00:00:00Z",
},
]);
vi.mock("@/features/issues", () => ({
useIssueStore: Object.assign(
(selector: (s: any) => any) => selector({ issues: stableStoreIssues }),
{ getState: () => ({ issues: stableStoreIssues, addIssue: vi.fn(), updateIssue: vi.fn(), removeIssue: vi.fn() }) },
),
}));
// Mock ws-context
vi.mock("@/features/realtime", () => ({
useWSEvent: () => {},
useWSReconnect: () => {},
}));
// Mock calendar (react-day-picker needs browser APIs)
vi.mock("@/components/ui/calendar", () => ({
Calendar: () => null,
}));
// Mock ContentEditor (Tiptap needs real DOM)
vi.mock("@/features/editor", () => ({
ContentEditor: 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 (
<textarea
value={value}
onChange={(e) => {
valueRef.current = e.target.value;
setValue(e.target.value);
onUpdate?.(e.target.value);
}}
onKeyDown={(e) => {
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
onSubmit?.();
}
}}
placeholder={placeholder}
data-testid="rich-text-editor"
/>
);
}),
TitleEditor: forwardRef(({ defaultValue, placeholder, onBlur, onChange }: any, ref: any) => {
const valueRef = useRef(defaultValue || "");
const [value, setValue] = useState(defaultValue || "");
useImperativeHandle(ref, () => ({
getText: () => valueRef.current,
focus: () => {},
}));
return (
<input
value={value}
onChange={(e) => {
valueRef.current = e.target.value;
setValue(e.target.value);
onChange?.(e.target.value);
}}
onBlur={() => onBlur?.(valueRef.current)}
placeholder={placeholder}
data-testid="title-editor"
/>
);
}),
}));
// Mock Markdown renderer
vi.mock("@/components/markdown", () => ({
Markdown: ({ children }: { children: string }) => <div>{children}</div>,
}));
// Mock api
const mockGetIssue = vi.hoisted(() => vi.fn());
const mockListTimeline = vi.hoisted(() => vi.fn());
const mockCreateComment = vi.hoisted(() => vi.fn());
const mockUpdateComment = vi.hoisted(() => vi.fn());
const mockDeleteComment = vi.hoisted(() => vi.fn());
const mockDeleteIssue = vi.hoisted(() => vi.fn());
const mockUpdateIssue = vi.hoisted(() => vi.fn());
vi.mock("@/shared/api", () => ({
api: {
getIssue: (...args: any[]) => mockGetIssue(...args),
listTimeline: (...args: any[]) => mockListTimeline(...args),
listComments: vi.fn().mockResolvedValue([]),
createComment: (...args: any[]) => mockCreateComment(...args),
updateComment: (...args: any[]) => mockUpdateComment(...args),
deleteComment: (...args: any[]) => mockDeleteComment(...args),
deleteIssue: (...args: any[]) => mockDeleteIssue(...args),
updateIssue: (...args: any[]) => mockUpdateIssue(...args),
listIssueSubscribers: vi.fn().mockResolvedValue([]),
subscribeToIssue: vi.fn().mockResolvedValue(undefined),
unsubscribeFromIssue: vi.fn().mockResolvedValue(undefined),
getActiveTaskForIssue: vi.fn().mockResolvedValue({ task: null }),
listTasksByIssue: vi.fn().mockResolvedValue([]),
listTaskMessages: vi.fn().mockResolvedValue([]),
},
}));
const mockIssue: Issue = {
id: "issue-1",
workspace_id: "ws-1",
number: 1,
identifier: "TES-1",
title: "Implement authentication",
description: "Add JWT auth to the backend",
status: "in_progress",
priority: "high",
assignee_type: "member",
assignee_id: "user-1",
creator_type: "member",
creator_id: "user-1",
parent_issue_id: null,
position: 0,
due_date: "2026-06-01T00:00:00Z",
created_at: "2026-01-15T00:00:00Z",
updated_at: "2026-01-20T00:00:00Z",
};
const mockTimeline: TimelineEntry[] = [
{
type: "comment",
id: "comment-1",
actor_type: "member",
actor_id: "user-1",
content: "Started working on this",
parent_id: null,
created_at: "2026-01-16T00:00:00Z",
updated_at: "2026-01-16T00:00:00Z",
comment_type: "comment",
},
{
type: "comment",
id: "comment-2",
actor_type: "agent",
actor_id: "agent-1",
content: "I can help with this",
parent_id: null,
created_at: "2026-01-17T00:00:00Z",
updated_at: "2026-01-17T00:00:00Z",
comment_type: "comment",
},
];
import IssueDetailPage from "./page";
// React 19 use(Promise) needs the promise to resolve within act + Suspense
async function renderPage(id = "issue-1") {
let result: ReturnType<typeof render>;
await act(async () => {
result = render(
<Suspense fallback={<div>Suspense loading...</div>}>
<IssueDetailPage params={Promise.resolve({ id })} />
</Suspense>,
);
});
return result!;
}
describe("IssueDetailPage", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders issue details after loading", async () => {
mockGetIssue.mockResolvedValueOnce(mockIssue);
mockListTimeline.mockResolvedValueOnce(mockTimeline);
await renderPage();
await waitFor(() => {
expect(
screen.getAllByText("Implement authentication").length,
).toBeGreaterThanOrEqual(1);
});
expect(
screen.getByText("Add JWT auth to the backend"),
).toBeInTheDocument();
});
it("renders issue properties sidebar", async () => {
mockGetIssue.mockResolvedValueOnce(mockIssue);
mockListTimeline.mockResolvedValueOnce(mockTimeline);
await renderPage();
await waitFor(() => {
expect(screen.getByText("Properties")).toBeInTheDocument();
});
expect(screen.getByText("In Progress")).toBeInTheDocument();
expect(screen.getByText("High")).toBeInTheDocument();
});
it("renders comments", async () => {
mockGetIssue.mockResolvedValueOnce(mockIssue);
mockListTimeline.mockResolvedValueOnce(mockTimeline);
await renderPage();
await waitFor(() => {
expect(
screen.getByText("Started working on this"),
).toBeInTheDocument();
});
expect(screen.getByText("I can help with this")).toBeInTheDocument();
expect(screen.getAllByText("Activity").length).toBeGreaterThanOrEqual(1);
});
it("shows 'Issue not found' for missing issue", async () => {
// issue-detail fetches getIssue, useIssueReactions also fetches getIssue
mockGetIssue.mockRejectedValue(new Error("Not found"));
mockListTimeline.mockRejectedValue(new Error("Not found"));
await renderPage("nonexistent-id");
await waitFor(() => {
expect(screen.getByText("This issue does not exist or has been deleted in this workspace.")).toBeInTheDocument();
});
});
it("submits a new comment", async () => {
mockGetIssue.mockResolvedValueOnce(mockIssue);
mockListTimeline.mockResolvedValueOnce(mockTimeline);
const newComment: Comment = {
id: "comment-3",
issue_id: "issue-1",
content: "New test comment",
type: "comment",
author_type: "member",
author_id: "user-1",
parent_id: null,
reactions: [],
attachments: [],
created_at: "2026-01-18T00:00:00Z",
updated_at: "2026-01-18T00:00:00Z",
};
mockCreateComment.mockResolvedValueOnce(newComment);
const user = userEvent.setup();
await renderPage();
await waitFor(() => {
expect(
screen.getByPlaceholderText("Leave a comment..."),
).toBeInTheDocument();
});
const commentInput = screen.getByPlaceholderText("Leave a comment...");
// Use fireEvent to update the textarea value and trigger onUpdate
await act(async () => {
fireEvent.change(commentInput, { target: { value: "New test comment" } });
});
// Find the submit button associated with the "Leave a comment..." input.
// Multiple ArrowUp buttons exist (one per ReplyInput), so we find the
// button within the same ReplyInput container as our textarea.
const allArrowUpBtns = screen.getAllByRole("button").filter(
(btn) => btn.querySelector(".lucide-arrow-up") !== null,
);
// The bottom "Leave a comment..." ReplyInput renders last, so its button is last
const submitBtn = allArrowUpBtns[allArrowUpBtns.length - 1]!;
await waitFor(() => {
expect(submitBtn).not.toBeDisabled();
});
await user.click(submitBtn);
await waitFor(() => {
expect(mockCreateComment).toHaveBeenCalled();
const [issueId, content] = mockCreateComment.mock.calls[0]!;
expect(issueId).toBe("issue-1");
expect(content).toBe("New test comment");
});
await waitFor(() => {
expect(screen.getByText("New test comment")).toBeInTheDocument();
});
});
it("renders breadcrumb navigation", async () => {
mockGetIssue.mockResolvedValueOnce(mockIssue);
mockListTimeline.mockResolvedValueOnce(mockTimeline);
await renderPage();
await waitFor(() => {
expect(screen.getByText("Test WS")).toBeInTheDocument();
});
const wsLink = screen.getByText("Test WS");
expect(wsLink.closest("a")).toHaveAttribute("href", "/issues");
});
});

View File

@@ -1,7 +1,7 @@
"use client";
import { use } from "react";
import { IssueDetail } from "@multica/views/issues/components";
import { IssueDetail } from "@/features/issues/components";
export default function IssueDetailPage({
params,

View File

@@ -0,0 +1,373 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { Issue } from "@/shared/types";
// Mock next/navigation
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn() }),
usePathname: () => "/issues",
}));
// Mock next/link
vi.mock("next/link", () => ({
default: ({
children,
href,
...props
}: {
children: React.ReactNode;
href: string;
[key: string]: any;
}) => (
<a href={href} {...props}>
{children}
</a>
),
}));
// 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",
getActorAvatarUrl: () => null,
}),
useWorkspaceStore: Object.assign(
(selector?: any) => {
const state = { workspace: { id: "ws-1", name: "Test", slug: "test" }, agents: [], members: [] };
return selector ? selector(state) : state;
},
{ getState: () => ({ workspace: { id: "ws-1", name: "Test", slug: "test" }, agents: [], members: [] }) },
),
WorkspaceAvatar: ({ name }: { name: string }) => <span>{name.charAt(0)}</span>,
}));
// Mock WebSocket context
vi.mock("@/features/realtime", () => ({
useWSEvent: vi.fn(),
useWSReconnect: vi.fn(),
useWS: () => ({ subscribe: vi.fn(() => () => {}), onReconnect: vi.fn(() => () => {}) }),
WSProvider: ({ children }: { children: React.ReactNode }) => children,
}));
// Mock sonner toast
vi.mock("sonner", () => ({
toast: { error: vi.fn(), success: vi.fn() },
}));
// Mock api
const mockUpdateIssue = vi.fn();
vi.mock("@/shared/api", () => ({
api: {
listIssues: vi.fn().mockResolvedValue({ issues: [], total: 0 }),
updateIssue: (...args: any[]) => mockUpdateIssue(...args),
},
}));
// Mock the issue store
let mockStoreState: {
issues: Issue[];
loading: boolean;
fetch: () => Promise<void>;
setIssues: (issues: Issue[]) => void;
addIssue: (issue: Issue) => void;
updateIssue: (id: string, updates: Partial<Issue>) => void;
removeIssue: (id: string) => void;
};
vi.mock("@/features/issues/store", () => ({
useIssueStore: Object.assign(
(selector?: any) => (selector ? selector(mockStoreState) : mockStoreState),
{ getState: () => mockStoreState },
),
}));
vi.mock("@/features/issues", () => ({
useIssueStore: Object.assign(
(selector?: any) => (selector ? selector(mockStoreState) : mockStoreState),
{ getState: () => mockStoreState },
),
StatusIcon: () => null,
PriorityIcon: () => null,
StatusPicker: ({ value, onChange }: any) => (
<button onClick={() => onChange?.("todo")}>{value || "todo"}</button>
),
PriorityPicker: ({ value, onChange }: any) => (
<button onClick={() => onChange?.("none")}>{value || "none"}</button>
),
}));
// Mock view store
const mockViewState = {
viewMode: "board" as const,
statusFilters: [] as string[],
priorityFilters: [] as string[],
assigneeFilters: [] as { type: string; id: string }[],
includeNoAssignee: false,
creatorFilters: [] as { type: string; id: string }[],
sortBy: "position" as const,
sortDirection: "asc" as const,
cardProperties: { priority: true, description: true, assignee: true, dueDate: true },
listCollapsedStatuses: [] as string[],
setViewMode: vi.fn(),
toggleStatusFilter: vi.fn(),
togglePriorityFilter: vi.fn(),
toggleAssigneeFilter: vi.fn(),
toggleNoAssignee: vi.fn(),
toggleCreatorFilter: vi.fn(),
hideStatus: vi.fn(),
showStatus: vi.fn(),
clearFilters: vi.fn(),
setSortBy: vi.fn(),
setSortDirection: vi.fn(),
toggleCardProperty: vi.fn(),
toggleListCollapsed: vi.fn(),
};
vi.mock("@/features/issues/stores/view-store", () => ({
initFilterWorkspaceSync: vi.fn(),
useIssueViewStore: Object.assign(
(selector?: any) => (selector ? selector(mockViewState) : mockViewState),
{ getState: () => mockViewState, setState: vi.fn() },
),
SORT_OPTIONS: [
{ value: "position", label: "Manual" },
{ value: "priority", label: "Priority" },
{ value: "due_date", label: "Due date" },
{ value: "created_at", label: "Created date" },
{ value: "title", label: "Title" },
],
CARD_PROPERTY_OPTIONS: [
{ key: "priority", label: "Priority" },
{ key: "description", label: "Description" },
{ key: "assignee", label: "Assignee" },
{ key: "dueDate", label: "Due date" },
],
}));
// Mock view store context (shared components read from context)
vi.mock("@/features/issues/stores/view-store-context", () => ({
ViewStoreProvider: ({ children }: { children: React.ReactNode }) => children,
useViewStore: (selector?: any) => (selector ? selector(mockViewState) : mockViewState),
useViewStoreApi: () => ({ getState: () => mockViewState, setState: vi.fn(), subscribe: vi.fn() }),
}));
// Mock issue config
vi.mock("@/features/issues/config", () => ({
ALL_STATUSES: ["backlog", "todo", "in_progress", "in_review", "done", "blocked", "cancelled"],
BOARD_STATUSES: ["backlog", "todo", "in_progress", "in_review", "done", "blocked"],
STATUS_ORDER: ["backlog", "todo", "in_progress", "in_review", "done", "blocked", "cancelled"],
STATUS_CONFIG: {
backlog: { label: "Backlog", iconColor: "text-muted-foreground", hoverBg: "hover:bg-accent" },
todo: { label: "Todo", iconColor: "text-muted-foreground", hoverBg: "hover:bg-accent" },
in_progress: { label: "In Progress", iconColor: "text-warning", hoverBg: "hover:bg-warning/10" },
in_review: { label: "In Review", iconColor: "text-success", hoverBg: "hover:bg-success/10" },
done: { label: "Done", iconColor: "text-done", hoverBg: "hover:bg-done/10" },
blocked: { label: "Blocked", iconColor: "text-destructive", hoverBg: "hover:bg-destructive/10" },
cancelled: { label: "Cancelled", iconColor: "text-muted-foreground", hoverBg: "hover:bg-accent" },
},
PRIORITY_ORDER: ["urgent", "high", "medium", "low", "none"],
PRIORITY_CONFIG: {
urgent: { label: "Urgent", bars: 4, color: "text-destructive" },
high: { label: "High", bars: 3, color: "text-warning" },
medium: { label: "Medium", bars: 2, color: "text-warning" },
low: { label: "Low", bars: 1, color: "text-info" },
none: { label: "No priority", bars: 0, color: "text-muted-foreground" },
},
}));
// Mock modals
vi.mock("@/features/modals", () => ({
useModalStore: Object.assign(
() => ({ open: vi.fn() }),
{ getState: () => ({ open: vi.fn() }) },
),
}));
// Mock dnd-kit
vi.mock("@dnd-kit/core", () => ({
DndContext: ({ children }: any) => children,
DragOverlay: () => null,
PointerSensor: class {},
useSensor: () => ({}),
useSensors: () => [],
useDroppable: () => ({ setNodeRef: vi.fn(), isOver: false }),
pointerWithin: vi.fn(),
closestCenter: vi.fn(),
}));
vi.mock("@dnd-kit/sortable", () => ({
SortableContext: ({ children }: any) => children,
verticalListSortingStrategy: {},
useSortable: () => ({
attributes: {},
listeners: {},
setNodeRef: vi.fn(),
transform: null,
transition: null,
isDragging: false,
}),
}));
vi.mock("@dnd-kit/utilities", () => ({
CSS: { Transform: { toString: () => undefined } },
}));
const issueDefaults = {
parent_issue_id: null,
position: 0,
};
const mockIssues: Issue[] = [
{
...issueDefaults,
id: "issue-1",
workspace_id: "ws-1",
number: 1,
identifier: "TES-1",
title: "Implement auth",
description: "Add JWT authentication",
status: "todo",
priority: "high",
assignee_type: "member",
assignee_id: "user-1",
creator_type: "member",
creator_id: "user-1",
due_date: null,
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
},
{
...issueDefaults,
id: "issue-2",
workspace_id: "ws-1",
number: 2,
identifier: "TES-2",
title: "Design landing page",
description: null,
status: "in_progress",
priority: "medium",
assignee_type: "agent",
assignee_id: "agent-1",
creator_type: "member",
creator_id: "user-1",
due_date: "2026-02-01T00:00:00Z",
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
},
{
...issueDefaults,
id: "issue-3",
workspace_id: "ws-1",
number: 3,
identifier: "TES-3",
title: "Write tests",
description: null,
status: "backlog",
priority: "low",
assignee_type: null,
assignee_id: null,
creator_type: "member",
creator_id: "user-1",
due_date: null,
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
},
];
import IssuesPage from "./page";
describe("IssuesPage", () => {
beforeEach(() => {
vi.clearAllMocks();
mockStoreState = {
issues: [],
loading: true,
fetch: vi.fn(),
setIssues: vi.fn(),
addIssue: vi.fn(),
updateIssue: vi.fn(),
removeIssue: vi.fn(),
};
mockViewState.viewMode = "board";
mockViewState.statusFilters = [];
mockViewState.priorityFilters = [];
});
it("shows loading state initially", () => {
mockStoreState.loading = true;
mockStoreState.issues = [];
render(<IssuesPage />);
expect(screen.getAllByRole("generic").some(el => el.getAttribute("data-slot") === "skeleton")).toBe(true);
});
it("renders issues in board view after loading", async () => {
mockStoreState.loading = false;
mockStoreState.issues = mockIssues;
render(<IssuesPage />);
expect(screen.getByText("Implement auth")).toBeInTheDocument();
expect(screen.getByText("Design landing page")).toBeInTheDocument();
expect(screen.getByText("Write tests")).toBeInTheDocument();
});
it("renders board columns", async () => {
mockStoreState.loading = false;
mockStoreState.issues = mockIssues;
render(<IssuesPage />);
expect(screen.getAllByText("Backlog").length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText("Todo").length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText("In Progress").length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText("In Review").length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText("Done").length).toBeGreaterThanOrEqual(1);
});
it("shows workspace breadcrumb", () => {
mockStoreState.loading = false;
mockStoreState.issues = [];
render(<IssuesPage />);
expect(screen.getByText("Issues")).toBeInTheDocument();
});
it("shows scope buttons", () => {
mockStoreState.loading = false;
mockStoreState.issues = [];
render(<IssuesPage />);
expect(screen.getByText("All")).toBeInTheDocument();
expect(screen.getByText("Members")).toBeInTheDocument();
expect(screen.getByText("Agents")).toBeInTheDocument();
});
it("shows filter and display icon buttons", () => {
mockStoreState.loading = false;
mockStoreState.issues = mockIssues;
render(<IssuesPage />);
// Filter and Display are now icon-only buttons, verify they render as buttons
const buttons = screen.getAllByRole("button");
expect(buttons.length).toBeGreaterThan(0);
});
it("shows empty board view when no issues exist", () => {
mockStoreState.loading = false;
mockStoreState.issues = [];
render(<IssuesPage />);
// Should still render the board/list view, not a "no issues" message
expect(screen.queryByText("No matching issues")).not.toBeInTheDocument();
});
});

View File

@@ -1,6 +1,6 @@
"use client";
import { IssuesPage } from "@multica/views/issues/components";
import { IssuesPage } from "@/features/issues/components/issues-page";
export default function Page() {
return <IssuesPage />;

View File

@@ -1,19 +1,57 @@
"use client";
import { DashboardLayout } from "@multica/views/layout";
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
import { SearchCommand, SearchTrigger } from "@multica/views/search";
import { ChatFab, ChatWindow } from "@multica/views/chat";
import { useEffect } from "react";
import { useRouter, usePathname } from "next/navigation";
import { MulticaIcon } from "@/components/multica-icon";
import { useNavigationStore } from "@/features/navigation";
import { SidebarProvider, SidebarInset } from "@/components/ui/sidebar";
import { useAuthStore } from "@/features/auth";
import { useWorkspaceStore } from "@/features/workspace";
import { AppSidebar } from "./_components/app-sidebar";
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
const router = useRouter();
const pathname = usePathname();
const user = useAuthStore((s) => s.user);
const isLoading = useAuthStore((s) => s.isLoading);
const workspace = useWorkspaceStore((s) => s.workspace);
useEffect(() => {
if (!isLoading && !user) {
router.push("/");
}
}, [user, isLoading, router]);
useEffect(() => {
useNavigationStore.getState().onPathChange(pathname);
}, [pathname]);
if (isLoading) {
return (
<div className="flex h-screen items-center justify-center">
<MulticaIcon className="size-6" />
</div>
);
}
if (!user) return null;
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<DashboardLayout
loadingIndicator={<MulticaIcon className="size-6" />}
searchSlot={<SearchTrigger />}
extra={<><SearchCommand /><ChatWindow /><ChatFab /></>}
onboardingPath="/onboarding"
>
{children}
</DashboardLayout>
<SidebarProvider className="h-svh">
<AppSidebar />
<SidebarInset className="overflow-hidden">
{workspace ? (
children
) : (
<div className="flex flex-1 items-center justify-center">
<MulticaIcon className="size-6 animate-pulse" />
</div>
)}
</SidebarInset>
</SidebarProvider>
);
}

View File

@@ -1,28 +0,0 @@
import { Skeleton } from "@multica/ui/components/ui/skeleton";
export default function DashboardLoading() {
return (
<div className="flex flex-1 min-h-0 flex-col">
{/* Header skeleton */}
<div className="flex h-12 shrink-0 items-center gap-2 border-b px-4">
<Skeleton className="h-5 w-5 rounded" />
<Skeleton className="h-4 w-32" />
</div>
{/* Toolbar skeleton */}
<div className="flex h-12 shrink-0 items-center justify-between border-b px-4">
<Skeleton className="h-5 w-24" />
<Skeleton className="h-8 w-24" />
</div>
{/* Content skeleton */}
<div className="flex-1 p-4 space-y-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="flex items-center gap-3">
<Skeleton className="h-4 w-4 rounded" />
<Skeleton className="h-4 flex-1 max-w-md" />
<Skeleton className="h-4 w-16" />
</div>
))}
</div>
</div>
);
}

View File

@@ -1,6 +1,6 @@
"use client";
import { MyIssuesPage } from "@multica/views/my-issues";
import { MyIssuesPage } from "@/features/my-issues";
export default function Page() {
return <MyIssuesPage />;

View File

@@ -1,13 +0,0 @@
"use client";
import { use } from "react";
import { ProjectDetail } from "@multica/views/projects/components";
export default function ProjectDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = use(params);
return <ProjectDetail projectId={id} />;
}

Some files were not shown because too many files have changed in this diff Show More