mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-29 02:19:19 +02:00
Compare commits
342 Commits
agent/lamb
...
agent/emac
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5d8daf902 | ||
|
|
ca7ba48934 | ||
|
|
63895343e3 | ||
|
|
88982ad23f | ||
|
|
7620a5a7e9 | ||
|
|
289e3c3ad0 | ||
|
|
e867076bde | ||
|
|
303a4b3144 | ||
|
|
0998a3a87d | ||
|
|
5878bddd6b | ||
|
|
102831919c | ||
|
|
1dd8ca86c3 | ||
|
|
aa6577c5b7 | ||
|
|
ef1db9e754 | ||
|
|
2d8c0a2d60 | ||
|
|
5647c129da | ||
|
|
254871635e | ||
|
|
cb81aa48d3 | ||
|
|
6340b560c7 | ||
|
|
cc5e2e1712 | ||
|
|
b067eee487 | ||
|
|
1f9ce6582c | ||
|
|
a4383e051f | ||
|
|
c1b1a55808 | ||
|
|
547b8839b2 | ||
|
|
4c88a1318d | ||
|
|
fb1554c0bf | ||
|
|
33768a2d3a | ||
|
|
05067f4960 | ||
|
|
715f196434 | ||
|
|
add8bf9f4f | ||
|
|
ba32f3a187 | ||
|
|
a8c3137f3b | ||
|
|
79b4c75303 | ||
|
|
18b16f2936 | ||
|
|
8567dacd55 | ||
|
|
a012d912fe | ||
|
|
042985d961 | ||
|
|
02cdfcb93f | ||
|
|
25080c6719 | ||
|
|
89fd2ce96e | ||
|
|
7d5db1ce8b | ||
|
|
825e40358b | ||
|
|
b5cccc8ac6 | ||
|
|
aec07456fc | ||
|
|
6209e2f3ae | ||
|
|
0a5a3b2450 | ||
|
|
90b2cb7848 | ||
|
|
bb34bd3db9 | ||
|
|
7950ac72af | ||
|
|
db55b79aa1 | ||
|
|
21484e506a | ||
|
|
63d01f5d6c | ||
|
|
6fa68fe20e | ||
|
|
141d7fd0aa | ||
|
|
c057741e22 | ||
|
|
5ebadefcd7 | ||
|
|
70aea76bf6 | ||
|
|
fb475915c1 | ||
|
|
1f717c9059 | ||
|
|
8a73251b15 | ||
|
|
c283288133 | ||
|
|
c8f0f3dc9d | ||
|
|
821b6ece57 | ||
|
|
3ffebd097c | ||
|
|
d911cdf5ac | ||
|
|
245beed829 | ||
|
|
741247c5cc | ||
|
|
ef11bcd2d1 | ||
|
|
9da6a911cd | ||
|
|
b669b1c3a6 | ||
|
|
8c51614cfa | ||
|
|
1b7c3d7d94 | ||
|
|
b7ffba4d2f | ||
|
|
072ccc90aa | ||
|
|
8cf27af3b2 | ||
|
|
0696532a99 | ||
|
|
0ff9e2ba39 | ||
|
|
3916a0ed1d | ||
|
|
b6c369ef17 | ||
|
|
870d9d9465 | ||
|
|
fee8f41ea5 | ||
|
|
80afd1cc00 | ||
|
|
8526f013da | ||
|
|
3046f51300 | ||
|
|
d5f18c23cb | ||
|
|
dab9c7cf9b | ||
|
|
83769c4780 | ||
|
|
68e2a14ba2 | ||
|
|
848d79df11 | ||
|
|
1caa7f6324 | ||
|
|
f9a430e100 | ||
|
|
d7a37f60b5 | ||
|
|
0e0c5f4cdb | ||
|
|
bea274492c | ||
|
|
f7c1ae4d77 | ||
|
|
784111a498 | ||
|
|
77f48d9f26 | ||
|
|
6e475b9521 | ||
|
|
dafd51e327 | ||
|
|
f9eeafb568 | ||
|
|
4585306bfc | ||
|
|
0c4f1027e8 | ||
|
|
74cc1d488e | ||
|
|
a135c44838 | ||
|
|
ec2b48a616 | ||
|
|
50f9e673e8 | ||
|
|
e2d98181c7 | ||
|
|
a9e68abb9d | ||
|
|
7ca5a97ec8 | ||
|
|
e3f34ace8e | ||
|
|
a9b3d4e6f4 | ||
|
|
a2a021a0dd | ||
|
|
711ab886e2 | ||
|
|
a092443a09 | ||
|
|
de73d39310 | ||
|
|
ff27a249cc | ||
|
|
4668aad039 | ||
|
|
b484b78cbd | ||
|
|
23136da34f | ||
|
|
5d1cc2a9bb | ||
|
|
f41a0cf423 | ||
|
|
35828492d5 | ||
|
|
e1e7f68330 | ||
|
|
e2da970344 | ||
|
|
b3fa5557ca | ||
|
|
19a1bbba4a | ||
|
|
f57cf44eba | ||
|
|
ae797811d2 | ||
|
|
7d01cf8c68 | ||
|
|
e79eabcc18 | ||
|
|
d2e4b9753d | ||
|
|
fab17b48b3 | ||
|
|
4f8969ef52 | ||
|
|
2e5b8b9a87 | ||
|
|
f4ba27f2f5 | ||
|
|
e6f840ca11 | ||
|
|
aa770f2333 | ||
|
|
bd6731525e | ||
|
|
68d052625c | ||
|
|
3d053345fd | ||
|
|
180c6966db | ||
|
|
0c45864ef0 | ||
|
|
c6ba954eb8 | ||
|
|
76354cd968 | ||
|
|
4bdb86057e | ||
|
|
a8a8ff6eca | ||
|
|
0dcaa60919 | ||
|
|
17e37ec4db | ||
|
|
060afc848c | ||
|
|
1903b886f6 | ||
|
|
240813c605 | ||
|
|
7d74b1f0b9 | ||
|
|
39ca8ed9e8 | ||
|
|
3c08395741 | ||
|
|
ec934f3a8b | ||
|
|
25cf64588d | ||
|
|
301a4a3882 | ||
|
|
102b19d948 | ||
|
|
a7afd4b959 | ||
|
|
8403c97688 | ||
|
|
7df5750979 | ||
|
|
990cc8b3ae | ||
|
|
7ee2450297 | ||
|
|
d58f6cdb33 | ||
|
|
af156040cb | ||
|
|
5e770b2e2f | ||
|
|
92e76dea81 | ||
|
|
4df32a853b | ||
|
|
fa0c0fe747 | ||
|
|
8a8d3ea20e | ||
|
|
88c2f4ddc4 | ||
|
|
98af9f442c | ||
|
|
34c39b765e | ||
|
|
efe131591f | ||
|
|
104bbbef41 | ||
|
|
eed8e36a69 | ||
|
|
8cf78b7a47 | ||
|
|
862b85e064 | ||
|
|
857ec7d4d4 | ||
|
|
7c79611309 | ||
|
|
99dad49052 | ||
|
|
6296629831 | ||
|
|
7ed565da6b | ||
|
|
030627c8c5 | ||
|
|
fe9479d6fc | ||
|
|
b94108768e | ||
|
|
348133b63d | ||
|
|
6032b5dfcb | ||
|
|
23198f3c26 | ||
|
|
e40341ab73 | ||
|
|
c695de5314 | ||
|
|
d6b59aade6 | ||
|
|
1d812bd446 | ||
|
|
abcc7bf3cd | ||
|
|
06fa65d4b5 | ||
|
|
9d1570b301 | ||
|
|
7f2ea9857d | ||
|
|
1ad057fb0f | ||
|
|
b85c068e83 | ||
|
|
30cda933bc | ||
|
|
b5537077bc | ||
|
|
638033c9ff | ||
|
|
7560f7be85 | ||
|
|
b84104b421 | ||
|
|
0c92fb2674 | ||
|
|
14fe8e9df9 | ||
|
|
f9c0fcba24 | ||
|
|
47917825d1 | ||
|
|
eab5f8e7e8 | ||
|
|
9495179923 | ||
|
|
f16b36fbc8 | ||
|
|
dd2ce90b1d | ||
|
|
88b87e2fa6 | ||
|
|
2be9f6cd2f | ||
|
|
5cf4ba803d | ||
|
|
cfb0365cb3 | ||
|
|
81d430d870 | ||
|
|
96d81f9836 | ||
|
|
5fe1ec806d | ||
|
|
2f63714dba | ||
|
|
4cf18e122d | ||
|
|
02a7598906 | ||
|
|
0263ecce9e | ||
|
|
d450b3d454 | ||
|
|
f1140222a1 | ||
|
|
66067a267a | ||
|
|
76c6b41033 | ||
|
|
29507a2e3a | ||
|
|
ceec6d3795 | ||
|
|
08ba74b399 | ||
|
|
ed7a288946 | ||
|
|
a26f9e965b | ||
|
|
6574d68d2b | ||
|
|
3bf094ebf7 | ||
|
|
72da372eba | ||
|
|
5fba76f010 | ||
|
|
09565bc40f | ||
|
|
4036d64996 | ||
|
|
5b0a537302 | ||
|
|
0d9d4e6b69 | ||
|
|
4c0dbbf1c8 | ||
|
|
52a9a6ae5f | ||
|
|
d6a5ba4d5e | ||
|
|
4afef09a03 | ||
|
|
0771c15a59 | ||
|
|
3a96567fc1 | ||
|
|
9d9e0317c0 | ||
|
|
5f2ac17129 | ||
|
|
4df3a52c4e | ||
|
|
9aee403ff9 | ||
|
|
7883fe7bd7 | ||
|
|
cbfb7d58b6 | ||
|
|
2832a06fe3 | ||
|
|
451715f5a1 | ||
|
|
fdf594155c | ||
|
|
c39470a53f | ||
|
|
e5dfb34a2a | ||
|
|
58549975e0 | ||
|
|
0bbc6bc1c5 | ||
|
|
beeb8bc107 | ||
|
|
5548d60dbb | ||
|
|
9fb25f4543 | ||
|
|
33140d4c5a | ||
|
|
2787bd60be | ||
|
|
e879d82e7d | ||
|
|
9b8cc0870b | ||
|
|
ce40b66c60 | ||
|
|
56b49cb2a6 | ||
|
|
4353340ea6 | ||
|
|
91cbf32fd1 | ||
|
|
10b482fac2 | ||
|
|
0024208354 | ||
|
|
32a3a3543d | ||
|
|
e314badf18 | ||
|
|
ad0615a08f | ||
|
|
fc6405e4be | ||
|
|
7b610a4013 | ||
|
|
978e81a268 | ||
|
|
c9c8230271 | ||
|
|
b84543e634 | ||
|
|
6c651f4be5 | ||
|
|
b5924ffa99 | ||
|
|
30640436c4 | ||
|
|
eb355dbc9c | ||
|
|
f34ed091e7 | ||
|
|
d9a6b8c8ed | ||
|
|
27e58d91af | ||
|
|
6799458807 | ||
|
|
8eb1caa72b | ||
|
|
35b379d688 | ||
|
|
392a8d7c8c | ||
|
|
fd744c331e | ||
|
|
a98f165458 | ||
|
|
097630c733 | ||
|
|
c3cca50f27 | ||
|
|
36ba23b3cd | ||
|
|
5df444ba00 | ||
|
|
e6a1ff4354 | ||
|
|
7cc4e63e0e | ||
|
|
36db325d50 | ||
|
|
d751373368 | ||
|
|
09764c5f51 | ||
|
|
565afed447 | ||
|
|
222f60d2dd | ||
|
|
e8c2a8eff9 | ||
|
|
7f0cb106bd | ||
|
|
c7fda85a3e | ||
|
|
b8b4731602 | ||
|
|
fe975fb2bb | ||
|
|
fc0ef0fcd8 | ||
|
|
62b7c0cfa2 | ||
|
|
606930725a | ||
|
|
ed9aef8f39 | ||
|
|
856a254252 | ||
|
|
682dc20ba9 | ||
|
|
17418f37b2 | ||
|
|
eba2e7eacf | ||
|
|
fdba410f11 | ||
|
|
a80d61f8e1 | ||
|
|
ff616de82b | ||
|
|
f353e8db59 | ||
|
|
575bbd7f60 | ||
|
|
cd1b1155c1 | ||
|
|
68da1efd74 | ||
|
|
bea739cba5 | ||
|
|
b88c7f1afa | ||
|
|
f05f3face3 | ||
|
|
bf713d3ad5 | ||
|
|
ac06e7f4a3 | ||
|
|
0659865645 | ||
|
|
ab505fd39c | ||
|
|
7b19ad7ccc | ||
|
|
6b0c9bba9e | ||
|
|
60e66f32b3 | ||
|
|
eb35bc5dc9 | ||
|
|
99fdf39c50 | ||
|
|
e4b2053d90 | ||
|
|
1264a1fa67 | ||
|
|
c4a24c1a86 | ||
|
|
479d69305d | ||
|
|
b1f7364097 |
@@ -29,6 +29,7 @@ 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=
|
||||
|
||||
File diff suppressed because one or more lines are too long
34
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
34
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
## What
|
||||
|
||||
<!-- What does this PR do? Keep it to 1-3 sentences. -->
|
||||
|
||||
## Why
|
||||
|
||||
<!-- Why is this change needed? Link the related issue. -->
|
||||
|
||||
Closes #<!-- issue number -->
|
||||
|
||||
## Type of Change
|
||||
|
||||
- [ ] Bug fix
|
||||
- [ ] New feature
|
||||
- [ ] Refactor / code improvement
|
||||
- [ ] Documentation
|
||||
- [ ] CI / infrastructure
|
||||
- [ ] Other (describe below)
|
||||
|
||||
## How to Test
|
||||
|
||||
<!-- How can a reviewer verify this works? Steps, commands, or screenshots. -->
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] `make check` passes (typecheck, unit tests, Go tests, E2E)
|
||||
- [ ] Changes follow existing code patterns and conventions
|
||||
- [ ] No unrelated changes included
|
||||
|
||||
## AI Disclosure (optional)
|
||||
|
||||
<!-- If AI tools were used: -->
|
||||
<!-- - Which tool? (e.g., Claude Code, Copilot, Cursor) -->
|
||||
<!-- - What prompt did you use? Sharing your prompt helps others learn and lets reviewers understand intent. -->
|
||||
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -57,7 +57,7 @@ jobs:
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.24"
|
||||
go-version: "1.26.1"
|
||||
cache-dependency-path: server/go.sum
|
||||
|
||||
- name: Build
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -36,6 +36,7 @@ apps/web/test-results/
|
||||
|
||||
# local settings
|
||||
.claude/
|
||||
.tool-versions
|
||||
|
||||
# feature tracking
|
||||
_features/
|
||||
|
||||
308
CLAUDE.md
308
CLAUDE.md
@@ -12,99 +12,47 @@ Multica is an AI-native task management platform — like Linear, but with AI ag
|
||||
|
||||
## Architecture
|
||||
|
||||
**Go backend + standalone Next.js frontend.**
|
||||
**Go backend + monorepo frontend (pnpm workspaces + Turborepo) with shared packages.**
|
||||
|
||||
- `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
|
||||
- `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
|
||||
|
||||
### Web App Structure (`apps/web/`)
|
||||
### Key Architectural Decisions
|
||||
|
||||
The frontend uses a **feature-based architecture** with four layers:
|
||||
**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.
|
||||
|
||||
```
|
||||
apps/web/
|
||||
├── app/ # Routing layer (thin shells — import from features/)
|
||||
├── features/ # Business logic, organized by domain
|
||||
├── shared/ # Cross-feature utilities (api client, types, logger)
|
||||
```
|
||||
**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.
|
||||
|
||||
**`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/`.
|
||||
**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.
|
||||
|
||||
**`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.
|
||||
**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.
|
||||
|
||||
### 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.
|
||||
The architecture relies on a strict split between server state and client state. Mixing them is the most common way to break it.
|
||||
|
||||
**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.
|
||||
- **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.
|
||||
|
||||
### Import Aliases
|
||||
**Hard rules — these are how the architecture stays coherent:**
|
||||
|
||||
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";
|
||||
```
|
||||
- **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.
|
||||
|
||||
Within a feature, use relative imports. Between features or to shared, use `@/`.
|
||||
**Common Zustand footguns to avoid:**
|
||||
|
||||
### 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).
|
||||
- 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).
|
||||
|
||||
## Commands
|
||||
|
||||
@@ -115,13 +63,14 @@ make start # Start backend + frontend together
|
||||
make stop # Stop app processes for the current checkout
|
||||
make db-down # Stop the shared PostgreSQL container
|
||||
|
||||
# Frontend
|
||||
# Frontend (all commands go through Turborepo)
|
||||
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)
|
||||
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)
|
||||
|
||||
# Backend (Go)
|
||||
make dev # Run Go server (port 8080)
|
||||
@@ -133,15 +82,24 @@ 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
|
||||
@@ -149,7 +107,7 @@ 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`.
|
||||
CI runs on Node 22 and Go 1.26.1 with a `pgvector/pgvector:pg17` PostgreSQL service. See `.github/workflows/ci.yml`.
|
||||
|
||||
### Worktree Support
|
||||
|
||||
@@ -169,43 +127,129 @@ 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 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- **If a component is identical between web and desktop, it belongs in a shared package.** Do not copy-paste between apps.
|
||||
|
||||
## Testing Rules
|
||||
|
||||
- **TypeScript**: Vitest. Mock external/third-party dependencies only.
|
||||
- **Go**: Standard `go test`. Tests should create their own fixture data in a test database.
|
||||
### 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}`);
|
||||
});
|
||||
```
|
||||
|
||||
## Commit Rules
|
||||
|
||||
- Use atomic commits grouped by logical intent.
|
||||
- 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.
|
||||
- Conventional format: `feat(scope)`, `fix(scope)`, `refactor(scope)`, `docs`, `test(scope)`, `chore(scope)`.
|
||||
|
||||
## Minimum Pre-Push Checks
|
||||
|
||||
@@ -218,7 +262,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)
|
||||
pnpm test # TS unit tests only (Vitest, all packages)
|
||||
make test # Go tests only
|
||||
pnpm exec playwright test # E2E only (requires backend + frontend running)
|
||||
```
|
||||
@@ -231,43 +275,29 @@ 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 `make check`
|
||||
- If any step fails, read the error output, fix the code, and re-run
|
||||
- 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
|
||||
## CLI Release
|
||||
|
||||
E2E tests should be self-contained. Use the `TestApiClient` fixture for data setup/teardown:
|
||||
**Prerequisite:** A CLI release must accompany every Production deployment.
|
||||
|
||||
```typescript
|
||||
import { loginAsDefault, createTestApi } from "./helpers";
|
||||
import type { TestApiClient } from "./fixtures";
|
||||
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
|
||||
|
||||
let api: TestApiClient;
|
||||
By default, bump the patch version each release (e.g. `v0.1.12` → `v0.1.13`), unless the user specifies a specific version.
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
api = await createTestApi(); // logged-in API client
|
||||
await loginAsDefault(page); // browser session
|
||||
});
|
||||
## Multi-tenancy
|
||||
|
||||
test.afterEach(async () => {
|
||||
await api.cleanup(); // delete any data created during the test
|
||||
});
|
||||
All queries filter by `workspace_id`. Membership checks gate access. `X-Workspace-ID` header routes requests to the correct workspace.
|
||||
|
||||
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
|
||||
});
|
||||
```
|
||||
## 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).
|
||||
|
||||
@@ -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-cli
|
||||
brew install multica
|
||||
```
|
||||
|
||||
### Build from Source
|
||||
@@ -289,6 +289,23 @@ multica issue comment add <issue-id> --parent <comment-id> --content "Thanks!"
|
||||
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
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
## Configuration
|
||||
|
||||
### View Config
|
||||
|
||||
171
CLI_INSTALL.md
Normal file
171
CLI_INSTALL.md
Normal file
@@ -0,0 +1,171 @@
|
||||
# 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
|
||||
|
||||
### Option A: Homebrew (preferred)
|
||||
|
||||
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 (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`.
|
||||
|
||||
---
|
||||
|
||||
## 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`).
|
||||
|
||||
**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` or `codex`) 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`)
|
||||
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: [Claude Code](https://docs.anthropic.com/en/docs/claude-code) (`claude`) or [Codex](https://github.com/openai/codex) (`codex`), 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`."
|
||||
221
LICENSE
221
LICENSE
@@ -1,199 +1,44 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
# Open Source License
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
Multica is licensed under a modified version of the Apache License 2.0, with the following additional conditions:
|
||||
|
||||
1. Definitions.
|
||||
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:
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
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.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
- 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.
|
||||
|
||||
"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.
|
||||
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.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
- 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.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
2. As a contributor, you should agree that:
|
||||
|
||||
"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.
|
||||
a. The producer can adjust the open-source agreement to be more strict
|
||||
or relaxed as deemed necessary.
|
||||
|
||||
"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).
|
||||
b. Your contributed code may be used for commercial purposes, including
|
||||
but not limited to its cloud business operations.
|
||||
|
||||
"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.
|
||||
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.
|
||||
|
||||
"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.
|
||||
© 2025 Multica, Inc.
|
||||
|
||||
17
Makefile
17
Makefile
@@ -69,7 +69,12 @@ stop:
|
||||
@echo "Stopping services..."
|
||||
@-lsof -ti:$(PORT) | xargs kill -9 2>/dev/null
|
||||
@-lsof -ti:$(FRONTEND_PORT) | xargs kill -9 2>/dev/null
|
||||
@echo "✓ App processes stopped. Shared PostgreSQL is still running on localhost:5432."
|
||||
@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
|
||||
|
||||
# Full verification: typecheck + unit tests + Go tests + E2E
|
||||
check:
|
||||
@@ -98,8 +103,12 @@ check-main:
|
||||
@ENV_FILE=$(MAIN_ENV_FILE) bash scripts/check.sh
|
||||
|
||||
setup-worktree:
|
||||
@echo "==> Generating $(WORKTREE_ENV_FILE) with unique ports..."
|
||||
@FORCE=1 bash scripts/init-worktree-env.sh $(WORKTREE_ENV_FILE)
|
||||
@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
|
||||
@$(MAKE) setup ENV_FILE=$(WORKTREE_ENV_FILE)
|
||||
|
||||
start-worktree:
|
||||
@@ -134,10 +143,12 @@ COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
|
||||
build:
|
||||
cd server && go build -o bin/server ./cmd/server
|
||||
cd server && go build -ldflags "-X main.version=$(VERSION) -X main.commit=$(COMMIT)" -o bin/multica ./cmd/multica
|
||||
cd server && go build -o bin/migrate ./cmd/migrate
|
||||
|
||||
test:
|
||||
$(REQUIRE_ENV)
|
||||
@bash scripts/ensure-postgres.sh "$(ENV_FILE)"
|
||||
cd server && go run ./cmd/migrate up
|
||||
cd server && go test ./...
|
||||
|
||||
# Database
|
||||
|
||||
135
README.md
135
README.md
@@ -1,28 +1,59 @@
|
||||
<p align="center">
|
||||
<img src="docs/assets/banner.jpg" alt="Multica — humans and agents, side by side" width="100%">
|
||||
</p>
|
||||
|
||||
<div align="center">
|
||||
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/logo-dark.svg">
|
||||
<source media="(prefers-color-scheme: light)" srcset="docs/assets/logo-light.svg">
|
||||
<img alt="Multica" src="docs/assets/logo-light.svg" width="50">
|
||||
</picture>
|
||||
|
||||
# Multica
|
||||
|
||||
AI-native project management — like Linear, but with AI agents as first-class team members.
|
||||
**Your next 10 hires won't be human.**
|
||||
|
||||
Multica lets you manage tasks and collaborate with AI agents the same way you work with human teammates. Agents can be assigned issues, post comments, update statuses, and execute work autonomously on your local machine.
|
||||
The open-source managed agents platform.<br/>
|
||||
Turn coding agents into real teammates — assign tasks, track progress, compound skills.
|
||||
|
||||
[](https://github.com/multica-ai/multica/actions/workflows/ci.yml)
|
||||
[](https://opensource.org/licenses/Apache-2.0)
|
||||
[](https://github.com/multica-ai/multica/stargazers)
|
||||
|
||||
[Website](https://multica.ai) · [Cloud](https://multica.ai/app) · [X](https://x.com/multica_hq) · [Self-Hosting](SELF_HOSTING.md) · [Contributing](CONTRIBUTING.md)
|
||||
|
||||
**English | [简体中文](README.zh-CN.md)**
|
||||
|
||||
</div>
|
||||
|
||||
## 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**.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/assets/hero-screenshot.png" alt="Multica board view" width="800">
|
||||
</p>
|
||||
|
||||
## Features
|
||||
|
||||
- **AI agents as teammates** — assign issues to agents, mention them in comments, and let them do the work
|
||||
- **Local agent runtime** — agents run on your machine using Claude Code or Codex, with full access to your codebase
|
||||
- **Real-time collaboration** — WebSocket-powered live updates across the board
|
||||
- **Multi-workspace** — organize work across teams with workspace-level isolation
|
||||
- **Familiar UX** — if you've used Linear, you'll feel right at home
|
||||
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.
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Use Multica Cloud
|
||||
### Multica Cloud
|
||||
|
||||
The fastest way to get started: [multica.ai](https://multica.ai)
|
||||
The fastest way to get started — no setup required: **[multica.ai](https://multica.ai)**
|
||||
|
||||
### Self-Host
|
||||
|
||||
Run Multica on your own infrastructure. See the [Self-Hosting Guide](SELF_HOSTING.md) for full instructions.
|
||||
|
||||
Quick start with Docker:
|
||||
### Self-Host with Docker
|
||||
|
||||
```bash
|
||||
git clone https://github.com/multica-ai/multica.git
|
||||
@@ -30,18 +61,25 @@ cd multica
|
||||
cp .env.example .env
|
||||
# Edit .env — at minimum, change JWT_SECRET
|
||||
|
||||
# Start PostgreSQL
|
||||
docker compose up -d
|
||||
|
||||
# Build and run the backend
|
||||
cd server && go run ./cmd/migrate up && cd ..
|
||||
make start
|
||||
docker compose up -d # Start PostgreSQL
|
||||
cd server && go run ./cmd/migrate up && cd .. # Run migrations
|
||||
make start # Start the app
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
**Option A — paste this to your coding 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.
|
||||
```
|
||||
|
||||
**Option B — install manually:**
|
||||
|
||||
```bash
|
||||
# Install
|
||||
brew tap multica-ai/tap
|
||||
@@ -52,10 +90,39 @@ 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.
|
||||
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.
|
||||
|
||||
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`, `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. 🎉
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
@@ -66,27 +133,23 @@ See the [CLI and Daemon Guide](CLI_AND_DAEMON.md) for the full command reference
|
||||
│
|
||||
┌──────┴───────┐
|
||||
│ Agent Daemon │ (runs on your machine)
|
||||
│ Claude/Codex │
|
||||
│Claude/Codex/ │
|
||||
│OpenClaw/Code │
|
||||
└──────────────┘
|
||||
```
|
||||
|
||||
- **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 or Codex
|
||||
| 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 |
|
||||
|
||||
## Development
|
||||
|
||||
For contributors working on the Multica codebase, see the [Contributing Guide](CONTRIBUTING.md).
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [Node.js](https://nodejs.org/) (v20+)
|
||||
- [pnpm](https://pnpm.io/) (v10.28+)
|
||||
- [Go](https://go.dev/) (v1.26+)
|
||||
- [Docker](https://www.docker.com/)
|
||||
|
||||
### Quick Start
|
||||
**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
|
||||
pnpm install
|
||||
@@ -96,7 +159,3 @@ make start
|
||||
```
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for the full development workflow, worktree support, testing, and troubleshooting.
|
||||
|
||||
## License
|
||||
|
||||
See [LICENSE](LICENSE) for details.
|
||||
|
||||
165
README.zh-CN.md
Normal file
165
README.zh-CN.md
Normal file
@@ -0,0 +1,165 @@
|
||||
<p align="center">
|
||||
<img src="docs/assets/banner.jpg" alt="Multica — 人类与 AI,并肩前行" width="100%">
|
||||
</p>
|
||||
|
||||
<div align="center">
|
||||
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/logo-dark.svg">
|
||||
<source media="(prefers-color-scheme: light)" srcset="docs/assets/logo-light.svg">
|
||||
<img alt="Multica" src="docs/assets/logo-light.svg" width="50">
|
||||
</picture>
|
||||
|
||||
# Multica
|
||||
|
||||
**你的下一批员工,不是人类。**
|
||||
|
||||
开源的 Managed Agents 平台。<br/>
|
||||
将编码 Agent 变成真正的队友——分配任务、跟踪进度、积累技能。
|
||||
|
||||
[](https://github.com/multica-ai/multica/actions/workflows/ci.yml)
|
||||
[](https://opensource.org/licenses/Apache-2.0)
|
||||
[](https://github.com/multica-ai/multica/stargazers)
|
||||
|
||||
[官网](https://multica.ai) · [云服务](https://multica.ai/app) · [X](https://x.com/multica_hq) · [自部署指南](SELF_HOSTING.md) · [参与贡献](CONTRIBUTING.md)
|
||||
|
||||
**[English](README.md) | 简体中文**
|
||||
|
||||
</div>
|
||||
|
||||
## Multica 是什么?
|
||||
|
||||
Multica 将编码 Agent 变成真正的队友。像分配给同事一样分配给 Agent——它们会自主接手工作、编写代码、报告阻塞问题、更新状态。
|
||||
|
||||
不再需要复制粘贴 prompt,不再需要盯着运行过程。你的 Agent 出现在看板上、参与对话、随着时间积累可复用的技能。可以理解为开源的 Managed Agents 基础设施——厂商中立、可自部署、专为人类 + AI 团队设计。支持 **Claude Code**、**Codex**、**OpenClaw** 和 **OpenCode**。
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/assets/hero-screenshot.png" alt="Multica 看板视图" width="800">
|
||||
</p>
|
||||
|
||||
## 功能特性
|
||||
|
||||
Multica 管理完整的 Agent 生命周期:从任务分配到执行监控再到技能复用。
|
||||
|
||||
- **Agent 即队友** — 像分配给同事一样分配给 Agent。它们有个人档案、出现在看板上、发表评论、创建 Issue、主动报告阻塞问题。
|
||||
- **自主执行** — 设置后无需管理。完整的任务生命周期管理(排队、认领、执行、完成/失败),通过 WebSocket 实时推送进度。
|
||||
- **可复用技能** — 每个解决方案都成为全团队可复用的技能。部署、数据库迁移、代码审查——技能让团队能力随时间持续增长。
|
||||
- **统一运行时** — 一个控制台管理所有算力。本地 daemon 和云端运行时,自动检测可用 CLI,实时监控。
|
||||
- **多工作区** — 按团队组织工作,工作区级别隔离。每个工作区有独立的 Agent、Issue 和设置。
|
||||
|
||||
## 快速开始
|
||||
|
||||
### Multica 云服务
|
||||
|
||||
最快的上手方式,无需任何配置:**[multica.ai](https://multica.ai)**
|
||||
|
||||
### Docker 自部署
|
||||
|
||||
```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 # 启动应用
|
||||
```
|
||||
|
||||
完整部署文档请参阅 [自部署指南](SELF_HOSTING.md)。
|
||||
|
||||
## CLI
|
||||
|
||||
`multica` CLI 将你的本地机器连接到 Multica — 用于认证、管理工作区和运行 Agent daemon。
|
||||
|
||||
**方式 A — 将以下指令粘贴给你的 coding agent(Claude Code、Codex、OpenClaw、OpenCode 等):**
|
||||
|
||||
```
|
||||
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.
|
||||
```
|
||||
|
||||
**方式 B — 手动安装:**
|
||||
|
||||
```bash
|
||||
# 安装
|
||||
brew tap multica-ai/tap
|
||||
brew install multica
|
||||
|
||||
# 认证并启动
|
||||
multica login
|
||||
multica daemon start
|
||||
```
|
||||
|
||||
daemon 会自动检测 PATH 中可用的 Agent CLI(`claude`、`codex`、`openclaw`、`opencode`)。当 Agent 被分配任务时,daemon 会创建隔离环境、运行 Agent、并将结果回传。
|
||||
|
||||
完整命令参考请参阅 [CLI 与 Daemon 指南](CLI_AND_DAEMON.md)。
|
||||
|
||||
## 快速上手
|
||||
|
||||
安装好 CLI(或注册 [Multica 云服务](https://multica.ai))后,按以下步骤将第一个任务分配给 Agent:
|
||||
|
||||
### 1. 登录并启动 daemon
|
||||
|
||||
```bash
|
||||
multica login # 使用你的 Multica 账号认证
|
||||
multica daemon start # 启动本地 Agent 运行时
|
||||
```
|
||||
|
||||
daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动检测 PATH 中可用的 Agent CLI(`claude`、`codex`、`openclaw`、`opencode`)。
|
||||
|
||||
### 2. 确认运行时已连接
|
||||
|
||||
在 Multica Web 端打开你的工作区,进入 **设置 → 运行时(Runtimes)**,你应该能看到你的机器已作为一个活跃的 **Runtime** 出现在列表中。
|
||||
|
||||
> **什么是 Runtime(运行时)?** Runtime 是可以执行 Agent 任务的计算环境。它可以是你的本地机器(通过 daemon 连接),也可以是云端实例。每个 Runtime 会上报可用的 Agent CLI,Multica 据此决定将任务路由到哪里执行。
|
||||
|
||||
### 3. 创建 Agent
|
||||
|
||||
进入 **设置 → Agents**,点击 **新建 Agent**。选择你刚连接的 Runtime,选择 Provider(Claude Code、Codex、OpenClaw 或 OpenCode),并为 Agent 起个名字——它将以这个名字出现在看板、评论和任务分配中。
|
||||
|
||||
### 4. 分配你的第一个任务
|
||||
|
||||
在看板上创建一个 Issue(或通过 `multica issue create` 命令创建),然后将其分配给你的新 Agent。Agent 会自动接手任务、在你的 Runtime 上执行、并实时汇报进度——就像一个真正的队友一样。
|
||||
|
||||
大功告成!你的 Agent 现在是团队的一员了。 🎉
|
||||
|
||||
## 架构
|
||||
|
||||
```
|
||||
┌──────────────┐ ┌──────────────┐ ┌──────────────────┐
|
||||
│ Next.js │────>│ Go 后端 │────>│ PostgreSQL │
|
||||
│ 前端 │<────│ (Chi + WS) │<────│ (pgvector) │
|
||||
└──────────────┘ └──────┬───────┘ └──────────────────┘
|
||||
│
|
||||
┌──────┴───────┐
|
||||
│ Agent Daemon │ (运行在你的机器上)
|
||||
│Claude/Codex/ │
|
||||
│OpenClaw/Code │
|
||||
└──────────────┘
|
||||
```
|
||||
|
||||
| 层级 | 技术栈 |
|
||||
|------|--------|
|
||||
| 前端 | Next.js 16 (App Router) |
|
||||
| 后端 | Go (Chi router, sqlc, gorilla/websocket) |
|
||||
| 数据库 | PostgreSQL 17 with pgvector |
|
||||
| Agent 运行时 | 本地 daemon 执行 Claude Code、Codex、OpenClaw 或 OpenCode |
|
||||
|
||||
## 开发
|
||||
|
||||
参与 Multica 代码贡献,请参阅 [贡献指南](CONTRIBUTING.md)。
|
||||
|
||||
**环境要求:** [Node.js](https://nodejs.org/) v20+, [pnpm](https://pnpm.io/) v10.28+, [Go](https://go.dev/) v1.26+, [Docker](https://www.docker.com/)
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
cp .env.example .env
|
||||
make setup
|
||||
make start
|
||||
```
|
||||
|
||||
完整的开发流程、worktree 支持、测试和问题排查请参阅 [CONTRIBUTING.md](CONTRIBUTING.md)。
|
||||
|
||||
## 开源协议
|
||||
|
||||
[Apache 2.0](LICENSE)
|
||||
@@ -257,8 +257,14 @@ Each team member who wants to run AI agents locally needs to:
|
||||
|
||||
```bash
|
||||
# Point CLI to your server
|
||||
#
|
||||
# For production deployments with TLS:
|
||||
export MULTICA_APP_URL=https://app.example.com
|
||||
export MULTICA_SERVER_URL=wss://api.example.com/ws
|
||||
#
|
||||
# For local deployments without TLS:
|
||||
# export MULTICA_APP_URL=http://localhost:3000
|
||||
# export MULTICA_SERVER_URL=ws://localhost:8080/ws
|
||||
|
||||
# Login (opens browser)
|
||||
multica login
|
||||
@@ -267,6 +273,8 @@ Each team member who wants to run AI agents locally needs to:
|
||||
multica daemon start
|
||||
```
|
||||
|
||||
> **Note:** Use `https://` and `wss://` for production deployments behind a TLS-terminating reverse proxy. For local or development deployments without TLS, use `http://` and `ws://` instead.
|
||||
|
||||
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
|
||||
|
||||
6
apps/desktop/.gitignore
vendored
Normal file
6
apps/desktop/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
dist
|
||||
out
|
||||
.DS_Store
|
||||
.eslintcache
|
||||
*.log*
|
||||
31
apps/desktop/electron-builder.yml
Normal file
31
apps/desktop/electron-builder.yml
Normal file
@@ -0,0 +1,31 @@
|
||||
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}"
|
||||
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
|
||||
22
apps/desktop/electron.vite.config.ts
Normal file
22
apps/desktop/electron.vite.config.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { resolve } from "path";
|
||||
import { defineConfig, externalizeDepsPlugin } from "electron-vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
export default defineConfig({
|
||||
main: {
|
||||
plugins: [externalizeDepsPlugin()],
|
||||
},
|
||||
preload: {
|
||||
plugins: [externalizeDepsPlugin()],
|
||||
},
|
||||
renderer: {
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": resolve("src/renderer/src"),
|
||||
},
|
||||
dedupe: ["react", "react-dom"],
|
||||
},
|
||||
},
|
||||
});
|
||||
6
apps/desktop/eslint.config.mjs
Normal file
6
apps/desktop/eslint.config.mjs
Normal file
@@ -0,0 +1,6 @@
|
||||
import reactConfig from "@multica/eslint-config/react";
|
||||
|
||||
export default [
|
||||
...reactConfig,
|
||||
{ ignores: ["out/", "dist/"] },
|
||||
];
|
||||
44
apps/desktop/package.json
Normal file
44
apps/desktop/package.json
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "@multica/desktop",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"main": "./out/main/index.js",
|
||||
"scripts": {
|
||||
"dev": "electron-vite dev",
|
||||
"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": {
|
||||
"@electron-toolkit/preload": "^3.0.2",
|
||||
"@electron-toolkit/utils": "^4.0.0",
|
||||
"@multica/core": "workspace:*",
|
||||
"@multica/ui": "workspace:*",
|
||||
"@multica/views": "workspace:*",
|
||||
"react-router-dom": "^7.6.0",
|
||||
"shadcn": "^4.1.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tw-animate-css": "^1.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@multica/tsconfig": "workspace:*",
|
||||
"@electron-toolkit/tsconfig": "^2.0.0",
|
||||
"@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:"
|
||||
}
|
||||
}
|
||||
BIN
apps/desktop/resources/icon.png
Normal file
BIN
apps/desktop/resources/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 35 KiB |
55
apps/desktop/src/main/index.ts
Normal file
55
apps/desktop/src/main/index.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { app, shell, BrowserWindow } from "electron";
|
||||
import { join } from "path";
|
||||
import { electronApp, optimizer, is } from "@electron-toolkit/utils";
|
||||
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
electronApp.setAppUserModelId("ai.multica.desktop");
|
||||
|
||||
app.on("browser-window-created", (_, window) => {
|
||||
optimizer.watchWindowShortcuts(window);
|
||||
});
|
||||
|
||||
createWindow();
|
||||
|
||||
app.on("activate", () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) createWindow();
|
||||
});
|
||||
});
|
||||
|
||||
app.on("window-all-closed", () => {
|
||||
if (process.platform !== "darwin") app.quit();
|
||||
});
|
||||
9
apps/desktop/src/preload/index.d.ts
vendored
Normal file
9
apps/desktop/src/preload/index.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
import { ElectronAPI } from "@electron-toolkit/preload";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
electron: ElectronAPI;
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
9
apps/desktop/src/preload/index.ts
Normal file
9
apps/desktop/src/preload/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { contextBridge } from "electron";
|
||||
import { electronAPI } from "@electron-toolkit/preload";
|
||||
|
||||
if (process.contextIsolated) {
|
||||
contextBridge.exposeInMainWorld("electron", electronAPI);
|
||||
} else {
|
||||
// @ts-expect-error - fallback for non-isolated context
|
||||
window.electron = electronAPI;
|
||||
}
|
||||
12
apps/desktop/src/renderer/index.html
Normal file
12
apps/desktop/src/renderer/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!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>
|
||||
37
apps/desktop/src/renderer/src/App.tsx
Normal file
37
apps/desktop/src/renderer/src/App.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { CoreProvider } from "@multica/core/platform";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
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);
|
||||
|
||||
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 />;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<CoreProvider
|
||||
apiBaseUrl={import.meta.env.VITE_API_URL || "http://localhost:8080"}
|
||||
wsUrl={import.meta.env.VITE_WS_URL || "ws://localhost:8080/ws"}
|
||||
>
|
||||
<AppContent />
|
||||
</CoreProvider>
|
||||
<Toaster />
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
102
apps/desktop/src/renderer/src/components/desktop-layout.tsx
Normal file
102
apps/desktop/src/renderer/src/components/desktop-layout.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
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 */}
|
||||
<div className="flex flex-1 min-h-0 flex-col overflow-hidden mr-2 mb-2 ml-0.5 rounded-xl shadow-sm bg-background">
|
||||
<TabContent />
|
||||
</div>
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
</div>
|
||||
<ModalRegistry />
|
||||
<SearchCommand />
|
||||
<ChatWindow />
|
||||
<ChatFab />
|
||||
</DashboardGuard>
|
||||
</DesktopNavigationProvider>
|
||||
);
|
||||
}
|
||||
112
apps/desktop/src/renderer/src/components/tab-bar.tsx
Normal file
112
apps/desktop/src/renderer/src/components/tab-bar.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import {
|
||||
Inbox,
|
||||
CircleUser,
|
||||
ListTodo,
|
||||
Bot,
|
||||
Monitor,
|
||||
BookOpenText,
|
||||
Settings,
|
||||
X,
|
||||
Plus,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
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 TabItem({ tab, isActive, isOnly }: { tab: Tab; isActive: boolean; isOnly: boolean }) {
|
||||
const setActiveTab = useTabStore((s) => s.setActiveTab);
|
||||
const closeTab = useTabStore((s) => s.closeTab);
|
||||
|
||||
const Icon = TAB_ICONS[tab.icon];
|
||||
|
||||
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
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleClick}
|
||||
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
|
||||
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",
|
||||
)}
|
||||
>
|
||||
{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}
|
||||
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);
|
||||
|
||||
return (
|
||||
<div className="flex h-full items-center gap-0.5 px-2 justify-start">
|
||||
{tabs.map((tab) => (
|
||||
<TabItem key={tab.id} tab={tab} isActive={tab.id === activeTabId} isOnly={tabs.length === 1} />
|
||||
))}
|
||||
<NewTabButton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
43
apps/desktop/src/renderer/src/components/tab-content.tsx
Normal file
43
apps/desktop/src/renderer/src/components/tab-content.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
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>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
1
apps/desktop/src/renderer/src/env.d.ts
vendored
Normal file
1
apps/desktop/src/renderer/src/env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
17
apps/desktop/src/renderer/src/globals.css
Normal file
17
apps/desktop/src/renderer/src/globals.css
Normal file
@@ -0,0 +1,17 @@
|
||||
@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 *));
|
||||
|
||||
@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;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
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]);
|
||||
}
|
||||
40
apps/desktop/src/renderer/src/hooks/use-tab-history.ts
Normal file
40
apps/desktop/src/renderer/src/hooks/use-tab-history.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
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 };
|
||||
}
|
||||
49
apps/desktop/src/renderer/src/hooks/use-tab-router-sync.ts
Normal file
49
apps/desktop/src/renderer/src/hooks/use-tab-router-sync.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
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]);
|
||||
}
|
||||
29
apps/desktop/src/renderer/src/hooks/use-tab-sync.ts
Normal file
29
apps/desktop/src/renderer/src/hooks/use-tab-sync.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
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();
|
||||
}, []);
|
||||
}
|
||||
5
apps/desktop/src/renderer/src/main.tsx
Normal file
5
apps/desktop/src/renderer/src/main.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App";
|
||||
import "./globals.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(<App />);
|
||||
17
apps/desktop/src/renderer/src/pages/issue-detail-page.tsx
Normal file
17
apps/desktop/src/renderer/src/pages/issue-detail-page.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
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} />;
|
||||
}
|
||||
20
apps/desktop/src/renderer/src/pages/login.tsx
Normal file
20
apps/desktop/src/renderer/src/pages/login.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { LoginPage } from "@multica/views/auth";
|
||||
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
|
||||
|
||||
export function DesktopLoginPage() {
|
||||
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" />}
|
||||
onSuccess={() => {
|
||||
// Auth store update triggers AppContent re-render → shows DesktopShell
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
17
apps/desktop/src/renderer/src/pages/project-detail-page.tsx
Normal file
17
apps/desktop/src/renderer/src/pages/project-detail-page.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
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} />;
|
||||
}
|
||||
116
apps/desktop/src/renderer/src/platform/navigation.tsx
Normal file
116
apps/desktop/src/renderer/src/platform/navigation.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
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>;
|
||||
}
|
||||
99
apps/desktop/src/renderer/src/routes.tsx
Normal file
99
apps/desktop/src/renderer/src/routes.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
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],
|
||||
});
|
||||
}
|
||||
185
apps/desktop/src/renderer/src/stores/tab-store.ts
Normal file
185
apps/desktop/src/renderer/src/stores/tab-store.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
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;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 crypto.randomUUID();
|
||||
}
|
||||
|
||||
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,
|
||||
),
|
||||
}));
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "multica_tabs",
|
||||
version: 1,
|
||||
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 };
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
4
apps/desktop/tsconfig.json
Normal file
4
apps/desktop/tsconfig.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [{ "path": "./tsconfig.node.json" }, { "path": "./tsconfig.web.json" }]
|
||||
}
|
||||
8
apps/desktop/tsconfig.node.json
Normal file
8
apps/desktop/tsconfig.node.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@electron-toolkit/tsconfig/tsconfig.node.json",
|
||||
"include": ["electron.vite.config.*", "src/main/**/*", "src/preload/**/*"],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"types": ["electron-vite/node"]
|
||||
}
|
||||
}
|
||||
20
apps/desktop/tsconfig.web.json
Normal file
20
apps/desktop/tsconfig.web.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"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/*"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
3
apps/docs/.gitignore
vendored
Normal file
3
apps/docs/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
.next/
|
||||
.source/
|
||||
node_modules/
|
||||
7
apps/docs/app/(home)/layout.tsx
Normal file
7
apps/docs/app/(home)/layout.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
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>;
|
||||
}
|
||||
29
apps/docs/app/(home)/page.tsx
Normal file
29
apps/docs/app/(home)/page.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
4
apps/docs/app/api/search/route.ts
Normal file
4
apps/docs/app/api/search/route.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { source } from "@/lib/source";
|
||||
import { createFromSource } from "fumadocs-core/search/server";
|
||||
|
||||
export const { GET } = createFromSource(source);
|
||||
47
apps/docs/app/docs/[[...slug]]/page.tsx
Normal file
47
apps/docs/app/docs/[[...slug]]/page.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
12
apps/docs/app/docs/layout.tsx
Normal file
12
apps/docs/app/docs/layout.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
3
apps/docs/app/global.css
Normal file
3
apps/docs/app/global.css
Normal file
@@ -0,0 +1,3 @@
|
||||
@import "tailwindcss";
|
||||
@import "fumadocs-ui/css/neutral.css";
|
||||
@import "fumadocs-ui/css/preset.css";
|
||||
25
apps/docs/app/layout.config.tsx
Normal file
25
apps/docs/app/layout.config.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
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",
|
||||
},
|
||||
],
|
||||
};
|
||||
23
apps/docs/app/layout.tsx
Normal file
23
apps/docs/app/layout.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
90
apps/docs/content/docs/cli/installation.mdx
Normal file
90
apps/docs/content/docs/cli/installation.mdx
Normal file
@@ -0,0 +1,90 @@
|
||||
---
|
||||
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
|
||||
# 1. Authenticate (opens browser for login)
|
||||
multica login
|
||||
|
||||
# 2. Start the agent daemon
|
||||
multica daemon start
|
||||
|
||||
# 3. Done — agents in your watched workspaces can now execute tasks on your machine
|
||||
```
|
||||
|
||||
`multica login` automatically discovers all workspaces you belong to and adds them to the daemon watch list.
|
||||
|
||||
## Verify
|
||||
|
||||
```bash
|
||||
multica daemon status
|
||||
```
|
||||
|
||||
Confirm:
|
||||
1. Status is `running`
|
||||
2. At least one agent is listed (e.g. `claude`, `codex`)
|
||||
3. At least one workspace is being watched
|
||||
|
||||
If the agents list is empty, install at least one AI agent CLI:
|
||||
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code) (`claude`)
|
||||
- [Codex](https://github.com/openai/codex) (`codex`)
|
||||
|
||||
Then restart the daemon:
|
||||
|
||||
```bash
|
||||
multica daemon stop && multica daemon start
|
||||
```
|
||||
4
apps/docs/content/docs/cli/meta.json
Normal file
4
apps/docs/content/docs/cli/meta.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"title": "CLI & Daemon",
|
||||
"pages": ["installation", "reference"]
|
||||
}
|
||||
306
apps/docs/content/docs/cli/reference.mdx
Normal file
306
apps/docs/content/docs/cli/reference.mdx
Normal file
@@ -0,0 +1,306 @@
|
||||
---
|
||||
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 |
|
||||
|
||||
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 |
|
||||
|
||||
### 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
|
||||
# Start a daemon for the staging server
|
||||
multica --profile staging login
|
||||
multica --profile staging daemon start
|
||||
|
||||
# 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
|
||||
```
|
||||
75
apps/docs/content/docs/developers/architecture.mdx
Normal file
75
apps/docs/content/docs/developers/architecture.mdx
Normal file
@@ -0,0 +1,75 @@
|
||||
---
|
||||
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.
|
||||
178
apps/docs/content/docs/developers/contributing.mdx
Normal file
178
apps/docs/content/docs/developers/contributing.mdx
Normal file
@@ -0,0 +1,178 @@
|
||||
---
|
||||
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.
|
||||
4
apps/docs/content/docs/developers/meta.json
Normal file
4
apps/docs/content/docs/developers/meta.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"title": "Developers",
|
||||
"pages": ["contributing", "architecture"]
|
||||
}
|
||||
48
apps/docs/content/docs/getting-started/cloud-quickstart.mdx
Normal file
48
apps/docs/content/docs/getting-started/cloud-quickstart.mdx
Normal file
@@ -0,0 +1,48 @@
|
||||
---
|
||||
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
|
||||
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`, `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.
|
||||
4
apps/docs/content/docs/getting-started/meta.json
Normal file
4
apps/docs/content/docs/getting-started/meta.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"title": "Getting Started",
|
||||
"pages": ["cloud-quickstart", "self-hosting"]
|
||||
}
|
||||
281
apps/docs/content/docs/getting-started/self-hosting.mdx
Normal file
281
apps/docs/content/docs/getting-started/self-hosting.mdx
Normal file
@@ -0,0 +1,281 @@
|
||||
---
|
||||
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 |
|
||||
|
||||
Additionally, each user who wants to run AI agents locally installs the **`multica` CLI** and runs the **agent daemon** on their own machine.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- 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
|
||||
|
||||
## Quick Start (Docker Compose)
|
||||
|
||||
```bash
|
||||
git clone https://github.com/multica-ai/multica.git
|
||||
cd multica
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Edit `.env` with your production values (see [Configuration](#configuration) below), then:
|
||||
|
||||
```bash
|
||||
# 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
|
||||
```
|
||||
|
||||
For the frontend:
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm build
|
||||
|
||||
# Start the frontend (production mode)
|
||||
cd apps/web
|
||||
REMOTE_API_URL=http://localhost:8080 pnpm start
|
||||
```
|
||||
|
||||
## 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 |
|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
## 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.
|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
> **Note:** Use `https://` and `wss://` for production deployments behind a TLS-terminating reverse proxy. For local or development deployments without TLS, use `http://` and `ws://` instead.
|
||||
|
||||
The daemon auto-detects installed agent CLIs and registers itself with the server.
|
||||
|
||||
## 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.
|
||||
50
apps/docs/content/docs/guides/agents.mdx
Normal file
50
apps/docs/content/docs/guides/agents.mdx
Normal file
@@ -0,0 +1,50 @@
|
||||
---
|
||||
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.
|
||||
4
apps/docs/content/docs/guides/meta.json
Normal file
4
apps/docs/content/docs/guides/meta.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"title": "Guides",
|
||||
"pages": ["quickstart", "agents"]
|
||||
}
|
||||
31
apps/docs/content/docs/guides/quickstart.mdx
Normal file
31
apps/docs/content/docs/guides/quickstart.mdx
Normal file
@@ -0,0 +1,31 @@
|
||||
---
|
||||
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. 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`, `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.
|
||||
47
apps/docs/content/docs/index.mdx
Normal file
47
apps/docs/content/docs/index.mdx
Normal file
@@ -0,0 +1,47 @@
|
||||
---
|
||||
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)
|
||||
10
apps/docs/content/docs/meta.json
Normal file
10
apps/docs/content/docs/meta.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"title": "Documentation",
|
||||
"pages": [
|
||||
"index",
|
||||
"getting-started",
|
||||
"cli",
|
||||
"guides",
|
||||
"developers"
|
||||
]
|
||||
}
|
||||
7
apps/docs/lib/source.ts
Normal file
7
apps/docs/lib/source.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { docs } from "@/.source";
|
||||
import { loader } from "fumadocs-core/source";
|
||||
|
||||
export const source = loader({
|
||||
baseUrl: "/docs",
|
||||
source: docs.toFumadocsSource(),
|
||||
});
|
||||
6
apps/docs/next-env.d.ts
vendored
Normal file
6
apps/docs/next-env.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
/// <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.
|
||||
10
apps/docs/next.config.mjs
Normal file
10
apps/docs/next.config.mjs
Normal file
@@ -0,0 +1,10 @@
|
||||
import { createMDX } from "fumadocs-mdx/next";
|
||||
|
||||
const withMDX = createMDX();
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const config = {
|
||||
reactStrictMode: true,
|
||||
};
|
||||
|
||||
export default withMDX(config);
|
||||
29
apps/docs/package.json
Normal file
29
apps/docs/package.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"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:"
|
||||
}
|
||||
}
|
||||
7
apps/docs/postcss.config.mjs
Normal file
7
apps/docs/postcss.config.mjs
Normal file
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
9
apps/docs/source.config.ts
Normal file
9
apps/docs/source.config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { defineDocs, defineConfig } from "fumadocs-mdx/config";
|
||||
|
||||
export const docs = defineDocs({
|
||||
dir: "content/docs",
|
||||
});
|
||||
|
||||
export default defineConfig({
|
||||
mdxOptions: {},
|
||||
});
|
||||
48
apps/docs/tsconfig.json
Normal file
48
apps/docs/tsconfig.json
Normal file
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"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"
|
||||
]
|
||||
}
|
||||
@@ -2,35 +2,54 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
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() }),
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
usePathname: () => "/login",
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}));
|
||||
|
||||
// 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 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 workspace store
|
||||
const mockHydrateWorkspace = vi.fn();
|
||||
vi.mock("@/features/workspace", () => ({
|
||||
useWorkspaceStore: (selector: (s: any) => any) =>
|
||||
selector({
|
||||
hydrateWorkspace: mockHydrateWorkspace,
|
||||
}),
|
||||
}));
|
||||
// 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 api
|
||||
vi.mock("@/shared/api", () => ({
|
||||
vi.mock("@multica/core/api", () => ({
|
||||
api: {
|
||||
listWorkspaces: vi.fn().mockResolvedValue([]),
|
||||
verifyCode: vi.fn(),
|
||||
@@ -49,8 +68,8 @@ describe("LoginPage", () => {
|
||||
it("renders login form with email input and continue button", () => {
|
||||
render(<LoginPage />);
|
||||
|
||||
expect(screen.getByText("Multica")).toBeInTheDocument();
|
||||
expect(screen.getByText("AI-native task management")).toBeInTheDocument();
|
||||
expect(screen.getByText("Sign in to Multica")).toBeInTheDocument();
|
||||
expect(screen.getByText("Enter your email to get a login code")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Email")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Continue" })
|
||||
|
||||
@@ -1,328 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, useState, useEffect, useCallback } from "react";
|
||||
import { Suspense, useEffect } from "react";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
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";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { setLoggedInCookie } from "@/features/auth/auth-cookie";
|
||||
import { LoginPage, validateCliCallback } from "@multica/views/auth";
|
||||
|
||||
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)}`;
|
||||
}
|
||||
const googleClientId = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID;
|
||||
|
||||
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();
|
||||
|
||||
// Already authenticated — redirect to dashboard
|
||||
const cliCallbackRaw = searchParams.get("cli_callback");
|
||||
const cliState = searchParams.get("cli_state") || "";
|
||||
const nextUrl = searchParams.get("next") || "/issues";
|
||||
|
||||
// Already authenticated — redirect to dashboard (skip if CLI callback)
|
||||
useEffect(() => {
|
||||
if (!isLoading && user && !searchParams.get("cli_callback")) {
|
||||
router.replace(searchParams.get("next") || "/issues");
|
||||
if (!isLoading && user && !cliCallbackRaw) {
|
||||
router.replace(nextUrl);
|
||||
}
|
||||
}, [isLoading, user, router, searchParams]);
|
||||
}, [isLoading, user, router, nextUrl, cliCallbackRaw]);
|
||||
|
||||
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);
|
||||
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
const lastWorkspaceId =
|
||||
typeof window !== "undefined"
|
||||
? localStorage.getItem("multica_workspace_id")
|
||||
: null;
|
||||
|
||||
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">Multica</CardTitle>
|
||||
<CardDescription>AI-native task management</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>
|
||||
<LoginPage
|
||||
onSuccess={() => router.push(nextUrl)}
|
||||
google={
|
||||
googleClientId
|
||||
? {
|
||||
clientId: googleClientId,
|
||||
redirectUri: `${window.location.origin}/auth/callback`,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
cliCallback={
|
||||
cliCallbackRaw && validateCliCallback(cliCallbackRaw)
|
||||
? { url: cliCallbackRaw, state: cliState }
|
||||
: undefined
|
||||
}
|
||||
lastWorkspaceId={lastWorkspaceId}
|
||||
onTokenObtained={setLoggedInCookie}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
export default function Page() {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<LoginPageContent />
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
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
@@ -1,10 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,460 +1 @@
|
||||
"use client";
|
||||
|
||||
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 selectedKey = searchParams.get("issue") ?? "";
|
||||
const setSelectedKey = (key: string) => {
|
||||
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.issue_id}
|
||||
issueId={selected.issue_id}
|
||||
defaultSidebarOpen={false}
|
||||
layoutId="multica_inbox_issue_detail_layout"
|
||||
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>
|
||||
);
|
||||
}
|
||||
export { InboxPage as default } from "@multica/views/inbox";
|
||||
|
||||
@@ -1,362 +0,0 @@
|
||||
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 RichTextEditor (Tiptap needs real DOM)
|
||||
vi.mock("@/components/common/rich-text-editor", () => ({
|
||||
RichTextEditor: forwardRef(({ defaultValue, onUpdate, placeholder, onSubmit }: any, ref: any) => {
|
||||
const valueRef = useRef(defaultValue || "");
|
||||
const [value, setValue] = useState(defaultValue || "");
|
||||
useImperativeHandle(ref, () => ({
|
||||
getMarkdown: () => valueRef.current,
|
||||
clearContent: () => { valueRef.current = ""; setValue(""); },
|
||||
focus: () => {},
|
||||
}));
|
||||
return (
|
||||
<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"
|
||||
/>
|
||||
);
|
||||
}),
|
||||
}));
|
||||
|
||||
// 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");
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { use } from "react";
|
||||
import { IssueDetail } from "@/features/issues/components";
|
||||
import { IssueDetail } from "@multica/views/issues/components";
|
||||
|
||||
export default function IssueDetailPage({
|
||||
params,
|
||||
|
||||
@@ -1,373 +0,0 @@
|
||||
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-info", hoverBg: "hover:bg-info/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();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { IssuesPage } from "@/features/issues/components/issues-page";
|
||||
import { IssuesPage } from "@multica/views/issues/components";
|
||||
|
||||
export default function Page() {
|
||||
return <IssuesPage />;
|
||||
|
||||
@@ -1,57 +1,18 @@
|
||||
"use client";
|
||||
|
||||
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;
|
||||
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";
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<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>
|
||||
<DashboardLayout
|
||||
loadingIndicator={<MulticaIcon className="size-6" />}
|
||||
searchSlot={<SearchTrigger />}
|
||||
extra={<><SearchCommand /><ChatWindow /><ChatFab /></>}
|
||||
>
|
||||
{children}
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
28
apps/web/app/(dashboard)/loading.tsx
Normal file
28
apps/web/app/(dashboard)/loading.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { MyIssuesPage } from "@/features/my-issues";
|
||||
import { MyIssuesPage } from "@multica/views/my-issues";
|
||||
|
||||
export default function Page() {
|
||||
return <MyIssuesPage />;
|
||||
|
||||
13
apps/web/app/(dashboard)/projects/[id]/page.tsx
Normal file
13
apps/web/app/(dashboard)/projects/[id]/page.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
"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} />;
|
||||
}
|
||||
7
apps/web/app/(dashboard)/projects/page.tsx
Normal file
7
apps/web/app/(dashboard)/projects/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { ProjectsPage } from "@multica/views/projects/components";
|
||||
|
||||
export default function Page() {
|
||||
return <ProjectsPage />;
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
export { RuntimesPage as default } from "@/features/runtimes";
|
||||
export { RuntimesPage as default } from "@multica/views/runtimes";
|
||||
|
||||
@@ -1,68 +1 @@
|
||||
"use client";
|
||||
|
||||
import { User, Palette, Key, Settings, Users } from "lucide-react";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||
import { useWorkspaceStore } from "@/features/workspace";
|
||||
import { AccountTab } from "./_components/account-tab";
|
||||
import { AppearanceTab } from "./_components/general-tab";
|
||||
import { TokensTab } from "./_components/tokens-tab";
|
||||
import { WorkspaceTab } from "./_components/workspace-tab";
|
||||
import { MembersTab } from "./_components/members-tab";
|
||||
|
||||
const accountTabs = [
|
||||
{ value: "profile", label: "Profile", icon: User },
|
||||
{ value: "appearance", label: "Appearance", icon: Palette },
|
||||
{ value: "tokens", label: "API Tokens", icon: Key },
|
||||
];
|
||||
|
||||
const workspaceTabs = [
|
||||
{ value: "workspace", label: "General", icon: Settings },
|
||||
{ value: "members", label: "Members", icon: Users },
|
||||
];
|
||||
|
||||
export default function SettingsPage() {
|
||||
const workspaceName = useWorkspaceStore((s) => s.workspace?.name);
|
||||
|
||||
return (
|
||||
<Tabs defaultValue="profile" orientation="vertical" className="flex-1 min-h-0 gap-0">
|
||||
{/* Left nav */}
|
||||
<div className="w-52 shrink-0 border-r overflow-y-auto p-4">
|
||||
<h1 className="text-sm font-semibold mb-4 px-2">Settings</h1>
|
||||
<TabsList variant="line" className="flex-col items-stretch">
|
||||
{/* My Account group */}
|
||||
<span className="px-2 pb-1 pt-2 text-xs font-medium text-muted-foreground">
|
||||
My Account
|
||||
</span>
|
||||
{accountTabs.map((tab) => (
|
||||
<TabsTrigger key={tab.value} value={tab.value}>
|
||||
<tab.icon className="h-4 w-4" />
|
||||
{tab.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
|
||||
{/* Workspace group */}
|
||||
<span className="px-2 pb-1 pt-4 text-xs font-medium text-muted-foreground truncate">
|
||||
{workspaceName ?? "Workspace"}
|
||||
</span>
|
||||
{workspaceTabs.map((tab) => (
|
||||
<TabsTrigger key={tab.value} value={tab.value}>
|
||||
<tab.icon className="h-4 w-4" />
|
||||
{tab.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
{/* Right content */}
|
||||
<div className="flex-1 min-w-0 overflow-y-auto">
|
||||
<div className="w-full max-w-3xl mx-auto p-6">
|
||||
<TabsContent value="profile"><AccountTab /></TabsContent>
|
||||
<TabsContent value="appearance"><AppearanceTab /></TabsContent>
|
||||
<TabsContent value="tokens"><TokensTab /></TabsContent>
|
||||
<TabsContent value="workspace"><WorkspaceTab /></TabsContent>
|
||||
<TabsContent value="members"><MembersTab /></TabsContent>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
export { SettingsPage as default } from "@multica/views/settings";
|
||||
|
||||
@@ -1 +1 @@
|
||||
export { SkillsPage as default } from "@/features/skills";
|
||||
export { SkillsPage as default } from "@multica/views/skills";
|
||||
|
||||
90
apps/web/app/auth/callback/page.tsx
Normal file
90
apps/web/app/auth/callback/page.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, useEffect, useState } from "react";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { useWorkspaceStore } from "@multica/core/workspace";
|
||||
import { api } from "@multica/core/api";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
} from "@multica/ui/components/ui/card";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
function CallbackContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const loginWithGoogle = useAuthStore((s) => s.loginWithGoogle);
|
||||
const hydrateWorkspace = useWorkspaceStore((s) => s.hydrateWorkspace);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const code = searchParams.get("code");
|
||||
if (!code) {
|
||||
setError("Missing authorization code");
|
||||
return;
|
||||
}
|
||||
|
||||
const errorParam = searchParams.get("error");
|
||||
if (errorParam) {
|
||||
setError(errorParam === "access_denied" ? "Access denied" : errorParam);
|
||||
return;
|
||||
}
|
||||
|
||||
const redirectUri = `${window.location.origin}/auth/callback`;
|
||||
|
||||
loginWithGoogle(code, redirectUri)
|
||||
.then(async () => {
|
||||
const wsList = await api.listWorkspaces();
|
||||
const lastWsId = localStorage.getItem("multica_workspace_id");
|
||||
await hydrateWorkspace(wsList, lastWsId);
|
||||
router.push("/issues");
|
||||
})
|
||||
.catch((err) => {
|
||||
setError(err instanceof Error ? err.message : "Login failed");
|
||||
});
|
||||
}, [searchParams, loginWithGoogle, hydrateWorkspace, router]);
|
||||
|
||||
if (error) {
|
||||
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">Login Failed</CardTitle>
|
||||
<CardDescription>{error}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex justify-center">
|
||||
<a href="/login" className="text-primary underline-offset-4 hover:underline">
|
||||
Back to login
|
||||
</a>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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">Signing in...</CardTitle>
|
||||
<CardDescription>Please wait while we complete your login</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex justify-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CallbackPage() {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<CallbackContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -1,32 +1,5 @@
|
||||
/* =============================================================================
|
||||
* Multica Web — Custom styles (non-shadcn)
|
||||
* Multica Web — Custom styles (non-shadcn, web-only)
|
||||
* Shared styles (shiki, entrance-spin, sidebar, sonner, scrollbar) are in
|
||||
* @multica/ui/styles/base.css
|
||||
* ============================================================================= */
|
||||
|
||||
/* Shiki dual themes: CSS-only light/dark switching via CSS variables */
|
||||
/* @see https://shiki.style/guide/dual-themes */
|
||||
.shiki,
|
||||
.shiki span {
|
||||
color: var(--shiki-light);
|
||||
}
|
||||
|
||||
.dark .shiki,
|
||||
.dark .shiki span {
|
||||
color: var(--shiki-dark) !important;
|
||||
}
|
||||
|
||||
/* Multica icon: entrance spin animation */
|
||||
@keyframes entrance-spin {
|
||||
0% { transform: rotate(0deg); opacity: 0; }
|
||||
50% { opacity: 1; }
|
||||
100% { transform: rotate(360deg); opacity: 1; }
|
||||
}
|
||||
|
||||
.animate-entrance-spin {
|
||||
animation: entrance-spin 0.6s ease-out forwards;
|
||||
}
|
||||
|
||||
/* Sidebar: open triggers (dropdown/popover) get active background */
|
||||
[data-sidebar="menu-button"][data-popup-open] {
|
||||
background-color: var(--sidebar-accent);
|
||||
color: var(--sidebar-accent-foreground);
|
||||
}
|
||||
|
||||
@@ -1,164 +1,12 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
@import "../../../packages/ui/styles/tokens.css";
|
||||
@import "../../../packages/ui/styles/base.css";
|
||||
@import "./custom.css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--font-heading: var(--font-sans);
|
||||
--font-sans: var(--font-sans);
|
||||
--font-mono: var(--font-mono);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-success: var(--success);
|
||||
--color-warning: var(--warning);
|
||||
--color-info: var(--info);
|
||||
--color-brand: var(--brand);
|
||||
--color-brand-foreground: var(--brand-foreground);
|
||||
--color-priority: var(--priority);
|
||||
--color-canvas: var(--canvas);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-background: var(--background);
|
||||
--radius-sm: calc(var(--radius) * 0.6);
|
||||
--radius-md: calc(var(--radius) * 0.8);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) * 1.4);
|
||||
--radius-2xl: calc(var(--radius) * 1.8);
|
||||
--radius-3xl: calc(var(--radius) * 2.2);
|
||||
--radius-4xl: calc(var(--radius) * 2.6);
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.141 0.005 285.823);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.141 0.005 285.823);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.141 0.005 285.823);
|
||||
--primary: oklch(0.21 0.006 285.885);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.967 0.001 286.375);
|
||||
--secondary-foreground: oklch(0.21 0.006 285.885);
|
||||
--muted: oklch(0.967 0.001 286.375);
|
||||
--muted-foreground: oklch(0.552 0.016 285.938);
|
||||
--accent: oklch(0.967 0.001 286.375);
|
||||
--accent-foreground: oklch(0.21 0.006 285.885);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.92 0.004 286.32);
|
||||
--input: oklch(0.92 0.004 286.32);
|
||||
--ring: oklch(0.705 0.015 286.067);
|
||||
--chart-1: oklch(0.871 0.006 286.286);
|
||||
--chart-2: oklch(0.552 0.016 285.938);
|
||||
--chart-3: oklch(0.442 0.017 285.786);
|
||||
--chart-4: oklch(0.37 0.013 285.805);
|
||||
--chart-5: oklch(0.274 0.006 286.033);
|
||||
--radius: 0.625rem;
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.141 0.005 285.823);
|
||||
--sidebar-primary: oklch(0.21 0.006 285.885);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.95 0.002 286.375);
|
||||
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
|
||||
--sidebar-border: oklch(0.92 0.004 286.32);
|
||||
--sidebar-ring: oklch(0.705 0.015 286.067);
|
||||
--brand: oklch(0.55 0.16 255);
|
||||
--brand-foreground: oklch(0.985 0 0);
|
||||
--canvas: oklch(0.95 0.002 286);
|
||||
--success: oklch(0.55 0.16 145);
|
||||
--warning: oklch(0.75 0.16 85);
|
||||
--info: oklch(0.55 0.18 250);
|
||||
--priority: oklch(0.65 0.18 50);
|
||||
--scrollbar-thumb: oklch(0 0 0 / 10%);
|
||||
--scrollbar-thumb-hover: oklch(0 0 0 / 18%);
|
||||
--scrollbar-track: transparent;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.18 0.005 285.823);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.21 0.006 285.885);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.21 0.006 285.885);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.92 0.004 286.32);
|
||||
--primary-foreground: oklch(0.21 0.006 285.885);
|
||||
--secondary: oklch(0.274 0.006 286.033);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.274 0.006 286.033);
|
||||
--muted-foreground: oklch(0.705 0.015 286.067);
|
||||
--accent: oklch(0.274 0.006 286.033);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.552 0.016 285.938);
|
||||
--chart-1: oklch(0.871 0.006 286.286);
|
||||
--chart-2: oklch(0.552 0.016 285.938);
|
||||
--chart-3: oklch(0.442 0.017 285.786);
|
||||
--chart-4: oklch(0.37 0.013 285.805);
|
||||
--chart-5: oklch(0.274 0.006 286.033);
|
||||
--sidebar: oklch(0.21 0.006 285.885);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.274 0.006 286.033);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.552 0.016 285.938);
|
||||
--brand: oklch(0.65 0.16 255);
|
||||
--brand-foreground: oklch(0.985 0 0);
|
||||
--canvas: oklch(0.2 0.005 286);
|
||||
--success: oklch(0.65 0.15 145);
|
||||
--warning: oklch(0.70 0.16 85);
|
||||
--info: oklch(0.65 0.18 250);
|
||||
--priority: oklch(0.70 0.18 50);
|
||||
--scrollbar-thumb: oklch(1 0 0 / 8%);
|
||||
--scrollbar-thumb-hover: oklch(1 0 0 / 18%);
|
||||
--scrollbar-track: transparent;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
|
||||
}
|
||||
*::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
*::-webkit-scrollbar-track { background: var(--scrollbar-track); }
|
||||
*::-webkit-scrollbar-thumb { background: var(--scrollbar-thumb); border-radius: 3px; }
|
||||
*::-webkit-scrollbar-thumb:hover { background: var(--scrollbar-thumb-hover); }
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
html {
|
||||
@apply font-sans;
|
||||
}
|
||||
}
|
||||
@source "../../../packages/ui/**/*.{ts,tsx}";
|
||||
@source "../../../packages/core/**/*.{ts,tsx}";
|
||||
@source "../../../packages/views/**/*.{ts,tsx}";
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { cookies } from "next/headers";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AuthInitializer } from "@/features/auth";
|
||||
import { WSProvider } from "@/features/realtime";
|
||||
import { ModalRegistry } from "@/features/modals";
|
||||
import { Toaster } from "@multica/ui/components/ui/sonner";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { WebProviders } from "@/components/web-providers";
|
||||
import { LocaleSync } from "@/components/locale-sync";
|
||||
import "./globals.css";
|
||||
|
||||
const geist = Geist({ subsets: ["latin"], variable: "--font-sans" });
|
||||
@@ -40,30 +38,35 @@ export const metadata: Metadata = {
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
site: "@multica_hq",
|
||||
creator: "@multica_hq",
|
||||
},
|
||||
alternates: {
|
||||
canonical: "/",
|
||||
},
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
},
|
||||
};
|
||||
|
||||
export default async function RootLayout({
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const cookieStore = await cookies();
|
||||
const locale = cookieStore.get("multica-locale")?.value;
|
||||
const lang = locale === "zh" ? "zh" : "en";
|
||||
|
||||
return (
|
||||
<html
|
||||
lang={lang}
|
||||
lang="en"
|
||||
suppressHydrationWarning
|
||||
className={cn("antialiased font-sans h-full", geist.variable, geistMono.variable)}
|
||||
>
|
||||
<body className="h-full overflow-hidden">
|
||||
<LocaleSync />
|
||||
<ThemeProvider>
|
||||
<AuthInitializer>
|
||||
<WSProvider>{children}</WSProvider>
|
||||
</AuthInitializer>
|
||||
<ModalRegistry />
|
||||
<WebProviders>
|
||||
{children}
|
||||
</WebProviders>
|
||||
<Toaster />
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
|
||||
@@ -13,11 +13,11 @@
|
||||
"iconLibrary": "lucide",
|
||||
"rtl": false,
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
"components": "@multica/ui/components",
|
||||
"utils": "@multica/ui/lib/utils",
|
||||
"ui": "@multica/ui/components/ui",
|
||||
"lib": "@multica/ui/lib",
|
||||
"hooks": "@multica/ui/hooks"
|
||||
},
|
||||
"menuColor": "default",
|
||||
"menuAccent": "subtle",
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { Users } from "lucide-react";
|
||||
import { HoverCard, HoverCardTrigger, HoverCardContent } from "@/components/ui/hover-card";
|
||||
import { ActorAvatar } from "@/components/common/actor-avatar";
|
||||
import { useWorkspaceStore } from "@/features/workspace";
|
||||
|
||||
interface MentionHoverCardProps {
|
||||
type: string;
|
||||
id: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function MentionHoverCard({ type, id, children }: MentionHoverCardProps) {
|
||||
const members = useWorkspaceStore((s) => s.members);
|
||||
const agents = useWorkspaceStore((s) => s.agents);
|
||||
|
||||
if (type === "all") {
|
||||
return (
|
||||
<HoverCard>
|
||||
<HoverCardTrigger render={<span />} className="cursor-default">
|
||||
{children}
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent align="start" className="w-auto min-w-48 max-w-72">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary/10">
|
||||
<Users className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium">All members</p>
|
||||
<p className="text-xs text-muted-foreground">Notifies all workspace members</p>
|
||||
</div>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "member") {
|
||||
const member = members.find((m) => m.user_id === id);
|
||||
if (!member) return <>{children}</>;
|
||||
|
||||
return (
|
||||
<HoverCard>
|
||||
<HoverCardTrigger render={<span />} className="cursor-default">
|
||||
{children}
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent align="start" className="w-auto min-w-48 max-w-72">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<ActorAvatar actorType="member" actorId={id} size={32} />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">{member.name}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">{member.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "agent") {
|
||||
const agent = agents.find((a) => a.id === id);
|
||||
if (!agent) return <>{children}</>;
|
||||
|
||||
return (
|
||||
<HoverCard>
|
||||
<HoverCardTrigger render={<span />} className="cursor-default">
|
||||
{children}
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent align="start" className="w-auto min-w-48 max-w-72">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<ActorAvatar actorType="agent" actorId={id} size={32} />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">{agent.name}</p>
|
||||
{agent.description && (
|
||||
<p className="text-xs text-muted-foreground truncate">{agent.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
export { MentionHoverCard };
|
||||
@@ -1,227 +0,0 @@
|
||||
/* Rich text editor: ProseMirror styles using shadcn design tokens */
|
||||
|
||||
.rich-text-editor.ProseMirror {
|
||||
color: var(--foreground);
|
||||
caret-color: var(--foreground);
|
||||
}
|
||||
|
||||
.rich-text-editor.ProseMirror:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Placeholder */
|
||||
.rich-text-editor .is-editor-empty:first-child::before {
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
color: var(--muted-foreground);
|
||||
pointer-events: none;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
/* Headings */
|
||||
.rich-text-editor h1 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 700;
|
||||
margin-top: 1.25rem;
|
||||
margin-bottom: 0.5rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.rich-text-editor h2 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.rich-text-editor h3 {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
margin-top: 0.75rem;
|
||||
margin-bottom: 0.25rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* Paragraphs */
|
||||
.rich-text-editor p {
|
||||
margin-top: 0.375rem;
|
||||
margin-bottom: 0.375rem;
|
||||
line-height: 1.625;
|
||||
}
|
||||
|
||||
/* First child should not have top margin */
|
||||
.rich-text-editor > *:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* Last child should not have bottom margin */
|
||||
.rich-text-editor > *:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Lists */
|
||||
.rich-text-editor ul {
|
||||
list-style-type: disc;
|
||||
padding-inline-start: 1.25rem;
|
||||
margin: 0.375rem 0;
|
||||
}
|
||||
|
||||
.rich-text-editor ol {
|
||||
list-style-type: decimal;
|
||||
padding-inline-start: 1.25rem;
|
||||
margin: 0.375rem 0;
|
||||
}
|
||||
|
||||
.rich-text-editor li {
|
||||
margin: 0.125rem 0;
|
||||
line-height: 1.625;
|
||||
}
|
||||
|
||||
.rich-text-editor li::marker {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
/* Inline code */
|
||||
.rich-text-editor code {
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: 0.8em;
|
||||
background: var(--muted);
|
||||
color: var(--foreground);
|
||||
padding: 0.15em 0.35em;
|
||||
border-radius: calc(var(--radius) * 0.6);
|
||||
}
|
||||
|
||||
/* Code blocks */
|
||||
.rich-text-editor pre {
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
background: var(--muted);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.75rem 1rem;
|
||||
margin: 0.5rem 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.rich-text-editor pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Syntax highlighting — lowlight (hljs) */
|
||||
.rich-text-editor .hljs-keyword,
|
||||
.rich-text-editor .hljs-selector-tag,
|
||||
.rich-text-editor .hljs-built_in { color: oklch(0.55 0.16 255); }
|
||||
|
||||
.rich-text-editor .hljs-string,
|
||||
.rich-text-editor .hljs-addition { color: oklch(0.55 0.14 155); }
|
||||
|
||||
.rich-text-editor .hljs-comment,
|
||||
.rich-text-editor .hljs-quote { color: var(--muted-foreground); font-style: italic; }
|
||||
|
||||
.rich-text-editor .hljs-number,
|
||||
.rich-text-editor .hljs-literal { color: oklch(0.58 0.16 30); }
|
||||
|
||||
.rich-text-editor .hljs-title,
|
||||
.rich-text-editor .hljs-section,
|
||||
.rich-text-editor .hljs-title\.function_ { color: oklch(0.55 0.14 280); }
|
||||
|
||||
.rich-text-editor .hljs-attr,
|
||||
.rich-text-editor .hljs-attribute { color: oklch(0.58 0.12 60); }
|
||||
|
||||
.rich-text-editor .hljs-variable,
|
||||
.rich-text-editor .hljs-template-variable { color: oklch(0.58 0.14 20); }
|
||||
|
||||
.rich-text-editor .hljs-type,
|
||||
.rich-text-editor .hljs-title\.class_ { color: oklch(0.55 0.14 200); }
|
||||
|
||||
.rich-text-editor .hljs-deletion { color: oklch(0.55 0.2 25); }
|
||||
|
||||
.rich-text-editor .hljs-meta { color: var(--muted-foreground); }
|
||||
|
||||
/* Dark mode overrides */
|
||||
.dark .rich-text-editor .hljs-keyword,
|
||||
.dark .rich-text-editor .hljs-selector-tag,
|
||||
.dark .rich-text-editor .hljs-built_in { color: oklch(0.7 0.14 255); }
|
||||
|
||||
.dark .rich-text-editor .hljs-string,
|
||||
.dark .rich-text-editor .hljs-addition { color: oklch(0.7 0.14 155); }
|
||||
|
||||
.dark .rich-text-editor .hljs-number,
|
||||
.dark .rich-text-editor .hljs-literal { color: oklch(0.72 0.14 30); }
|
||||
|
||||
.dark .rich-text-editor .hljs-title,
|
||||
.dark .rich-text-editor .hljs-section,
|
||||
.dark .rich-text-editor .hljs-title\.function_ { color: oklch(0.72 0.12 280); }
|
||||
|
||||
.dark .rich-text-editor .hljs-attr,
|
||||
.dark .rich-text-editor .hljs-attribute { color: oklch(0.72 0.1 60); }
|
||||
|
||||
.dark .rich-text-editor .hljs-variable,
|
||||
.dark .rich-text-editor .hljs-template-variable { color: oklch(0.72 0.12 20); }
|
||||
|
||||
.dark .rich-text-editor .hljs-type,
|
||||
.dark .rich-text-editor .hljs-title\.class_ { color: oklch(0.72 0.12 200); }
|
||||
|
||||
.dark .rich-text-editor .hljs-deletion { color: oklch(0.7 0.18 25); }
|
||||
|
||||
/* Blockquotes */
|
||||
.rich-text-editor blockquote {
|
||||
border-left: 2px solid var(--border);
|
||||
padding-left: 0.75rem;
|
||||
margin: 0.5rem 0;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
/* Horizontal rules */
|
||||
.rich-text-editor hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--border);
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
/* Links */
|
||||
.rich-text-editor a {
|
||||
color: var(--brand);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.rich-text-editor a:hover {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Mentions */
|
||||
.rich-text-editor .mention {
|
||||
color: var(--primary);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
margin: 0 0.125rem;
|
||||
}
|
||||
|
||||
/* Strong / emphasis */
|
||||
.rich-text-editor strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.rich-text-editor em {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.rich-text-editor s {
|
||||
text-decoration: line-through;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
/* Uploading image placeholder (blob: URLs = in-flight uploads) */
|
||||
.rich-text-editor img[src^="blob:"] {
|
||||
opacity: 0.5;
|
||||
border-radius: var(--radius);
|
||||
animation: rte-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes rte-pulse {
|
||||
0%, 100% { opacity: 0.5; }
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
@@ -1,460 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
forwardRef,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
} from "react";
|
||||
import { useEditor, EditorContent, ReactNodeViewRenderer } from "@tiptap/react";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight";
|
||||
import { common, createLowlight } from "lowlight";
|
||||
import Placeholder from "@tiptap/extension-placeholder";
|
||||
import Link from "@tiptap/extension-link";
|
||||
import Typography from "@tiptap/extension-typography";
|
||||
import Mention from "@tiptap/extension-mention";
|
||||
import Image from "@tiptap/extension-image";
|
||||
import { Markdown } from "@tiptap/markdown";
|
||||
import { Extension, mergeAttributes } from "@tiptap/core";
|
||||
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
||||
import { Slice } from "@tiptap/pm/model";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { UploadResult } from "@/shared/hooks/use-file-upload";
|
||||
import { createMentionSuggestion } from "./mention-suggestion";
|
||||
import { CodeBlockView } from "./code-block-view";
|
||||
import "./rich-text-editor.css";
|
||||
|
||||
const lowlight = createLowlight(common);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface RichTextEditorProps {
|
||||
defaultValue?: string;
|
||||
onUpdate?: (markdown: string) => void;
|
||||
placeholder?: string;
|
||||
editable?: boolean;
|
||||
className?: string;
|
||||
debounceMs?: number;
|
||||
onSubmit?: () => void;
|
||||
onBlur?: () => void;
|
||||
onUploadFile?: (file: File) => Promise<UploadResult | null>;
|
||||
}
|
||||
|
||||
interface RichTextEditorRef {
|
||||
getMarkdown: () => string;
|
||||
clearContent: () => void;
|
||||
focus: () => void;
|
||||
insertFile: (filename: string, url: string, isImage: boolean) => void;
|
||||
}
|
||||
|
||||
const LinkExtension = Link.configure({
|
||||
openOnClick: true,
|
||||
autolink: true,
|
||||
HTMLAttributes: {
|
||||
class: "text-primary hover:underline cursor-pointer",
|
||||
},
|
||||
});
|
||||
|
||||
const MentionExtension = Mention.configure({
|
||||
HTMLAttributes: { class: "mention" },
|
||||
suggestion: createMentionSuggestion(),
|
||||
}).extend({
|
||||
renderHTML({ node, HTMLAttributes }) {
|
||||
return [
|
||||
"span",
|
||||
mergeAttributes(
|
||||
{ "data-type": "mention" },
|
||||
this.options.HTMLAttributes,
|
||||
HTMLAttributes,
|
||||
{
|
||||
"data-mention-type": node.attrs.type ?? "member",
|
||||
"data-mention-id": node.attrs.id,
|
||||
},
|
||||
),
|
||||
`@${node.attrs.label ?? node.attrs.id}`,
|
||||
];
|
||||
},
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
type: {
|
||||
default: "member",
|
||||
parseHTML: (el: HTMLElement) =>
|
||||
el.getAttribute("data-mention-type") ?? "member",
|
||||
renderHTML: () => ({}),
|
||||
},
|
||||
};
|
||||
},
|
||||
// @tiptap/markdown: custom tokenizer to parse [@Label](mention://type/id)
|
||||
markdownTokenizer: {
|
||||
name: "mention",
|
||||
level: "inline" as const,
|
||||
start(src: string) {
|
||||
return src.search(/\[@[^\]]+\]\(mention:\/\//);
|
||||
},
|
||||
tokenize(src: string) {
|
||||
const match = src.match(
|
||||
/^\[@([^\]]+)\]\(mention:\/\/(\w+)\/([^)]+)\)/,
|
||||
);
|
||||
if (!match) return undefined;
|
||||
return {
|
||||
type: "mention",
|
||||
raw: match[0],
|
||||
attributes: { label: match[1], type: match[2] ?? "member", id: match[3] },
|
||||
};
|
||||
},
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
parseMarkdown: (token: any, helpers: any) => {
|
||||
return helpers.createNode("mention", token.attributes);
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
renderMarkdown: (node: any) => {
|
||||
const { id, label, type = "member" } = node.attrs || {};
|
||||
return `[@${label ?? id}](mention://${type}/${id})`;
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Submit shortcut extension (Mod+Enter)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createSubmitExtension(onSubmit: () => void) {
|
||||
return Extension.create({
|
||||
name: "submitShortcut",
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
"Mod-Enter": () => {
|
||||
onSubmit();
|
||||
return true;
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Markdown paste extension — parse pasted markdown text as rich text
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createMarkdownPasteExtension() {
|
||||
return Extension.create({
|
||||
name: "markdownPaste",
|
||||
addProseMirrorPlugins() {
|
||||
const { editor } = this;
|
||||
return [
|
||||
new Plugin({
|
||||
key: new PluginKey("markdownPaste"),
|
||||
props: {
|
||||
clipboardTextParser(text, _context, plainText) {
|
||||
if (!plainText && editor.markdown) {
|
||||
const json = editor.markdown.parse(text);
|
||||
const node = editor.schema.nodeFromJSON(json);
|
||||
return Slice.maxOpen(node.content);
|
||||
}
|
||||
// Plain text fallback
|
||||
const p = editor.schema.nodes.paragraph!;
|
||||
const doc = editor.schema.nodes.doc!;
|
||||
const paragraph = p.create(null, text ? editor.schema.text(text) : undefined);
|
||||
return new Slice(doc.create(null, paragraph).content, 0, 0);
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// File upload extension (paste + drop) with blob URL instant preview
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function removeImageBySrc(editor: ReturnType<typeof useEditor>, src: string) {
|
||||
if (!editor) return;
|
||||
const { tr } = editor.state;
|
||||
let deleted = false;
|
||||
editor.state.doc.descendants((node, pos) => {
|
||||
if (deleted) return false;
|
||||
if (node.type.name === "image" && node.attrs.src === src) {
|
||||
tr.delete(pos, pos + node.nodeSize);
|
||||
deleted = true;
|
||||
return false;
|
||||
}
|
||||
});
|
||||
if (deleted) editor.view.dispatch(tr);
|
||||
}
|
||||
|
||||
function createFileUploadExtension(
|
||||
onUploadFileRef: React.RefObject<((file: File) => Promise<UploadResult | null>) | undefined>,
|
||||
) {
|
||||
return Extension.create({
|
||||
name: "fileUpload",
|
||||
addProseMirrorPlugins() {
|
||||
const { editor } = this;
|
||||
|
||||
const handleFiles = async (files: FileList, pos?: number) => {
|
||||
const handler = onUploadFileRef.current;
|
||||
if (!handler) return false;
|
||||
|
||||
let handled = false;
|
||||
for (const file of Array.from(files)) {
|
||||
handled = true;
|
||||
const isImage = file.type.startsWith("image/");
|
||||
|
||||
if (isImage) {
|
||||
// Instant preview via blob URL, then replace with real URL after upload
|
||||
const blobUrl = URL.createObjectURL(file);
|
||||
if (pos !== undefined) {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.insertContentAt(pos, {
|
||||
type: "image",
|
||||
attrs: { src: blobUrl, alt: file.name },
|
||||
})
|
||||
.run();
|
||||
} else {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.setImage({ src: blobUrl, alt: file.name })
|
||||
.run();
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await handler(file);
|
||||
if (result) {
|
||||
const { tr } = editor.state;
|
||||
editor.state.doc.descendants((node, nodePos) => {
|
||||
if (
|
||||
node.type.name === "image" &&
|
||||
node.attrs.src === blobUrl
|
||||
) {
|
||||
tr.setNodeMarkup(nodePos, undefined, {
|
||||
...node.attrs,
|
||||
src: result.link,
|
||||
alt: result.filename,
|
||||
});
|
||||
}
|
||||
});
|
||||
editor.view.dispatch(tr);
|
||||
} else {
|
||||
removeImageBySrc(editor, blobUrl);
|
||||
}
|
||||
} catch {
|
||||
removeImageBySrc(editor, blobUrl);
|
||||
} finally {
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
}
|
||||
} else {
|
||||
// Non-image: upload first, then insert link
|
||||
try {
|
||||
const result = await handler(file);
|
||||
if (!result) continue;
|
||||
const linkText = `[${result.filename}](${result.link})`;
|
||||
if (pos !== undefined) {
|
||||
editor.chain().focus().insertContentAt(pos, linkText).run();
|
||||
} else {
|
||||
editor.chain().focus().insertContent(linkText).run();
|
||||
}
|
||||
} catch {
|
||||
// Upload errors handled by the hook/caller via toast
|
||||
}
|
||||
}
|
||||
}
|
||||
return handled;
|
||||
};
|
||||
|
||||
return [
|
||||
new Plugin({
|
||||
key: new PluginKey("fileUpload"),
|
||||
props: {
|
||||
handlePaste(_view, event) {
|
||||
const files = event.clipboardData?.files;
|
||||
if (!files?.length) return false;
|
||||
if (!onUploadFileRef.current) return false;
|
||||
handleFiles(files);
|
||||
return true;
|
||||
},
|
||||
handleDrop(_view, event) {
|
||||
const files = (event as DragEvent).dataTransfer?.files;
|
||||
if (!files?.length) return false;
|
||||
if (!onUploadFileRef.current) return false;
|
||||
handleFiles(files);
|
||||
return true;
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const RichTextEditor = forwardRef<RichTextEditorRef, RichTextEditorProps>(
|
||||
function RichTextEditor(
|
||||
{
|
||||
defaultValue = "",
|
||||
onUpdate,
|
||||
placeholder: placeholderText = "",
|
||||
editable = true,
|
||||
className,
|
||||
debounceMs = 300,
|
||||
onSubmit,
|
||||
onBlur,
|
||||
onUploadFile,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
const onUpdateRef = useRef(onUpdate);
|
||||
const onSubmitRef = useRef(onSubmit);
|
||||
const onBlurRef = useRef(onBlur);
|
||||
const onUploadFileRef = useRef(onUploadFile);
|
||||
|
||||
// Helper to get markdown from @tiptap/markdown extension.
|
||||
// Post-processes mention shortcodes [@ id="..." label="..."] → markdown
|
||||
// links, using the Tiptap JSON doc for type info, in case the
|
||||
// renderMarkdown override doesn't take effect.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const getEditorMarkdown = (ed: any): string => {
|
||||
const md: string = ed?.getMarkdown?.() ?? "";
|
||||
if (!md || !md.includes("[@ ")) return md;
|
||||
|
||||
// Build type map from editor JSON (which always has the type attr)
|
||||
const json = ed?.getJSON?.();
|
||||
const typeMap = new Map<string, string>();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function walk(node: any) {
|
||||
if (node?.type === "mention" && node.attrs?.id) {
|
||||
typeMap.set(node.attrs.id, node.attrs.type || "member");
|
||||
}
|
||||
if (node?.content) node.content.forEach(walk);
|
||||
}
|
||||
if (json) walk(json);
|
||||
|
||||
return md.replace(
|
||||
/\[@\s+([^\]]*)\]/g,
|
||||
(match: string, attrString: string) => {
|
||||
const attrs: Record<string, string> = {};
|
||||
const re = /(\w+)="([^"]*)"/g;
|
||||
let m;
|
||||
while ((m = re.exec(attrString)) !== null) {
|
||||
if (m[1] && m[2] !== undefined) attrs[m[1]] = m[2];
|
||||
}
|
||||
const { id, label } = attrs;
|
||||
if (!id || !label) return match;
|
||||
const type = typeMap.get(id) || "member";
|
||||
const display = type === "issue" ? label : `@${label}`;
|
||||
return `[${display}](mention://${type}/${id})`;
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
// Keep refs in sync without recreating editor
|
||||
onUpdateRef.current = onUpdate;
|
||||
onSubmitRef.current = onSubmit;
|
||||
onBlurRef.current = onBlur;
|
||||
onUploadFileRef.current = onUploadFile;
|
||||
|
||||
const editor = useEditor({
|
||||
immediatelyRender: false,
|
||||
editable,
|
||||
content: defaultValue || "",
|
||||
contentType: defaultValue ? "markdown" : undefined,
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
heading: { levels: [1, 2, 3] },
|
||||
link: false,
|
||||
codeBlock: false,
|
||||
}),
|
||||
CodeBlockLowlight.extend({
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(CodeBlockView);
|
||||
},
|
||||
}).configure({ lowlight }),
|
||||
Placeholder.configure({
|
||||
placeholder: placeholderText,
|
||||
}),
|
||||
LinkExtension,
|
||||
Typography,
|
||||
MentionExtension,
|
||||
Image.configure({
|
||||
inline: false,
|
||||
allowBase64: false,
|
||||
HTMLAttributes: { style: "max-width: 100%; height: auto;" },
|
||||
}),
|
||||
Markdown,
|
||||
createMarkdownPasteExtension(),
|
||||
createSubmitExtension(() => onSubmitRef.current?.()),
|
||||
createFileUploadExtension(onUploadFileRef),
|
||||
],
|
||||
onUpdate: ({ editor: ed }) => {
|
||||
if (!onUpdateRef.current) return;
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => {
|
||||
onUpdateRef.current?.(ed.getMarkdown());
|
||||
}, debounceMs);
|
||||
},
|
||||
onBlur: () => {
|
||||
onBlurRef.current?.();
|
||||
},
|
||||
editorProps: {
|
||||
handleDOMEvents: {
|
||||
click(_view, event) {
|
||||
if (event.metaKey || event.ctrlKey) {
|
||||
const link = (event.target as HTMLElement).closest("a");
|
||||
const href = link?.getAttribute("href");
|
||||
if (href && !href.startsWith("mention://")) {
|
||||
window.open(href, "_blank", "noopener,noreferrer");
|
||||
event.preventDefault();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
},
|
||||
attributes: {
|
||||
class: cn("rich-text-editor text-sm outline-none", className),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Cleanup debounce on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
getMarkdown: () => editor?.getMarkdown() ?? "",
|
||||
clearContent: () => {
|
||||
editor?.commands.clearContent();
|
||||
},
|
||||
focus: () => {
|
||||
editor?.commands.focus();
|
||||
},
|
||||
insertFile: (filename: string, url: string, isImage: boolean) => {
|
||||
if (!editor) return;
|
||||
if (isImage) {
|
||||
editor.chain().focus().setImage({ src: url, alt: filename }).run();
|
||||
} else {
|
||||
editor.chain().focus().insertContent(`[${filename}](${url})`).run();
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
if (!editor) return null;
|
||||
|
||||
return <EditorContent editor={editor} />;
|
||||
},
|
||||
);
|
||||
|
||||
export { RichTextEditor, type RichTextEditorProps, type RichTextEditorRef };
|
||||
@@ -1,30 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Spinner } from "@/components/spinner";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type LoadingVariant = "generating" | "streaming";
|
||||
|
||||
interface LoadingIndicatorProps {
|
||||
variant: LoadingVariant;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const VARIANT_TEXT: Record<LoadingVariant, string> = {
|
||||
generating: "Generating...",
|
||||
streaming: "Streaming...",
|
||||
};
|
||||
|
||||
/**
|
||||
* Unified loading indicator for chat.
|
||||
* Use "generating" when waiting for AI response (no content yet).
|
||||
* Use "streaming" when content is actively being received.
|
||||
*/
|
||||
export function LoadingIndicator({ variant, className }: LoadingIndicatorProps) {
|
||||
return (
|
||||
<div className={cn("flex items-center gap-2 py-1 text-muted-foreground", className)}>
|
||||
<Spinner className="text-xs" />
|
||||
<span className="text-xs">{VARIANT_TEXT[variant]}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user