mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-22 23:19:17 +02:00
Compare commits
352 Commits
agent/lamb
...
feat/creat
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b7c3d7d94 | ||
|
|
b7ffba4d2f | ||
|
|
8cf27af3b2 | ||
|
|
0ff9e2ba39 | ||
|
|
3916a0ed1d | ||
|
|
b6c369ef17 | ||
|
|
3046f51300 | ||
|
|
d5f18c23cb | ||
|
|
dab9c7cf9b | ||
|
|
68e2a14ba2 | ||
|
|
d7a37f60b5 | ||
|
|
6e475b9521 | ||
|
|
0c4f1027e8 | ||
|
|
a135c44838 | ||
|
|
ec2b48a616 | ||
|
|
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 | ||
|
|
85d00fde57 | ||
|
|
02c9d9f0b0 | ||
|
|
05fcf35ab9 | ||
|
|
f315e55cd6 | ||
|
|
b1f7364097 | ||
|
|
8f1526d2bb | ||
|
|
9add49b832 | ||
|
|
b28bac6bb7 | ||
|
|
59ebf30cf0 | ||
|
|
b41536467d | ||
|
|
40d29bea50 | ||
|
|
4f0c2afcb7 | ||
|
|
eb5aaf003c | ||
|
|
04da4bc3b7 | ||
|
|
6b9341f7ad | ||
|
|
095b7f8185 | ||
|
|
e68091e4a8 | ||
|
|
8815d27e1d | ||
|
|
71dec10201 | ||
|
|
d602873a5c | ||
|
|
9bcc35bf61 | ||
|
|
ae1c05af60 | ||
|
|
2b1b588187 | ||
|
|
75b25539ab | ||
|
|
815f16ddd6 | ||
|
|
8719e2cedd | ||
|
|
808a0e9c38 | ||
|
|
f1e5bc7925 | ||
|
|
20a2332c94 | ||
|
|
9c249f0770 | ||
|
|
8db970cd17 | ||
|
|
a18a24f904 | ||
|
|
421e0a2429 | ||
|
|
09376dc879 | ||
|
|
4d74091f8d | ||
|
|
fb8829fc12 | ||
|
|
3c5a3b5e6a | ||
|
|
3bd504fe5f | ||
|
|
39c5cf2cbe | ||
|
|
005025b05c | ||
|
|
7204626f1d | ||
|
|
57cb32f09a | ||
|
|
1e4cd346ef | ||
|
|
f933b5f3a7 | ||
|
|
3e96240cec | ||
|
|
1bc491fc65 | ||
|
|
98e7d27acc | ||
|
|
cc922367be | ||
|
|
c36b6f3e07 | ||
|
|
ab48eafe61 | ||
|
|
5ec4bcd627 | ||
|
|
f891a5bbd7 | ||
|
|
daaa4deaf7 | ||
|
|
fe963d672d | ||
|
|
0d214dc321 | ||
|
|
946b4a277e | ||
|
|
a2eb16995e | ||
|
|
094962d9aa | ||
|
|
9a00fd5146 | ||
|
|
0f2dfdbca8 | ||
|
|
c6e67bd41e | ||
|
|
1a0d8c282a | ||
|
|
c52c6e69c3 | ||
|
|
4780540bd2 | ||
|
|
8218d7e87d | ||
|
|
57e48c1d6b | ||
|
|
afdefa9b65 | ||
|
|
a4d222da46 | ||
|
|
bf0765de26 | ||
|
|
7e2b328e3c | ||
|
|
0b4c6b3910 | ||
|
|
82e45fb0a2 | ||
|
|
c0166e5264 | ||
|
|
e9ce376b96 | ||
|
|
11041052d0 | ||
|
|
99ac6a6736 | ||
|
|
ed3bcaea6b | ||
|
|
449011e60b | ||
|
|
16efcaca43 | ||
|
|
e2453cc040 | ||
|
|
b589d733ca | ||
|
|
62eafe5f99 | ||
|
|
7188fd8f2a | ||
|
|
46ea12e4cf | ||
|
|
365b5b5f0e | ||
|
|
4c3d9ed1a1 | ||
|
|
9a37af4ca1 | ||
|
|
38cad92e7e | ||
|
|
c8ab2e8642 | ||
|
|
68ca281761 | ||
|
|
0431ee2ee0 | ||
|
|
3f612c37f2 | ||
|
|
00af622d0f | ||
|
|
400739f3c9 | ||
|
|
03d2a5546c | ||
|
|
9a6925bc8b | ||
|
|
d0ad32a5b9 | ||
|
|
f2289bd733 | ||
|
|
68217d2967 | ||
|
|
259f2823bc | ||
|
|
c902460eae | ||
|
|
3646ec5a53 | ||
|
|
e2c466ffa1 |
@@ -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=
|
||||
|
||||
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/
|
||||
|
||||
@@ -47,7 +47,7 @@ brews:
|
||||
directory: Formula
|
||||
homepage: "https://github.com/multica-ai/multica"
|
||||
description: "Multica CLI — local agent runtime and management tool for the Multica platform"
|
||||
license: "MIT"
|
||||
license: "Apache-2.0"
|
||||
install: |
|
||||
bin.install "multica"
|
||||
test: |
|
||||
|
||||
278
AGENTS.md
278
AGENTS.md
@@ -1,16 +1,274 @@
|
||||
# Repository Guidelines
|
||||
|
||||
## Project Structure & Module Organization
|
||||
`apps/web/` contains the Next.js 16 frontend: routes live in `app/`, reusable UI in `components/`, feature code in `features/`, test utilities in `test/`, and static assets in `public/`. `server/` contains the Go backend: entry points are in `cmd/{server,multica,migrate}`, application logic lives in `internal/`, migrations are in `migrations/`, and SQL lives under `pkg/db/queries/` with generated sqlc output in `pkg/db/generated/`. `e2e/` holds Playwright coverage. `scripts/` and the root `Makefile` drive local setup and verification.
|
||||
This file provides guidance to AI agents when working with code in this repository.
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
Use `make setup` for first-time setup: it installs dependencies, ensures PostgreSQL is running, and applies migrations. Use `make start` to run backend and frontend together with `.env` or `.env.worktree`. For single-surface work, use `pnpm dev:web` for the frontend and `make dev` for the Go server. Run `pnpm test` for Vitest, `make test` for Go tests, and `make check` for the full pipeline: typecheck, frontend unit tests, Go tests, then Playwright. After changing SQL in `server/pkg/db/queries/*.sql`, run `make sqlc`.
|
||||
## Project Context
|
||||
|
||||
## Coding Style & Naming Conventions
|
||||
TypeScript in `apps/web` uses 2-space indentation, double quotes, and semicolons. Prefer PascalCase for React components, camelCase for hooks and helpers, and colocated test files such as `page.test.tsx`. Go code should stay `gofmt`-clean and use domain-oriented filenames like `issue.go` or `cmd_issue.go`. Do not hand-edit generated code in `server/pkg/db/generated/`.
|
||||
Multica is an AI-native task management platform — like Linear, but with AI agents as first-class citizens.
|
||||
|
||||
## Testing Guidelines
|
||||
Frontend unit tests use Vitest with Testing Library and shared setup from `apps/web/test/`. End-to-end tests live in `e2e/*.spec.ts`; `make check` will start missing services automatically, while direct Playwright runs expect the app to already be running. Backend tests use Go’s standard `*_test.go` pattern. Add or update tests whenever you change handlers, CLI commands, daemon behavior, or SQL-backed flows.
|
||||
- Agents can be assigned issues, create issues, comment, and change status
|
||||
- Supports local (daemon) and cloud agent runtimes
|
||||
- Built for 2-10 person AI-native teams
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
Recent history follows conventional commits with scopes, for example `feat(web): ...`, `fix(cli): ...`, `refactor(daemon): ...`, `test(cli): ...`, and `docs: ...`. Keep PRs focused and include a short description, linked issue or PR number when relevant, screenshots for UI work, and notes for migrations, env changes, or CLI surface changes. Before opening a PR, run `make check` or the relevant frontend/backend subset.
|
||||
## Architecture
|
||||
|
||||
**Go backend + standalone Next.js frontend.**
|
||||
|
||||
- `server/` — Go backend (Chi router, sqlc for DB, gorilla/websocket for real-time)
|
||||
- `apps/web/` — Next.js 16 frontend (App Router) — self-contained, no shared package dependencies
|
||||
- `e2e/` — Playwright end-to-end tests
|
||||
- `scripts/` and root `Makefile` — local setup and verification
|
||||
|
||||
### Web App Structure (`apps/web/`)
|
||||
|
||||
The frontend uses a **feature-based architecture** with four layers:
|
||||
|
||||
```
|
||||
apps/web/
|
||||
├── app/ # Routing layer (thin shells — import from features/)
|
||||
├── features/ # Business logic, organized by domain
|
||||
├── shared/ # Cross-feature utilities (api client, types, logger)
|
||||
├── test/ # Shared test utilities and setup
|
||||
├── public/ # Static assets
|
||||
```
|
||||
|
||||
**`app/`** — Next.js App Router pages. Route files should be thin: import and re-export from `features/`. Layout components and route-specific glue (redirects, auth guards) live here. Shared layout components (e.g. `app-sidebar`) stay in `app/(dashboard)/_components/`.
|
||||
|
||||
**`features/`** — Domain modules, each with its own components, hooks, stores, and config:
|
||||
|
||||
| Feature | Purpose | Exports |
|
||||
|---|---|---|
|
||||
| `features/auth/` | Authentication state | `useAuthStore`, `AuthInitializer` |
|
||||
| `features/workspace/` | Workspace, members, agents | `useWorkspaceStore`, `useActorName` |
|
||||
| `features/issues/` | Issue state, components, config | `useIssueStore`, icons, pickers, status/priority config |
|
||||
| `features/inbox/` | Inbox notification state | `useInboxStore` |
|
||||
| `features/realtime/` | WebSocket connection + sync | `WSProvider`, `useWSEvent`, `useRealtimeSync` |
|
||||
| `features/modals/` | Modal registry and state | Modal store and components |
|
||||
| `features/skills/` | Skill management | Skill components |
|
||||
|
||||
**`shared/`** — Code used across multiple features:
|
||||
- `shared/api/` — `ApiClient` (REST) and `WSClient` (WebSocket) for backend communication, plus the `api` singleton.
|
||||
- `shared/types/` — Domain types (Issue, Agent, Workspace, etc.) and WebSocket event types.
|
||||
- `shared/logger.ts` — Logger utility.
|
||||
|
||||
### State Management
|
||||
|
||||
- **Zustand** for global client state — one store per feature domain (`features/auth/store.ts`, `features/workspace/store.ts`, `features/issues/store.ts`, `features/inbox/store.ts`).
|
||||
- **React Context** only for connection lifecycle (`WSProvider` in `features/realtime/`).
|
||||
- **Local `useState`** for component-scoped UI state (forms, modals, filters).
|
||||
- Do not use React Context for data that can be a zustand store.
|
||||
|
||||
**Store conventions:**
|
||||
- One store per feature domain. Import via `useAuthStore(selector)` or `useWorkspaceStore(selector)`.
|
||||
- Stores must not call `useRouter` or any React hooks — keep navigation in components.
|
||||
- Cross-store reads use `useOtherStore.getState()` inside actions (not hooks).
|
||||
- Dependency direction: `workspace` → `auth`, `realtime` → `auth`, `issues` → `workspace`. Never reverse.
|
||||
|
||||
### Import Aliases
|
||||
|
||||
Use `@/` alias (maps to `apps/web/`):
|
||||
```typescript
|
||||
import { api } from "@/shared/api";
|
||||
import type { Issue } from "@/shared/types";
|
||||
import { useAuthStore } from "@/features/auth";
|
||||
import { useWorkspaceStore } from "@/features/workspace";
|
||||
import { useIssueStore } from "@/features/issues";
|
||||
import { useInboxStore } from "@/features/inbox";
|
||||
import { useWSEvent } from "@/features/realtime";
|
||||
import { StatusIcon } from "@/features/issues/components";
|
||||
```
|
||||
|
||||
Within a feature, use relative imports. Between features or to shared, use `@/`.
|
||||
|
||||
### Data Flow
|
||||
|
||||
```
|
||||
Browser → ApiClient (shared/api) → REST API (Chi handlers) → sqlc queries → PostgreSQL
|
||||
Browser ← WSClient (shared/api) ← WebSocket ← Hub.Broadcast() ← Handlers/TaskService
|
||||
```
|
||||
|
||||
### Backend Structure (`server/`)
|
||||
|
||||
- **Entry points** (`cmd/`): `server` (HTTP API), `multica` (CLI — daemon, agent management, config), `migrate`
|
||||
- **Handlers** (`internal/handler/`): One file per domain (issue, comment, agent, auth, daemon, etc.). Each handler holds `Queries`, `DB`, `Hub`, and `TaskService`.
|
||||
- **Real-time** (`internal/realtime/`): Hub manages WebSocket clients. Server broadcasts events; inbound WS message routing is still TODO.
|
||||
- **Auth** (`internal/auth/` + `internal/middleware/`): JWT (HS256). Middleware sets `X-User-ID` and `X-User-Email` headers. Login creates user on-the-fly if not found.
|
||||
- **Task lifecycle** (`internal/service/task.go`): Orchestrates agent work — enqueue → claim → start → complete/fail. Syncs issue status automatically and broadcasts WS events at each transition.
|
||||
- **Agent SDK** (`pkg/agent/`): Unified `Backend` interface for executing prompts via Claude Code or Codex. Each backend spawns its CLI and streams results via `Session.Messages` + `Session.Result` channels.
|
||||
- **Daemon** (`internal/daemon/`): Local agent runtime — auto-detects available CLIs (claude, codex), registers runtimes, polls for tasks, routes by provider.
|
||||
- **CLI** (`internal/cli/`): Shared helpers for the `multica` CLI — API client, config management, output formatting.
|
||||
- **Events** (`internal/events/`): Internal event bus for decoupled communication between handlers and services.
|
||||
- **Logging** (`internal/logger/`): Structured logging via slog. `LOG_LEVEL` env var controls level (debug, info, warn, error).
|
||||
- **Database**: PostgreSQL with pgvector extension (`pgvector/pgvector:pg17`). sqlc generates Go code from SQL in `pkg/db/queries/` → `pkg/db/generated/`. Migrations in `migrations/`.
|
||||
- **Routes** (`cmd/server/router.go`): Public routes (auth, health, ws) + protected routes (require JWT) + daemon routes (unauthenticated, separate auth model).
|
||||
|
||||
### Multi-tenancy
|
||||
|
||||
All queries filter by `workspace_id`. Membership checks gate access. `X-Workspace-ID` header routes requests to the correct workspace.
|
||||
|
||||
### Agent Assignees
|
||||
|
||||
Assignees are polymorphic — can be a member or an agent. `assignee_type` + `assignee_id` on issues. Agents render with distinct styling (purple background, robot icon).
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# One-click setup & run
|
||||
make setup # First-time: ensure shared DB, create app DB, migrate
|
||||
make start # Start backend + frontend together
|
||||
make stop # Stop app processes for the current checkout
|
||||
make db-down # Stop the shared PostgreSQL container
|
||||
|
||||
# Frontend
|
||||
pnpm install
|
||||
pnpm dev:web # Next.js dev server (port 3000)
|
||||
pnpm build # Build frontend
|
||||
pnpm typecheck # TypeScript check
|
||||
pnpm lint # ESLint via Next.js
|
||||
pnpm test # TS tests (Vitest)
|
||||
|
||||
# Backend (Go)
|
||||
make dev # Run Go server (port 8080)
|
||||
make daemon # Run local daemon
|
||||
make build # Build server + CLI binaries to server/bin/
|
||||
make cli ARGS="..." # Run multica CLI (e.g. make cli ARGS="config")
|
||||
make test # Go tests
|
||||
make sqlc # Regenerate sqlc code after editing SQL in server/pkg/db/queries/
|
||||
make migrate-up # Run database migrations
|
||||
make migrate-down # Rollback migrations
|
||||
|
||||
# Run a single Go test
|
||||
cd server && go test ./internal/handler/ -run TestName
|
||||
|
||||
# Run a single TS test
|
||||
pnpm --filter @multica/web exec vitest run src/path/to/file.test.ts
|
||||
|
||||
# Run a single E2E test (requires backend + frontend running)
|
||||
pnpm exec playwright test e2e/tests/specific-test.spec.ts
|
||||
|
||||
# Infrastructure
|
||||
make db-up # Start shared PostgreSQL (pgvector/pg17 image)
|
||||
make db-down # Stop shared PostgreSQL
|
||||
```
|
||||
|
||||
### CI Requirements
|
||||
|
||||
CI runs on Node 22 and Go 1.24 with a `pgvector/pgvector:pg17` PostgreSQL service. See `.github/workflows/ci.yml`.
|
||||
|
||||
### Worktree Support
|
||||
|
||||
All checkouts share one PostgreSQL container. Isolation is at the database level — each worktree gets its own DB name and unique ports via `.env.worktree`. Main checkouts use `.env`.
|
||||
|
||||
```bash
|
||||
make worktree-env # Generate .env.worktree with unique DB/ports
|
||||
make setup-worktree # Setup using .env.worktree
|
||||
make start-worktree # Start using .env.worktree
|
||||
```
|
||||
|
||||
## Coding Rules
|
||||
|
||||
- TypeScript strict mode is enabled; keep types explicit.
|
||||
- TypeScript in `apps/web` uses 2-space indentation, double quotes, and semicolons.
|
||||
- Prefer PascalCase for React components, camelCase for hooks and helpers, and colocated test files such as `page.test.tsx`.
|
||||
- Go code follows standard Go conventions (gofmt, go vet). Use domain-oriented filenames like `issue.go` or `cmd_issue.go`.
|
||||
- Do not hand-edit generated code in `server/pkg/db/generated/`.
|
||||
- Keep comments in code **English only**.
|
||||
- Prefer existing patterns/components over introducing parallel abstractions.
|
||||
- Unless the user explicitly asks for backwards compatibility, do **not** add compatibility layers, fallback paths, dual-write logic, legacy adapters, or temporary shims.
|
||||
- If a flow or API is being replaced and the product is not yet live, prefer removing the old path instead of preserving both old and new behavior.
|
||||
- Treat compatibility code as a maintenance cost, not a default safety mechanism. Avoid "just in case" branches that make the codebase harder to reason about.
|
||||
- Avoid broad refactors unless required by the task.
|
||||
|
||||
## UI/UX Rules
|
||||
|
||||
- Prefer shadcn components over custom implementations. Install missing components via `npx shadcn add`.
|
||||
- **Feature-specific components** → `features/<domain>/components/` — issue icons, pickers, and other domain-bound UI live inside their feature module.
|
||||
- Use shadcn design tokens for styling (e.g. `bg-primary`, `text-muted-foreground`, `text-destructive`). Avoid hardcoded color values (e.g. `text-red-500`, `bg-gray-100`).
|
||||
- Do not introduce extra state (useState, context, reducers) unless explicitly required by the design. Prefer zustand stores for shared state over React Context.
|
||||
- Pay close attention to **overflow** (truncate long text, scrollable containers), **alignment**, and **spacing** consistency.
|
||||
- When unsure about interaction or state design, ask — the user will provide direction.
|
||||
|
||||
## Testing Rules
|
||||
|
||||
- **TypeScript**: Vitest with Testing Library. Shared test setup lives in `apps/web/test/`. Mock external/third-party dependencies only.
|
||||
- **Go**: Standard `go test`. Tests should create their own fixture data in a test database.
|
||||
- End-to-end tests live in `e2e/*.spec.ts`; `make check` will start missing services automatically, while direct Playwright runs expect the app to already be running.
|
||||
- Add or update tests whenever you change handlers, CLI commands, daemon behavior, or SQL-backed flows.
|
||||
|
||||
## Commit & Pull Request Rules
|
||||
|
||||
- Use atomic commits grouped by logical intent.
|
||||
- Conventional format with scopes:
|
||||
- `feat(web): ...`, `feat(cli): ...`
|
||||
- `fix(web): ...`, `fix(cli): ...`
|
||||
- `refactor(daemon): ...`
|
||||
- `test(cli): ...`
|
||||
- `docs: ...`
|
||||
- `chore(scope): ...`
|
||||
- Keep PRs focused and include a short description, linked issue or PR number when relevant, screenshots for UI work, and notes for migrations, env changes, or CLI surface changes.
|
||||
- Before opening a PR, run `make check` or the relevant frontend/backend subset.
|
||||
|
||||
## Minimum Pre-Push Checks
|
||||
|
||||
```bash
|
||||
make check # Runs all checks: typecheck, unit tests, Go tests, E2E
|
||||
```
|
||||
|
||||
Run verification only when the user explicitly asks for it.
|
||||
|
||||
For targeted checks when requested:
|
||||
```bash
|
||||
pnpm typecheck # TypeScript type errors only
|
||||
pnpm test # TS unit tests only (Vitest)
|
||||
make test # Go tests only
|
||||
pnpm exec playwright test # E2E only (requires backend + frontend running)
|
||||
```
|
||||
|
||||
## AI Agent Verification Loop
|
||||
|
||||
After writing or modifying code, always run the full verification pipeline:
|
||||
|
||||
```bash
|
||||
make check
|
||||
```
|
||||
|
||||
This runs all checks in sequence:
|
||||
1. TypeScript typecheck (`pnpm typecheck`)
|
||||
2. TypeScript unit tests (`pnpm test`)
|
||||
3. Go tests (`go test ./...`)
|
||||
4. E2E tests (auto-starts backend + frontend if needed, runs Playwright)
|
||||
|
||||
**Workflow:**
|
||||
- Write code to satisfy the requirement
|
||||
- Run `make check`
|
||||
- If any step fails, read the error output, fix the code, and re-run `make check`
|
||||
- Repeat until all checks pass
|
||||
- Only then consider the task complete
|
||||
|
||||
**Quick iteration:** If you know only TypeScript or Go is affected, run individual checks first for faster feedback, then finish with a full `make check` before marking work complete.
|
||||
|
||||
## E2E Test Patterns
|
||||
|
||||
E2E tests should be self-contained. Use the `TestApiClient` fixture for data setup/teardown:
|
||||
|
||||
```typescript
|
||||
import { loginAsDefault, createTestApi } from "./helpers";
|
||||
import type { TestApiClient } from "./fixtures";
|
||||
|
||||
let api: TestApiClient;
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
api = await createTestApi(); // logged-in API client
|
||||
await loginAsDefault(page); // browser session
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
await api.cleanup(); // delete any data created during the test
|
||||
});
|
||||
|
||||
test("example", async ({ page }) => {
|
||||
const issue = await api.createIssue("Test Issue"); // create via API
|
||||
await page.goto(`/issues/${issue.id}`); // test via UI
|
||||
// api.cleanup() in afterEach removes the issue
|
||||
});
|
||||
```
|
||||
|
||||
213
CLAUDE.md
213
CLAUDE.md
@@ -12,77 +12,162 @@ 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 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 16 frontend (App Router)
|
||||
- `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)
|
||||
- `packages/tsconfig/` — Shared TypeScript configuration
|
||||
|
||||
### Web App Structure (`apps/web/`)
|
||||
### Package Architecture
|
||||
|
||||
The frontend uses a **feature-based architecture** with four layers:
|
||||
Three shared packages with single-direction dependencies:
|
||||
|
||||
```
|
||||
packages/
|
||||
├── core/ # @multica/core — types, API client, stores, queries, mutations, realtime
|
||||
├── ui/ # @multica/ui — 55 shadcn components, common components, markdown, hooks
|
||||
├── views/ # @multica/views — issue pages, editor, modals, skills, runtimes, navigation
|
||||
└── tsconfig/ # @multica/tsconfig — shared TS base configs
|
||||
```
|
||||
|
||||
**Dependency direction:** `views/ → core/ + ui/`. Core and UI are independent of each other. No package imports from `next/*` or `apps/web/`.
|
||||
|
||||
**Platform bridge:** `apps/web/platform/` is the only place that touches `process.env`, `next/navigation`, and creates store/api singletons. Each future app (desktop, mobile) provides its own platform layer.
|
||||
|
||||
### packages/core/ (`@multica/core`)
|
||||
|
||||
Headless business logic. **Zero react-dom, zero localStorage, zero process.env.**
|
||||
|
||||
| Module | Purpose | Key exports |
|
||||
|---|---|---|
|
||||
| `core/types/` | Domain types + StorageAdapter interface | `Issue`, `Agent`, `Workspace`, `StorageAdapter` |
|
||||
| `core/api/` | API client class + WS client | `ApiClient`, `WSClient`, `setApiInstance()` |
|
||||
| `core/auth/` | Auth store factory | `createAuthStore(options)`, `registerAuthStore()` |
|
||||
| `core/workspace/` | Workspace store factory + actor hooks | `createWorkspaceStore(api)`, `useActorName()` |
|
||||
| `core/issues/` | Issue queries, mutations, stores, config | `issueListOptions`, `useUpdateIssue`, `useIssueStore` |
|
||||
| `core/inbox/` | Inbox queries, mutations, WS updaters | `inboxListOptions`, `useMarkInboxRead` |
|
||||
| `core/runtimes/` | Runtime queries + mutations | `runtimeListOptions`, `useDeleteRuntime` |
|
||||
| `core/realtime/` | WS provider + sync hooks | `WSProvider`, `useWSEvent`, `useRealtimeSync` |
|
||||
| `core/hooks.tsx` | Workspace ID context | `useWorkspaceId`, `WorkspaceIdProvider` |
|
||||
| `core/modals/` | Modal state store | `useModalStore` |
|
||||
| `core/navigation/` | Navigation state store | `useNavigationStore` |
|
||||
|
||||
**Store factory pattern:** Auth and workspace stores are created via factory functions that receive platform-specific dependencies:
|
||||
```typescript
|
||||
createAuthStore({ api, storage, onLogin?, onLogout? })
|
||||
createWorkspaceStore(api, { storage?, onError? })
|
||||
```
|
||||
Each app creates its own instances in its platform layer and registers them via `registerAuthStore()` / `registerWorkspaceStore()`.
|
||||
|
||||
**StorageAdapter:** All persistent storage goes through a `StorageAdapter` interface (getItem/setItem/removeItem), injected by the platform. Web uses an SSR-safe localStorage wrapper.
|
||||
|
||||
### packages/ui/ (`@multica/ui`)
|
||||
|
||||
Atomic UI layer. **Zero business logic, zero `@multica/core` imports.**
|
||||
|
||||
- `components/ui/` — 55 shadcn components (button, dialog, card, tooltip, sidebar, etc.)
|
||||
- `components/common/` — Pure-props components (actor-avatar, emoji-picker, reaction-bar, file-upload-button)
|
||||
- `markdown/` — Markdown renderer with `renderMention` slot for platform-specific mention cards
|
||||
- `hooks/` — DOM hooks (use-auto-scroll, use-mobile, use-scroll-fade)
|
||||
- `lib/utils.ts` — `cn()` function (clsx + tailwind-merge)
|
||||
- `styles/tokens.css` — Tailwind CSS v4 design tokens (@theme, :root, .dark variables)
|
||||
|
||||
### packages/views/ (`@multica/views`)
|
||||
|
||||
Shared business UI pages. **Zero `next/*` imports.** Uses `NavigationAdapter` for routing.
|
||||
|
||||
- `navigation/` — `NavigationAdapter` interface, `useNavigation()` hook, `AppLink` component
|
||||
- `issues/components/` — IssuesPage, IssueDetail, BoardView, ListView, pickers, icons
|
||||
- `editor/` — ContentEditor, TitleEditor, Tiptap extensions
|
||||
- `modals/` — CreateIssueModal, CreateWorkspaceModal, ModalRegistry
|
||||
- `my-issues/`, `skills/`, `runtimes/` — domain pages
|
||||
- `common/` — Data-aware wrappers (ActorAvatar with useActorName, Markdown with IssueMentionCard)
|
||||
|
||||
### apps/web/ (Next.js App)
|
||||
|
||||
Thin routing shells + platform-specific code.
|
||||
|
||||
```
|
||||
apps/web/
|
||||
├── app/ # Routing layer (thin shells — import from features/)
|
||||
├── features/ # Business logic, organized by domain
|
||||
├── shared/ # Cross-feature utilities (api client, types, logger)
|
||||
├── app/ # Next.js route shells (< 15 lines each, import from @multica/views)
|
||||
├── platform/ # Web platform bridge (api singleton, store instances, navigation, storage)
|
||||
├── features/
|
||||
│ ├── auth/ # Web-only: auth-cookie.ts, initializer.tsx
|
||||
│ ├── landing/ # Web-only: landing pages (uses next/image, next/link)
|
||||
│ └── search/ # Web-only: search dialog
|
||||
└── components/ # App-level: theme-provider, multica-icon, locale-sync, loading-indicator
|
||||
```
|
||||
|
||||
**`app/`** — Next.js App Router pages. Route files should be thin: import and re-export from `features/`. Layout components and route-specific glue (redirects, auth guards) live here. Shared layout components (e.g. `app-sidebar`) stay in `app/(dashboard)/_components/`.
|
||||
|
||||
**`features/`** — Domain modules, each with its own components, hooks, stores, and config:
|
||||
|
||||
| Feature | Purpose | Exports |
|
||||
|---|---|---|
|
||||
| `features/auth/` | Authentication state | `useAuthStore`, `AuthInitializer` |
|
||||
| `features/workspace/` | Workspace, members, agents | `useWorkspaceStore`, `useActorName` |
|
||||
| `features/issues/` | Issue state, components, config | `useIssueStore`, icons, pickers, status/priority config |
|
||||
| `features/inbox/` | Inbox notification state | `useInboxStore` |
|
||||
| `features/realtime/` | WebSocket connection + sync | `WSProvider`, `useWSEvent`, `useRealtimeSync` |
|
||||
| `features/modals/` | Modal registry and state | Modal store and components |
|
||||
| `features/skills/` | Skill management | Skill components |
|
||||
|
||||
**`shared/`** — Code used across multiple features:
|
||||
- `shared/api/` — `ApiClient` (REST) and `WSClient` (WebSocket) for backend communication, plus the `api` singleton.
|
||||
- `shared/types/` — Domain types (Issue, Agent, Workspace, etc.) and WebSocket event types.
|
||||
- `shared/logger.ts` — Logger utility.
|
||||
**`platform/`** — The only code that touches Next.js APIs and browser globals:
|
||||
- `api.ts` — Creates `ApiClient` singleton with `onUnauthorized` redirect
|
||||
- `auth.ts` — `createAuthStore({ api, storage: webStorage, onLogin: setLoggedInCookie })`
|
||||
- `workspace.ts` — `createWorkspaceStore(api, { storage: webStorage, onError: toast.error })`
|
||||
- `ws-provider.tsx` — Wraps `WSProvider` with web-specific WS URL and store instances
|
||||
- `navigation.tsx` — `WebNavigationProvider` wrapping Next.js `useRouter`/`usePathname`
|
||||
- `storage.ts` — SSR-safe `webStorage` adapter (guards `localStorage` with `typeof window` checks)
|
||||
|
||||
### 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/`).
|
||||
- **TanStack Query** for all server state — issues, inbox, members, agents, skills, runtimes. Query definitions in `@multica/core/<domain>/queries.ts`, mutations in `mutations.ts`.
|
||||
- **Zustand** for client-only state — UI selections (`activeIssueId`), view filters, modal state. Auth and workspace stores use factory pattern with injected dependencies.
|
||||
- **React Context** for `WorkspaceIdProvider` (provides workspace ID to all dashboard children) and `NavigationProvider` (provides platform-agnostic routing).
|
||||
- **Local `useState`** for component-scoped UI state (forms, modals, filters).
|
||||
- Do not use React Context for data that can be a zustand store.
|
||||
|
||||
**Store conventions:**
|
||||
- One store per feature domain. Import via `useAuthStore(selector)` or `useWorkspaceStore(selector)`.
|
||||
- Stores must not call `useRouter` or any React hooks — keep navigation in components.
|
||||
- Cross-store reads use `useOtherStore.getState()` inside actions (not hooks).
|
||||
- Dependency direction: `workspace` → `auth`, `realtime` → `auth`, `issues` → `workspace`. Never reverse.
|
||||
**TanStack Query conventions:**
|
||||
- `staleTime: Infinity` — WS events handle cache freshness, no polling or refetch-on-focus.
|
||||
- WS events trigger `queryClient.invalidateQueries()` (preferred) or `queryClient.setQueryData()` for granular updates.
|
||||
- All workspace-scoped query keys include `wsId` — workspace switch automatically uses new cache.
|
||||
- Mutations use `onMutate` for optimistic updates + `onError` for rollback + `onSettled` for invalidation.
|
||||
|
||||
### Import Aliases
|
||||
**Zustand store conventions:**
|
||||
- Stores in `@multica/core` hold only client state. Zero direct `api.*` calls — API access is injected via factory.
|
||||
- Auth/workspace stores are created by platform layer and registered via `registerAuthStore()` / `registerWorkspaceStore()`.
|
||||
- Other stores (issue, modal, navigation) are plain Zustand stores exported directly.
|
||||
|
||||
### Import Conventions
|
||||
|
||||
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";
|
||||
// Core (headless business logic) — from @multica/core
|
||||
import { issueListOptions } from "@multica/core/issues/queries";
|
||||
import { useUpdateIssue } from "@multica/core/issues/mutations";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import type { Issue } from "@multica/core/types";
|
||||
|
||||
// UI (atomic components) — from @multica/ui
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { ActorAvatar } from "@multica/ui/components/common/actor-avatar";
|
||||
|
||||
// Views (shared pages) — from @multica/views
|
||||
import { IssuesPage } from "@multica/views/issues/components";
|
||||
import { useNavigation, AppLink } from "@multica/views/navigation";
|
||||
import { ModalRegistry } from "@multica/views/modals/registry";
|
||||
|
||||
// Platform (web-only singletons) — from @/platform
|
||||
import { api } from "@/platform/api";
|
||||
import { useAuthStore } from "@/platform/auth";
|
||||
import { useWorkspaceStore } from "@/platform/workspace";
|
||||
|
||||
// Web-only features — from @/features
|
||||
import { AuthInitializer } from "@/features/auth";
|
||||
import { SearchCommand } from "@/features/search";
|
||||
```
|
||||
|
||||
Within a feature, use relative imports. Between features or to shared, use `@/`.
|
||||
`@/` maps to `apps/web/`. Within a package, use relative imports. Between packages, use `@multica/*`.
|
||||
|
||||
### Data Flow
|
||||
|
||||
```
|
||||
Browser → ApiClient (shared/api) → REST API (Chi handlers) → sqlc queries → PostgreSQL
|
||||
Browser ← WSClient (shared/api) ← WebSocket ← Hub.Broadcast() ← Handlers/TaskService
|
||||
Browser → useQuery (@multica/core) → ApiClient (@multica/core/api) → REST API → sqlc → PostgreSQL
|
||||
Browser ← useQuery cache ← invalidateQueries ← WS event handlers ← WSClient ← Hub.Broadcast()
|
||||
```
|
||||
|
||||
Mutations: `useMutation (@multica/core)` → optimistic cache update → API call → onSettled invalidation.
|
||||
WS events: `use-realtime-sync.ts` → `queryClient.invalidateQueries()` for most events, `setQueryData()` for granular issue/inbox updates.
|
||||
|
||||
### Backend Structure (`server/`)
|
||||
|
||||
- **Entry points** (`cmd/`): `server` (HTTP API), `multica` (CLI — daemon, agent management, config), `migrate`
|
||||
@@ -115,13 +200,13 @@ 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 typecheck # TypeScript check (all packages via turbo)
|
||||
pnpm lint # ESLint via Next.js
|
||||
pnpm test # TS tests (Vitest)
|
||||
pnpm test # TS tests (Vitest, via turbo)
|
||||
|
||||
# Backend (Go)
|
||||
make dev # Run Go server (port 8080)
|
||||
@@ -142,6 +227,9 @@ 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
|
||||
|
||||
# shadcn (monorepo mode — must specify app)
|
||||
npx shadcn add badge -c apps/web
|
||||
|
||||
# Infrastructure
|
||||
make db-up # Start shared PostgreSQL (pgvector/pg17 image)
|
||||
make db-down # Stop shared PostgreSQL
|
||||
@@ -149,7 +237,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
|
||||
|
||||
@@ -172,12 +260,21 @@ make start-worktree # Start using .env.worktree
|
||||
- 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
|
||||
|
||||
- `packages/core/` — zero react-dom, zero localStorage (use StorageAdapter), zero process.env, zero UI libraries
|
||||
- `packages/ui/` — zero `@multica/core` imports (pure UI, no business logic)
|
||||
- `packages/views/` — zero `next/*` imports (use NavigationAdapter for routing)
|
||||
- `apps/web/platform/` — the only place for Next.js APIs, env vars, and browser globals
|
||||
|
||||
## 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.
|
||||
- Prefer shadcn components over custom implementations. Install via `npx shadcn add <component> -c apps/web` (monorepo flag required).
|
||||
- **Shared UI components** → `packages/ui/components/` — shadcn primitives and pure-props common components.
|
||||
- **Shared business components** → `packages/views/<domain>/components/` — pages and domain-bound UI.
|
||||
- **Web-only components** → `apps/web/features/` or `apps/web/components/`.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
@@ -197,6 +294,16 @@ make start-worktree # Start using .env.worktree
|
||||
- `test(scope): ...`
|
||||
- `chore(scope): ...`
|
||||
|
||||
## CLI Release
|
||||
|
||||
**Prerequisite:** A CLI release must accompany every Production deployment. When deploying to Production, always release a new CLI version as part of the process.
|
||||
|
||||
1. Create a tag on the `main` branch: `git tag v0.x.x`
|
||||
2. Push the tag: `git push origin v0.x.x`
|
||||
3. GitHub Actions automatically triggers `release.yml`: runs Go tests → GoReleaser builds multi-platform binaries → publishes to GitHub Releases + Homebrew tap
|
||||
|
||||
By default, bump the patch version each release (e.g. `v0.1.12` → `v0.1.13`), unless the user specifies a specific version.
|
||||
|
||||
## Minimum Pre-Push Checks
|
||||
|
||||
```bash
|
||||
|
||||
345
CLI_AND_DAEMON.md
Normal file
345
CLI_AND_DAEMON.md
Normal file
@@ -0,0 +1,345 @@
|
||||
# CLI and Agent Daemon Guide
|
||||
|
||||
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.
|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
### 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.
|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
```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
|
||||
```
|
||||
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`."
|
||||
@@ -1,6 +1,6 @@
|
||||
# Local Development Guide
|
||||
# Contributing Guide
|
||||
|
||||
This guide documents the intended local development workflow for Multica.
|
||||
This guide documents the local development workflow for contributors working on the Multica codebase.
|
||||
|
||||
It covers:
|
||||
|
||||
44
LICENSE
Normal file
44
LICENSE
Normal file
@@ -0,0 +1,44 @@
|
||||
# Open Source License
|
||||
|
||||
Multica is licensed under a modified version of the Apache License 2.0, with the following additional conditions:
|
||||
|
||||
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:
|
||||
|
||||
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.
|
||||
|
||||
- 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.
|
||||
|
||||
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.
|
||||
|
||||
- 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.
|
||||
|
||||
2. As a contributor, you should agree that:
|
||||
|
||||
a. The producer can adjust the open-source agreement to be more strict
|
||||
or relaxed as deemed necessary.
|
||||
|
||||
b. Your contributed code may be used for commercial purposes, including
|
||||
but not limited to its cloud business operations.
|
||||
|
||||
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.
|
||||
|
||||
© 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
|
||||
|
||||
341
README.md
341
README.md
@@ -1,237 +1,160 @@
|
||||
<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 task management platform — like Linear, but with AI agents as first-class citizens.
|
||||
**Your next 10 hires won't be human.**
|
||||
|
||||
For the full local development workflow, see [Local Development Guide](LOCAL_DEVELOPMENT.md).
|
||||
The open-source managed agents platform.<br/>
|
||||
Turn coding agents into real teammates — assign tasks, track progress, compound skills.
|
||||
|
||||
## Prerequisites
|
||||
[](https://github.com/multica-ai/multica/actions/workflows/ci.yml)
|
||||
[](https://opensource.org/licenses/Apache-2.0)
|
||||
[](https://github.com/multica-ai/multica/stargazers)
|
||||
|
||||
- [Node.js](https://nodejs.org/) (v20+)
|
||||
- [pnpm](https://pnpm.io/) (v10.28+)
|
||||
- [Go](https://go.dev/) (v1.26+)
|
||||
- [Docker](https://www.docker.com/)
|
||||
[Website](https://multica.ai) · [Cloud](https://multica.ai/app) · [Self-Hosting](SELF_HOSTING.md) · [Contributing](CONTRIBUTING.md)
|
||||
|
||||
## Quick Start
|
||||
**English | [简体中文](README.zh-CN.md)**
|
||||
|
||||
### Using Multica (CLI)
|
||||
</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** and **Codex**.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/assets/hero-screenshot.png" alt="Multica board view" width="800">
|
||||
</p>
|
||||
|
||||
## Features
|
||||
|
||||
Multica manages the full agent lifecycle: from task assignment to execution monitoring to skill reuse.
|
||||
|
||||
- **Agents as Teammates** — assign to an agent like you'd assign to a colleague. They have profiles, show up on the board, post comments, create issues, and report blockers proactively.
|
||||
- **Autonomous Execution** — set it and forget it. Full task lifecycle management (enqueue, claim, start, complete/fail) with real-time progress streaming via WebSocket.
|
||||
- **Reusable Skills** — every solution becomes a reusable skill for the whole team. Deployments, migrations, code reviews — skills compound your team's capabilities over time.
|
||||
- **Unified Runtimes** — one dashboard for all your compute. Local daemons and cloud runtimes, auto-detection of available CLIs, real-time monitoring.
|
||||
- **Multi-Workspace** — organize work across teams with workspace-level isolation. Each workspace has its own agents, issues, and settings.
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Multica Cloud
|
||||
|
||||
The fastest way to get started — no setup required: **[multica.ai](https://multica.ai)**
|
||||
|
||||
### Self-Host with Docker
|
||||
|
||||
```bash
|
||||
# 1. Install the CLI
|
||||
git clone https://github.com/multica-ai/multica.git
|
||||
cd multica
|
||||
cp .env.example .env
|
||||
# Edit .env — at minimum, change JWT_SECRET
|
||||
|
||||
docker compose up -d # Start PostgreSQL
|
||||
cd server && go run ./cmd/migrate up && cd .. # Run migrations
|
||||
make start # Start the app
|
||||
```
|
||||
|
||||
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, 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
|
||||
brew install multica-cli
|
||||
brew install multica
|
||||
|
||||
# 2. Login and auto-watch your workspaces
|
||||
# Authenticate and start
|
||||
multica login
|
||||
|
||||
# 3. Start the local agent daemon
|
||||
multica daemon start
|
||||
```
|
||||
|
||||
The daemon auto-detects available agent CLIs (`claude`, `codex`) on your PATH and begins polling your watched workspaces for tasks.
|
||||
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.
|
||||
|
||||
Manage which workspaces the daemon monitors:
|
||||
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 workspace list # List all workspaces (* = watched)
|
||||
multica workspace watch <workspace-id> # Add a workspace to the watch list
|
||||
multica workspace unwatch <workspace-id> # Remove a workspace from the watch list
|
||||
multica login # Authenticate with your Multica account
|
||||
multica daemon start # Start the local agent runtime
|
||||
```
|
||||
|
||||
### Local Development
|
||||
The daemon runs in the background and keeps your machine connected to Multica. It auto-detects agent CLIs (`claude`, `codex`) available on your PATH.
|
||||
|
||||
### 2. Verify your runtime
|
||||
|
||||
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 or Codex). Give your agent a name — this is how it will appear on the board, in comments, and in assignments.
|
||||
|
||||
### 4. Assign your first task
|
||||
|
||||
Create an issue from the board (or via `multica issue create`), then assign it to your new agent. The agent will automatically pick up the task, execute it on your runtime, and report progress — just like a human teammate.
|
||||
|
||||
That's it! Your agent is now part of the team. 🎉
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌──────────────┐ ┌──────────────┐ ┌──────────────────┐
|
||||
│ Next.js │────>│ Go Backend │────>│ PostgreSQL │
|
||||
│ Frontend │<────│ (Chi + WS) │<────│ (pgvector) │
|
||||
└──────────────┘ └──────┬───────┘ └──────────────────┘
|
||||
│
|
||||
┌──────┴───────┐
|
||||
│ Agent Daemon │ (runs on your machine)
|
||||
│ Claude/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 or Codex |
|
||||
|
||||
## 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/)
|
||||
|
||||
```bash
|
||||
# 1. Install dependencies
|
||||
pnpm install
|
||||
|
||||
# 2. Copy environment variables for the shared main environment
|
||||
cp .env.example .env
|
||||
|
||||
# 3. One-time setup: ensure shared PostgreSQL, create the app DB, run migrations
|
||||
make setup
|
||||
|
||||
# 4. Start backend + frontend
|
||||
make start
|
||||
```
|
||||
|
||||
Open your configured `FRONTEND_ORIGIN` in the browser. By default that is [http://localhost:3000](http://localhost:3000).
|
||||
|
||||
Main checkout uses `.env`. A Git worktree should generate its own `.env.worktree` and use the explicit worktree targets:
|
||||
|
||||
```bash
|
||||
make worktree-env
|
||||
make setup-worktree
|
||||
make start-worktree
|
||||
```
|
||||
|
||||
Every checkout shares the same PostgreSQL container on `localhost:5432`. Isolation now happens at the database level:
|
||||
|
||||
- `.env` typically uses `POSTGRES_DB=multica`
|
||||
- each `.env.worktree` gets its own `POSTGRES_DB`, such as `multica_my_feature_702`
|
||||
- backend/frontend ports still stay unique per worktree
|
||||
|
||||
That keeps one Docker container and one volume, while still isolating schema and data per worktree.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
├── server/ # Go backend (Chi + sqlc + gorilla/websocket)
|
||||
│ ├── cmd/ # server, daemon, migrate
|
||||
│ ├── internal/ # Core business logic
|
||||
│ ├── migrations/ # SQL migrations
|
||||
│ └── sqlc.yaml # sqlc config
|
||||
├── apps/
|
||||
│ └── web/ # Next.js 16 frontend
|
||||
├── packages/ # Shared TypeScript packages
|
||||
│ ├── ui/ # Component library (shadcn/ui + Radix)
|
||||
│ ├── types/ # Shared type definitions
|
||||
│ ├── sdk/ # API client SDK
|
||||
│ ├── store/ # State management
|
||||
│ ├── hooks/ # Shared React hooks
|
||||
│ └── utils/ # Utility functions
|
||||
├── Makefile # Backend commands
|
||||
├── docker-compose.yml # PostgreSQL + pgvector
|
||||
└── .env.example # Environment variable template
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
### Frontend
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `pnpm dev:web` | Start Next.js dev server (uses `FRONTEND_PORT`, default `3000`) |
|
||||
| `pnpm build` | Build all TypeScript packages |
|
||||
| `pnpm typecheck` | Run TypeScript type checking |
|
||||
| `pnpm test` | Run TypeScript tests |
|
||||
|
||||
### Backend
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `make dev` | Run Go server (uses `PORT`, default `8080`) |
|
||||
| `make daemon` | Run local agent daemon |
|
||||
| `make multica ARGS="version"` | Run the local `multica` CLI without installing it |
|
||||
| `make test` | Run Go tests |
|
||||
| `make build` | Build server & daemon binaries |
|
||||
| `make sqlc` | Regenerate sqlc code from SQL |
|
||||
|
||||
### Database
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `make db-up` | Start the shared PostgreSQL container |
|
||||
| `make db-down` | Stop the shared PostgreSQL container |
|
||||
| `make migrate-up` | Ensure the current DB exists, then run migrations |
|
||||
| `make migrate-down` | Rollback database migrations for the current DB |
|
||||
| `make worktree-env` | Generate an isolated `.env.worktree` for the current worktree |
|
||||
| `make setup-main` / `make start-main` | Force use of the shared main `.env` |
|
||||
| `make setup-worktree` / `make start-worktree` | Force use of isolated `.env.worktree` |
|
||||
|
||||
## CLI (`multica`)
|
||||
|
||||
The CLI manages authentication, workspace configuration, and the local agent daemon.
|
||||
|
||||
### Install
|
||||
|
||||
```bash
|
||||
brew tap multica-ai/tap
|
||||
brew install multica-cli
|
||||
```
|
||||
|
||||
Or build from source:
|
||||
|
||||
```bash
|
||||
make build
|
||||
cp server/bin/multica /usr/local/bin/multica # or ~/.local/bin/multica
|
||||
```
|
||||
|
||||
For local development, you can also run the CLI directly from the repo:
|
||||
|
||||
```bash
|
||||
make multica ARGS="version"
|
||||
make multica ARGS="auth status"
|
||||
```
|
||||
|
||||
For browser-based auth from source, make sure the local frontend is running at `FRONTEND_ORIGIN` first, for example with `make start`, `make start-main`, or `make start-worktree`.
|
||||
|
||||
### Authentication
|
||||
|
||||
```bash
|
||||
multica login # Authenticate and auto-watch your workspaces
|
||||
multica auth login # Legacy auth-only flow
|
||||
multica auth login --token # Legacy token-only auth flow
|
||||
multica auth status # Show current auth status
|
||||
multica auth logout # Remove stored token
|
||||
```
|
||||
|
||||
Credentials are saved to `~/.multica/config.json`.
|
||||
|
||||
### Workspaces
|
||||
|
||||
```bash
|
||||
multica workspace list # List all workspaces you belong to
|
||||
multica workspace get # Show the current workspace details/context
|
||||
```
|
||||
|
||||
### Daemon Watch List
|
||||
|
||||
The daemon monitors one or more workspaces for tasks. Manage which workspaces are watched:
|
||||
|
||||
```bash
|
||||
multica workspace watch <workspace-id> # Add a workspace to the watch list
|
||||
multica workspace unwatch <workspace-id> # Remove a workspace from the watch list
|
||||
multica workspace list # Show all workspaces (watched ones marked with *)
|
||||
```
|
||||
|
||||
The watch list is stored in `~/.multica/config.json`. Changes are picked up by a running daemon within 5 seconds (hot-reload).
|
||||
|
||||
### Local Agent Daemon
|
||||
|
||||
The daemon polls watched workspaces for tasks and executes them using locally installed AI agents (Claude Code, Codex).
|
||||
|
||||
```bash
|
||||
# 1. Authenticate
|
||||
multica login
|
||||
|
||||
# 2. Add workspaces to watch
|
||||
multica workspace watch <workspace-id>
|
||||
|
||||
# 3. Start the daemon
|
||||
multica daemon start
|
||||
```
|
||||
|
||||
The daemon auto-detects available agent CLIs (`claude`, `codex`) on your PATH. When a task is claimed, it creates an isolated execution environment, runs the agent, and reports results back to the server.
|
||||
|
||||
### Other Commands
|
||||
|
||||
```bash
|
||||
multica agent list # List agents in the current workspace
|
||||
multica daemon status # Show local daemon status
|
||||
multica config # Show CLI configuration
|
||||
multica config show # Compatibility alias for config display
|
||||
multica version # Show CLI version
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
See [`.env.example`](.env.example) for all available variables:
|
||||
|
||||
- `DATABASE_URL` — PostgreSQL connection string
|
||||
- `POSTGRES_DB` — Database name for the current checkout or worktree
|
||||
- `POSTGRES_PORT` — Shared PostgreSQL host port (fixed to `5432`)
|
||||
- `PORT` — Backend server port (default: 8080)
|
||||
- `FRONTEND_PORT` / `FRONTEND_ORIGIN` — Frontend port and browser origin
|
||||
- `JWT_SECRET` — JWT signing secret
|
||||
- `MULTICA_APP_URL` — Browser origin for CLI login callback (default: `http://localhost:3000`)
|
||||
- `MULTICA_DAEMON_ID` / `MULTICA_DAEMON_DEVICE_NAME` — Stable daemon identity for runtime registration
|
||||
- `MULTICA_CLAUDE_PATH` / `MULTICA_CLAUDE_MODEL` — Claude Code executable and optional model override
|
||||
- `MULTICA_CODEX_PATH` / `MULTICA_CODEX_MODEL` — Codex executable and optional model override
|
||||
- `MULTICA_WORKSPACES_ROOT` — Base directory for agent execution environments (default: `~/multica_workspaces`)
|
||||
- `NEXT_PUBLIC_API_URL` — Frontend → backend API URL
|
||||
- `NEXT_PUBLIC_WS_URL` — Frontend → backend WebSocket URL
|
||||
|
||||
## Local Development Notes
|
||||
|
||||
- `make setup`, `make start`, `make dev`, and `make test` now require an env file. They fail fast if `.env` or `.env.worktree` is missing.
|
||||
- `make stop` only stops the backend/frontend processes for the current checkout. It does not stop the shared PostgreSQL container.
|
||||
- Use `make db-down` only when you explicitly want to shut down the shared local PostgreSQL instance for every checkout.
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for the full development workflow, worktree support, testing, and troubleshooting.
|
||||
|
||||
164
README.zh-CN.md
Normal file
164
README.zh-CN.md
Normal file
@@ -0,0 +1,164 @@
|
||||
<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) · [自部署指南](SELF_HOSTING.md) · [参与贡献](CONTRIBUTING.md)
|
||||
|
||||
**[English](README.md) | 简体中文**
|
||||
|
||||
</div>
|
||||
|
||||
## Multica 是什么?
|
||||
|
||||
Multica 将编码 Agent 变成真正的队友。像分配给同事一样分配给 Agent——它们会自主接手工作、编写代码、报告阻塞问题、更新状态。
|
||||
|
||||
不再需要复制粘贴 prompt,不再需要盯着运行过程。你的 Agent 出现在看板上、参与对话、随着时间积累可复用的技能。可以理解为开源的 Managed Agents 基础设施——厂商中立、可自部署、专为人类 + AI 团队设计。支持 **Claude Code** 和 **Codex**。
|
||||
|
||||
<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 等):**
|
||||
|
||||
```
|
||||
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`)。当 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`)。
|
||||
|
||||
### 2. 确认运行时已连接
|
||||
|
||||
在 Multica Web 端打开你的工作区,进入 **设置 → 运行时(Runtimes)**,你应该能看到你的机器已作为一个活跃的 **Runtime** 出现在列表中。
|
||||
|
||||
> **什么是 Runtime(运行时)?** Runtime 是可以执行 Agent 任务的计算环境。它可以是你的本地机器(通过 daemon 连接),也可以是云端实例。每个 Runtime 会上报可用的 Agent CLI,Multica 据此决定将任务路由到哪里执行。
|
||||
|
||||
### 3. 创建 Agent
|
||||
|
||||
进入 **设置 → Agents**,点击 **新建 Agent**。选择你刚连接的 Runtime,选择 Provider(Claude Code 或 Codex),并为 Agent 起个名字——它将以这个名字出现在看板、评论和任务分配中。
|
||||
|
||||
### 4. 分配你的第一个任务
|
||||
|
||||
在看板上创建一个 Issue(或通过 `multica issue create` 命令创建),然后将其分配给你的新 Agent。Agent 会自动接手任务、在你的 Runtime 上执行、并实时汇报进度——就像一个真正的队友一样。
|
||||
|
||||
大功告成!你的 Agent 现在是团队的一员了。 🎉
|
||||
|
||||
## 架构
|
||||
|
||||
```
|
||||
┌──────────────┐ ┌──────────────┐ ┌──────────────────┐
|
||||
│ Next.js │────>│ Go 后端 │────>│ PostgreSQL │
|
||||
│ 前端 │<────│ (Chi + WS) │<────│ (pgvector) │
|
||||
└──────────────┘ └──────┬───────┘ └──────────────────┘
|
||||
│
|
||||
┌──────┴───────┐
|
||||
│ Agent Daemon │ (运行在你的机器上)
|
||||
│ Claude/Codex │
|
||||
└──────────────┘
|
||||
```
|
||||
|
||||
| 层级 | 技术栈 |
|
||||
|------|--------|
|
||||
| 前端 | Next.js 16 (App Router) |
|
||||
| 后端 | Go (Chi router, sqlc, gorilla/websocket) |
|
||||
| 数据库 | PostgreSQL 17 with pgvector |
|
||||
| Agent 运行时 | 本地 daemon 执行 Claude Code 或 Codex |
|
||||
|
||||
## 开发
|
||||
|
||||
参与 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)
|
||||
286
SELF_HOSTING.md
Normal file
286
SELF_HOSTING.md
Normal file
@@ -0,0 +1,286 @@
|
||||
# Self-Hosting Guide
|
||||
|
||||
This guide walks you through deploying 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
|
||||
#
|
||||
# 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
|
||||
|
||||
# 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. When an agent is assigned a task in Multica, the daemon picks it up, creates an isolated workspace, runs the agent, and reports results back.
|
||||
|
||||
## Upgrading
|
||||
|
||||
1. Pull the latest code or image
|
||||
2. Run migrations: `./server/bin/migrate up`
|
||||
3. Restart the backend and frontend
|
||||
|
||||
Migrations are forward-only and safe to run on a live database. They are idempotent — running them multiple times has no effect.
|
||||
@@ -12,7 +12,7 @@ vi.mock("next/navigation", () => ({
|
||||
// Mock auth store
|
||||
const mockSendCode = vi.fn();
|
||||
const mockVerifyCode = vi.fn();
|
||||
vi.mock("@/features/auth", () => ({
|
||||
vi.mock("@/platform/auth", () => ({
|
||||
useAuthStore: (selector: (s: any) => any) =>
|
||||
selector({
|
||||
sendCode: mockSendCode,
|
||||
@@ -20,9 +20,14 @@ vi.mock("@/features/auth", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock auth-cookie
|
||||
vi.mock("@/features/auth/auth-cookie", () => ({
|
||||
setLoggedInCookie: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock workspace store
|
||||
const mockHydrateWorkspace = vi.fn();
|
||||
vi.mock("@/features/workspace", () => ({
|
||||
vi.mock("@/platform/workspace", () => ({
|
||||
useWorkspaceStore: (selector: (s: any) => any) =>
|
||||
selector({
|
||||
hydrateWorkspace: mockHydrateWorkspace,
|
||||
@@ -30,7 +35,7 @@ vi.mock("@/features/workspace", () => ({
|
||||
}));
|
||||
|
||||
// Mock api
|
||||
vi.mock("@/shared/api", () => ({
|
||||
vi.mock("@/platform/api", () => ({
|
||||
api: {
|
||||
listWorkspaces: vi.fn().mockResolvedValue([]),
|
||||
verifyCode: vi.fn(),
|
||||
@@ -50,7 +55,7 @@ describe("LoginPage", () => {
|
||||
render(<LoginPage />);
|
||||
|
||||
expect(screen.getByText("Multica")).toBeInTheDocument();
|
||||
expect(screen.getByText("AI-native task management")).toBeInTheDocument();
|
||||
expect(screen.getByText("Turn coding agents into real teammates")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Email")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Continue" })
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
import { Suspense, useState, useEffect, useCallback } from "react";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { useAuthStore } from "@/features/auth";
|
||||
import { useWorkspaceStore } from "@/features/workspace";
|
||||
import { api } from "@/shared/api";
|
||||
import { useAuthStore } from "@/platform/auth";
|
||||
import { setLoggedInCookie } from "@/features/auth/auth-cookie";
|
||||
import { useWorkspaceStore } from "@/platform/workspace";
|
||||
import { api } from "@/platform/api";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
@@ -12,16 +13,16 @@ import {
|
||||
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";
|
||||
} from "@multica/ui/components/ui/card";
|
||||
import { Input } from "@multica/ui/components/ui/input";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { Label } from "@multica/ui/components/ui/label";
|
||||
import {
|
||||
InputOTP,
|
||||
InputOTPGroup,
|
||||
InputOTPSlot,
|
||||
} from "@/components/ui/input-otp";
|
||||
import type { User } from "@/shared/types";
|
||||
} from "@multica/ui/components/ui/input-otp";
|
||||
import type { User } from "@multica/core/types";
|
||||
|
||||
function validateCliCallback(cliCallback: string): boolean {
|
||||
try {
|
||||
@@ -46,11 +47,20 @@ function redirectToCliCallback(
|
||||
|
||||
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
|
||||
useEffect(() => {
|
||||
if (!isLoading && user && !searchParams.get("cli_callback")) {
|
||||
router.replace(searchParams.get("next") || "/issues");
|
||||
}
|
||||
}, [isLoading, user, router, searchParams]);
|
||||
|
||||
const [step, setStep] = useState<"email" | "code" | "cli_confirm">("email");
|
||||
const [email, setEmail] = useState("");
|
||||
const [code, setCode] = useState("");
|
||||
@@ -137,6 +147,10 @@ function LoginPageContent() {
|
||||
return;
|
||||
}
|
||||
const { token } = await api.verifyCode(email, value);
|
||||
// Persist session in the browser so the web app stays logged in
|
||||
localStorage.setItem("multica_token", token);
|
||||
api.setToken(token);
|
||||
setLoggedInCookie();
|
||||
const cliState = searchParams.get("cli_state") || "";
|
||||
redirectToCliCallback(cliCallback, token, cliState);
|
||||
return;
|
||||
@@ -144,7 +158,8 @@ function LoginPageContent() {
|
||||
|
||||
await verifyCode(email, value);
|
||||
const wsList = await api.listWorkspaces();
|
||||
await hydrateWorkspace(wsList);
|
||||
const lastWsId = localStorage.getItem("multica_workspace_id");
|
||||
await hydrateWorkspace(wsList, lastWsId);
|
||||
router.push(searchParams.get("next") || "/issues");
|
||||
} catch (err) {
|
||||
setError(
|
||||
@@ -272,12 +287,28 @@ function LoginPageContent() {
|
||||
);
|
||||
}
|
||||
|
||||
const googleClientId = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID;
|
||||
|
||||
const handleGoogleLogin = () => {
|
||||
if (!googleClientId) return;
|
||||
const redirectUri = `${window.location.origin}/auth/callback`;
|
||||
const params = new URLSearchParams({
|
||||
client_id: googleClientId,
|
||||
redirect_uri: redirectUri,
|
||||
response_type: "code",
|
||||
scope: "openid email profile",
|
||||
access_type: "offline",
|
||||
prompt: "select_account",
|
||||
});
|
||||
window.location.href = `https://accounts.google.com/o/oauth2/v2/auth?${params}`;
|
||||
};
|
||||
|
||||
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>
|
||||
<CardDescription>Turn coding agents into real teammates</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form id="login-form" onSubmit={handleSendCode} className="space-y-4">
|
||||
@@ -297,7 +328,7 @@ function LoginPageContent() {
|
||||
)}
|
||||
</form>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<CardFooter className="flex flex-col gap-3">
|
||||
<Button
|
||||
type="submit"
|
||||
form="login-form"
|
||||
@@ -307,6 +338,46 @@ function LoginPageContent() {
|
||||
>
|
||||
{submitting ? "Sending code..." : "Continue"}
|
||||
</Button>
|
||||
{googleClientId && (
|
||||
<>
|
||||
<div className="relative w-full">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="w-full border-t" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-card px-2 text-muted-foreground">or</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
size="lg"
|
||||
onClick={handleGoogleLogin}
|
||||
disabled={submitting}
|
||||
>
|
||||
<svg className="mr-2 h-4 w-4" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"
|
||||
fill="#4285F4"
|
||||
/>
|
||||
<path
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||
fill="#34A853"
|
||||
/>
|
||||
<path
|
||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||
fill="#FBBC05"
|
||||
/>
|
||||
<path
|
||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||
fill="#EA4335"
|
||||
/>
|
||||
</svg>
|
||||
Continue with Google
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import {
|
||||
@@ -15,9 +16,10 @@ import {
|
||||
BookOpenText,
|
||||
SquarePen,
|
||||
CircleUser,
|
||||
FolderKanban,
|
||||
} from "lucide-react";
|
||||
import { WorkspaceAvatar } from "@/features/workspace";
|
||||
import { useIssueDraftStore } from "@/features/issues/stores/draft-store";
|
||||
import { WorkspaceAvatar } from "@multica/views/workspace/workspace-avatar";
|
||||
import { useIssueDraftStore } from "@multica/core/issues/stores/draft-store";
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
@@ -29,7 +31,7 @@ import {
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarRail,
|
||||
} from "@/components/ui/sidebar";
|
||||
} from "@multica/ui/components/ui/sidebar";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -38,17 +40,21 @@ import {
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
|
||||
import { useAuthStore } from "@/features/auth";
|
||||
import { useWorkspaceStore } from "@/features/workspace";
|
||||
import { useInboxStore } from "@/features/inbox";
|
||||
import { useModalStore } from "@/features/modals";
|
||||
} from "@multica/ui/components/ui/dropdown-menu";
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
|
||||
import { useAuthStore } from "@/platform/auth";
|
||||
import { useWorkspaceStore } from "@/platform/workspace";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { inboxKeys, deduplicateInboxItems } from "@multica/core/inbox/queries";
|
||||
import { api } from "@/platform/api";
|
||||
import { useModalStore } from "@multica/core/modals";
|
||||
import { useMyRuntimesNeedUpdate } from "@multica/core/runtimes/hooks";
|
||||
|
||||
const primaryNav = [
|
||||
{ href: "/inbox", label: "Inbox", icon: Inbox },
|
||||
{ href: "/my-issues", label: "My Issues", icon: CircleUser },
|
||||
{ href: "/issues", label: "Issues", icon: ListTodo },
|
||||
{ href: "/projects", label: "Projects", icon: FolderKanban },
|
||||
];
|
||||
|
||||
const workspaceNav = [
|
||||
@@ -73,12 +79,22 @@ export function AppSidebar() {
|
||||
const workspaces = useWorkspaceStore((s) => s.workspaces);
|
||||
const switchWorkspace = useWorkspaceStore((s) => s.switchWorkspace);
|
||||
|
||||
const unreadCount = useInboxStore((s) => s.unreadCount());
|
||||
const wsId = workspace?.id;
|
||||
const { data: inboxItems = [] } = useQuery({
|
||||
queryKey: wsId ? inboxKeys.list(wsId) : ["inbox", "disabled"],
|
||||
queryFn: () => api.listInbox(),
|
||||
enabled: !!wsId,
|
||||
});
|
||||
const unreadCount = React.useMemo(
|
||||
() => deduplicateInboxItems(inboxItems).filter((i) => !i.read).length,
|
||||
[inboxItems],
|
||||
);
|
||||
const hasRuntimeUpdates = useMyRuntimesNeedUpdate();
|
||||
|
||||
const logout = () => {
|
||||
router.push("/");
|
||||
authLogout();
|
||||
useWorkspaceStore.getState().clearWorkspace();
|
||||
router.push("/login");
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -132,6 +148,7 @@ export function AppSidebar() {
|
||||
key={ws.id}
|
||||
onClick={() => {
|
||||
if (ws.id !== workspace?.id) {
|
||||
router.push("/issues");
|
||||
switchWorkspace(ws.id);
|
||||
}
|
||||
}}
|
||||
@@ -211,6 +228,9 @@ export function AppSidebar() {
|
||||
>
|
||||
<item.icon />
|
||||
<span>{item.label}</span>
|
||||
{item.label === "Runtimes" && hasRuntimeUpdates && (
|
||||
<span className="ml-auto size-1.5 rounded-full bg-destructive" />
|
||||
)}
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useDefaultLayout } from "react-resizable-panels";
|
||||
import { useInboxStore } from "@/features/inbox";
|
||||
import { IssueDetail, StatusIcon, PriorityIcon } from "@/features/issues/components";
|
||||
import { STATUS_CONFIG, PRIORITY_CONFIG } from "@/features/issues/config";
|
||||
import { useActorName } from "@/features/workspace";
|
||||
import { ActorAvatar } from "@/components/common/actor-avatar";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import {
|
||||
inboxListOptions,
|
||||
deduplicateInboxItems,
|
||||
} from "@multica/core/inbox/queries";
|
||||
import {
|
||||
useMarkInboxRead,
|
||||
useArchiveInbox,
|
||||
useMarkAllInboxRead,
|
||||
useArchiveAllInbox,
|
||||
useArchiveAllReadInbox,
|
||||
useArchiveCompletedInbox,
|
||||
} from "@multica/core/inbox/mutations";
|
||||
import { IssueDetail, StatusIcon, PriorityIcon } from "@multica/views/issues/components";
|
||||
import { STATUS_CONFIG, PRIORITY_CONFIG } from "@multica/core/issues/config";
|
||||
import { useActorName } from "@multica/core/workspace/hooks";
|
||||
import { ActorAvatar } from "@multica/views/common/actor-avatar";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
ArrowRight,
|
||||
@@ -17,22 +31,21 @@ import {
|
||||
BookCheck,
|
||||
ListChecks,
|
||||
} from "lucide-react";
|
||||
import type { InboxItem, InboxItemType, IssueStatus, IssuePriority } from "@/shared/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { InboxItem, InboxItemType, IssueStatus, IssuePriority } from "@multica/core/types";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import {
|
||||
ResizablePanelGroup,
|
||||
ResizablePanel,
|
||||
ResizableHandle,
|
||||
} from "@/components/ui/resizable";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
} from "@multica/ui/components/ui/resizable";
|
||||
import { Skeleton } from "@multica/ui/components/ui/skeleton";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { api } from "@/shared/api";
|
||||
} from "@multica/ui/components/ui/dropdown-menu";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
@@ -219,14 +232,24 @@ function InboxListItem({
|
||||
|
||||
export default function InboxPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const selectedKey = searchParams.get("issue") ?? "";
|
||||
const setSelectedKey = (key: string) => {
|
||||
const urlIssue = searchParams.get("issue") ?? "";
|
||||
|
||||
const [selectedKey, setSelectedKeyState] = useState(() => urlIssue);
|
||||
|
||||
// Sync from URL when searchParams change (e.g. Next.js navigation)
|
||||
useEffect(() => {
|
||||
setSelectedKeyState(urlIssue);
|
||||
}, [urlIssue]);
|
||||
|
||||
const setSelectedKey = useCallback((key: string) => {
|
||||
setSelectedKeyState(key);
|
||||
const url = key ? `/inbox?issue=${key}` : "/inbox";
|
||||
window.history.replaceState(null, "", url);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const items = useInboxStore((s) => s.dedupedItems());
|
||||
const loading = useInboxStore((s) => s.loading);
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: rawItems = [], isLoading: loading } = useQuery(inboxListOptions(wsId));
|
||||
const items = useMemo(() => deduplicateInboxItems(rawItems), [rawItems]);
|
||||
|
||||
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
|
||||
id: "multica_inbox_layout",
|
||||
@@ -235,85 +258,69 @@ export default function InboxPage() {
|
||||
const selected = items.find((i) => (i.issue_id ?? i.id) === selectedKey) ?? null;
|
||||
const unreadCount = items.filter((i) => !i.read).length;
|
||||
|
||||
const markReadMutation = useMarkInboxRead();
|
||||
const archiveMutation = useArchiveInbox();
|
||||
const markAllReadMutation = useMarkAllInboxRead();
|
||||
const archiveAllMutation = useArchiveAllInbox();
|
||||
const archiveAllReadMutation = useArchiveAllReadInbox();
|
||||
const archiveCompletedMutation = useArchiveCompletedInbox();
|
||||
|
||||
// Click-to-read: select + auto-mark-read
|
||||
const handleSelect = async (item: InboxItem) => {
|
||||
const handleSelect = (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");
|
||||
}
|
||||
markReadMutation.mutate(item.id, {
|
||||
onError: () => 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");
|
||||
}
|
||||
const handleArchive = (id: string) => {
|
||||
const archived = items.find((i) => i.id === id);
|
||||
if (archived && (archived.issue_id ?? archived.id) === selectedKey) setSelectedKey("");
|
||||
archiveMutation.mutate(id, {
|
||||
onError: () => 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 handleMarkAllRead = () => {
|
||||
markAllReadMutation.mutate(undefined, {
|
||||
onError: () => toast.error("Failed to mark all as read"),
|
||||
});
|
||||
};
|
||||
|
||||
const handleArchiveAll = async () => {
|
||||
try {
|
||||
useInboxStore.getState().archiveAll();
|
||||
setSelectedKey("");
|
||||
await api.archiveAllInbox();
|
||||
} catch {
|
||||
toast.error("Failed to archive all");
|
||||
useInboxStore.getState().fetch();
|
||||
}
|
||||
const handleArchiveAll = () => {
|
||||
setSelectedKey("");
|
||||
archiveAllMutation.mutate(undefined, {
|
||||
onError: () => toast.error("Failed to archive all"),
|
||||
});
|
||||
};
|
||||
|
||||
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 handleArchiveAllRead = () => {
|
||||
const readKeys = items.filter((i) => i.read).map((i) => i.issue_id ?? i.id);
|
||||
if (readKeys.includes(selectedKey)) setSelectedKey("");
|
||||
archiveAllReadMutation.mutate(undefined, {
|
||||
onError: () => toast.error("Failed to archive read items"),
|
||||
});
|
||||
};
|
||||
|
||||
const handleArchiveCompleted = async () => {
|
||||
try {
|
||||
await api.archiveCompletedInbox();
|
||||
setSelectedKey("");
|
||||
await useInboxStore.getState().fetch();
|
||||
} catch {
|
||||
toast.error("Failed to archive completed");
|
||||
}
|
||||
const handleArchiveCompleted = () => {
|
||||
setSelectedKey("");
|
||||
archiveCompletedMutation.mutate(undefined, {
|
||||
onError: () => 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="overflow-y-auto border-r h-full">
|
||||
<div className="flex h-12 items-center border-b px-4">
|
||||
<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="space-y-1 p-2">
|
||||
<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" />
|
||||
@@ -341,8 +348,8 @@ export default function InboxPage() {
|
||||
<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="overflow-y-auto border-r h-full">
|
||||
<div className="flex h-12 items-center justify-between border-b px-4">
|
||||
<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 && (
|
||||
@@ -385,6 +392,7 @@ export default function InboxPage() {
|
||||
</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" />
|
||||
@@ -403,6 +411,7 @@ export default function InboxPage() {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle />
|
||||
@@ -411,9 +420,11 @@ export default function InboxPage() {
|
||||
<div className="flex flex-col min-h-0 h-full">
|
||||
{selected?.issue_id ? (
|
||||
<IssueDetail
|
||||
key={selected.id}
|
||||
issueId={selected.issue_id}
|
||||
defaultSidebarOpen={false}
|
||||
layoutId="multica_inbox_issue_detail_layout"
|
||||
highlightCommentId={selected.details?.comment_id ?? undefined}
|
||||
onDelete={() => {
|
||||
handleArchive(selected.id);
|
||||
}}
|
||||
|
||||
@@ -2,7 +2,9 @@ import { Suspense, forwardRef, useRef, useState, useImperativeHandle } from "rea
|
||||
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";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { Issue, Comment, TimelineEntry } from "@multica/core/types";
|
||||
import { WorkspaceIdProvider } from "@multica/core/hooks";
|
||||
|
||||
// Mock next/navigation
|
||||
vi.mock("next/navigation", () => ({
|
||||
@@ -28,7 +30,7 @@ vi.mock("next/link", () => ({
|
||||
}));
|
||||
|
||||
// Mock auth store
|
||||
vi.mock("@/features/auth", () => ({
|
||||
vi.mock("@/platform/auth", () => ({
|
||||
useAuthStore: (selector: (s: any) => any) =>
|
||||
selector({
|
||||
user: { id: "user-1", name: "Test User", email: "test@multica.ai" },
|
||||
@@ -36,6 +38,121 @@ vi.mock("@/features/auth", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock @multica/core/workspace (used by @multica/views components)
|
||||
vi.mock("@multica/core/workspace", () => ({
|
||||
useWorkspaceStore: Object.assign(
|
||||
(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" }],
|
||||
}),
|
||||
{ getState: () => ({
|
||||
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" }],
|
||||
}),
|
||||
},
|
||||
),
|
||||
registerWorkspaceStore: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock @multica/core/auth (used by @multica/views components)
|
||||
vi.mock("@multica/core/auth", () => ({
|
||||
useAuthStore: Object.assign(
|
||||
(selector: (s: any) => any) =>
|
||||
selector({
|
||||
user: { id: "user-1", name: "Test User", email: "test@multica.ai" },
|
||||
isLoading: false,
|
||||
}),
|
||||
{ getState: () => ({
|
||||
user: { id: "user-1", name: "Test User", email: "test@multica.ai" },
|
||||
isLoading: false,
|
||||
}),
|
||||
},
|
||||
),
|
||||
registerAuthStore: vi.fn(),
|
||||
createAuthStore: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock @multica/views/navigation (AppLink used by views components)
|
||||
vi.mock("@multica/views/navigation", () => ({
|
||||
AppLink: ({ children, href, ...props }: any) => <a href={href} {...props}>{children}</a>,
|
||||
useNavigation: () => ({ push: vi.fn(), pathname: "/issues/issue-1" }),
|
||||
NavigationProvider: ({ children }: { children: React.ReactNode }) => children,
|
||||
}));
|
||||
|
||||
// Mock @multica/views/editor (ContentEditor, TitleEditor used by IssueDetail)
|
||||
vi.mock("@multica/views/editor", () => ({
|
||||
ReadonlyContent: ({ content }: { content: string }) => (
|
||||
<div data-testid="readonly-content">{content}</div>
|
||||
),
|
||||
ContentEditor: forwardRef(({ defaultValue, onUpdate, placeholder, onSubmit }: any, ref: any) => {
|
||||
const valueRef = useRef(defaultValue || "");
|
||||
const [value, setValue] = useState(defaultValue || "");
|
||||
useImperativeHandle(ref, () => ({
|
||||
getMarkdown: () => valueRef.current,
|
||||
clearContent: () => { valueRef.current = ""; setValue(""); },
|
||||
focus: () => {},
|
||||
}));
|
||||
return (
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
valueRef.current = e.target.value;
|
||||
setValue(e.target.value);
|
||||
onUpdate?.(e.target.value);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
|
||||
onSubmit?.();
|
||||
}
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
data-testid="rich-text-editor"
|
||||
/>
|
||||
);
|
||||
}),
|
||||
TitleEditor: forwardRef(({ defaultValue, placeholder, onBlur, onChange }: any, ref: any) => {
|
||||
const valueRef = useRef(defaultValue || "");
|
||||
const [value, setValue] = useState(defaultValue || "");
|
||||
useImperativeHandle(ref, () => ({
|
||||
getText: () => valueRef.current,
|
||||
focus: () => {},
|
||||
}));
|
||||
return (
|
||||
<input
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
valueRef.current = e.target.value;
|
||||
setValue(e.target.value);
|
||||
onChange?.(e.target.value);
|
||||
}}
|
||||
onBlur={() => onBlur?.(valueRef.current)}
|
||||
placeholder={placeholder}
|
||||
data-testid="title-editor"
|
||||
/>
|
||||
);
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock @multica/views/workspace/workspace-avatar
|
||||
vi.mock("@multica/views/workspace/workspace-avatar", () => ({
|
||||
WorkspaceAvatar: ({ name }: { name: string }) => <span>{name.charAt(0)}</span>,
|
||||
}));
|
||||
|
||||
// Mock @multica/views/common/actor-avatar
|
||||
vi.mock("@multica/views/common/actor-avatar", () => ({
|
||||
ActorAvatar: ({ actorType, actorId }: any) => <span data-testid="actor-avatar">{actorType}:{actorId}</span>,
|
||||
}));
|
||||
|
||||
// Mock @multica/views/common/markdown
|
||||
vi.mock("@multica/views/common/markdown", () => ({
|
||||
Markdown: ({ children }: { children: string }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
// Mock workspace feature
|
||||
vi.mock("@/features/workspace", () => ({
|
||||
useWorkspaceStore: (selector: (s: any) => any) =>
|
||||
@@ -62,34 +179,47 @@ vi.mock("@/features/workspace", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
// 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("@/platform/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" }],
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock workspace hooks from core
|
||||
vi.mock("@multica/core/workspace/hooks", () => ({
|
||||
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 — only client state remains (activeIssueId)
|
||||
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() }) },
|
||||
(selector: (s: any) => any) => selector({ activeIssueId: null }),
|
||||
{ getState: () => ({ activeIssueId: null, setActiveIssue: vi.fn() }) },
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/issues", () => ({
|
||||
useIssueStore: Object.assign(
|
||||
(selector: (s: any) => any) => selector({ activeIssueId: null }),
|
||||
{ getState: () => ({ activeIssueId: null, setActiveIssue: vi.fn() }) },
|
||||
),
|
||||
}));
|
||||
|
||||
@@ -99,14 +229,26 @@ vi.mock("@/features/realtime", () => ({
|
||||
useWSReconnect: () => {},
|
||||
}));
|
||||
|
||||
// Mock core realtime (hooks now import from @multica/core/realtime)
|
||||
vi.mock("@multica/core/realtime", () => ({
|
||||
useWSEvent: () => {},
|
||||
useWSReconnect: () => {},
|
||||
useWS: () => ({ subscribe: vi.fn(() => () => {}), onReconnect: vi.fn(() => () => {}) }),
|
||||
WSProvider: ({ children }: { children: React.ReactNode }) => children,
|
||||
useRealtimeSync: () => {},
|
||||
}));
|
||||
|
||||
// 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) => {
|
||||
// Mock ContentEditor (Tiptap needs real DOM)
|
||||
vi.mock("@/features/editor", () => ({
|
||||
ReadonlyContent: ({ content }: { content: string }) => (
|
||||
<div data-testid="readonly-content">{content}</div>
|
||||
),
|
||||
ContentEditor: forwardRef(({ defaultValue, onUpdate, placeholder, onSubmit }: any, ref: any) => {
|
||||
const valueRef = useRef(defaultValue || "");
|
||||
const [value, setValue] = useState(defaultValue || "");
|
||||
useImperativeHandle(ref, () => ({
|
||||
@@ -132,6 +274,27 @@ vi.mock("@/components/common/rich-text-editor", () => ({
|
||||
/>
|
||||
);
|
||||
}),
|
||||
TitleEditor: forwardRef(({ defaultValue, placeholder, onBlur, onChange }: any, ref: any) => {
|
||||
const valueRef = useRef(defaultValue || "");
|
||||
const [value, setValue] = useState(defaultValue || "");
|
||||
useImperativeHandle(ref, () => ({
|
||||
getText: () => valueRef.current,
|
||||
focus: () => {},
|
||||
}));
|
||||
return (
|
||||
<input
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
valueRef.current = e.target.value;
|
||||
setValue(e.target.value);
|
||||
onChange?.(e.target.value);
|
||||
}}
|
||||
onBlur={() => onBlur?.(valueRef.current)}
|
||||
placeholder={placeholder}
|
||||
data-testid="title-editor"
|
||||
/>
|
||||
);
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock Markdown renderer
|
||||
@@ -139,29 +302,73 @@ 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());
|
||||
// Mock api (core queries/mutations use @multica/core/api, some components use @/platform/api)
|
||||
|
||||
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),
|
||||
const mockApiObj = vi.hoisted(() => ({
|
||||
getIssue: vi.fn(),
|
||||
listTimeline: vi.fn(),
|
||||
listComments: vi.fn().mockResolvedValue([]),
|
||||
createComment: vi.fn(),
|
||||
updateComment: vi.fn(),
|
||||
deleteComment: vi.fn(),
|
||||
deleteIssue: vi.fn(),
|
||||
updateIssue: vi.fn(),
|
||||
listIssueSubscribers: vi.fn().mockResolvedValue([]),
|
||||
subscribeToIssue: vi.fn().mockResolvedValue(undefined),
|
||||
unsubscribeFromIssue: vi.fn().mockResolvedValue(undefined),
|
||||
getActiveTasksForIssue: vi.fn().mockResolvedValue({ tasks: [] }),
|
||||
listTasksByIssue: vi.fn().mockResolvedValue([]),
|
||||
listTaskMessages: vi.fn().mockResolvedValue([]),
|
||||
listChildIssues: vi.fn().mockResolvedValue({ issues: [] }),
|
||||
listIssues: vi.fn().mockResolvedValue({ issues: [], total: 0 }),
|
||||
uploadFile: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/api", () => ({
|
||||
api: mockApiObj,
|
||||
getApi: () => mockApiObj,
|
||||
setApiInstance: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/platform/api", () => ({
|
||||
api: mockApiObj,
|
||||
}));
|
||||
|
||||
// Mock issue config from core
|
||||
vi.mock("@multica/core/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("@multica/core/modals", () => ({
|
||||
useModalStore: Object.assign(
|
||||
() => ({ open: vi.fn() }),
|
||||
{ getState: () => ({ open: vi.fn() }) },
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock utils
|
||||
vi.mock("@multica/core/utils", () => ({
|
||||
timeAgo: (date: string) => "1d ago",
|
||||
}));
|
||||
|
||||
const mockIssue: Issue = {
|
||||
@@ -178,6 +385,7 @@ const mockIssue: Issue = {
|
||||
creator_type: "member",
|
||||
creator_id: "user-1",
|
||||
parent_issue_id: null,
|
||||
project_id: null,
|
||||
position: 0,
|
||||
due_date: "2026-06-01T00:00:00Z",
|
||||
created_at: "2026-01-15T00:00:00Z",
|
||||
@@ -211,14 +419,28 @@ const mockTimeline: TimelineEntry[] = [
|
||||
|
||||
import IssueDetailPage from "./page";
|
||||
|
||||
function createTestQueryClient() {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false, gcTime: 0 },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// React 19 use(Promise) needs the promise to resolve within act + Suspense
|
||||
async function renderPage(id = "issue-1") {
|
||||
const queryClient = createTestQueryClient();
|
||||
let result: ReturnType<typeof render>;
|
||||
await act(async () => {
|
||||
result = render(
|
||||
<Suspense fallback={<div>Suspense loading...</div>}>
|
||||
<IssueDetailPage params={Promise.resolve({ id })} />
|
||||
</Suspense>,
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<WorkspaceIdProvider wsId="ws-1">
|
||||
<Suspense fallback={<div>Suspense loading...</div>}>
|
||||
<IssueDetailPage params={Promise.resolve({ id })} />
|
||||
</Suspense>
|
||||
</WorkspaceIdProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
return result!;
|
||||
@@ -230,8 +452,8 @@ describe("IssueDetailPage", () => {
|
||||
});
|
||||
|
||||
it("renders issue details after loading", async () => {
|
||||
mockGetIssue.mockResolvedValueOnce(mockIssue);
|
||||
mockListTimeline.mockResolvedValueOnce(mockTimeline);
|
||||
mockApiObj.getIssue.mockResolvedValueOnce(mockIssue);
|
||||
mockApiObj.listTimeline.mockResolvedValueOnce(mockTimeline);
|
||||
await renderPage();
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -246,8 +468,8 @@ describe("IssueDetailPage", () => {
|
||||
});
|
||||
|
||||
it("renders issue properties sidebar", async () => {
|
||||
mockGetIssue.mockResolvedValueOnce(mockIssue);
|
||||
mockListTimeline.mockResolvedValueOnce(mockTimeline);
|
||||
mockApiObj.getIssue.mockResolvedValueOnce(mockIssue);
|
||||
mockApiObj.listTimeline.mockResolvedValueOnce(mockTimeline);
|
||||
await renderPage();
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -259,8 +481,8 @@ describe("IssueDetailPage", () => {
|
||||
});
|
||||
|
||||
it("renders comments", async () => {
|
||||
mockGetIssue.mockResolvedValueOnce(mockIssue);
|
||||
mockListTimeline.mockResolvedValueOnce(mockTimeline);
|
||||
mockApiObj.getIssue.mockResolvedValueOnce(mockIssue);
|
||||
mockApiObj.listTimeline.mockResolvedValueOnce(mockTimeline);
|
||||
await renderPage();
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -275,8 +497,8 @@ describe("IssueDetailPage", () => {
|
||||
|
||||
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"));
|
||||
mockApiObj.getIssue.mockRejectedValue(new Error("Not found"));
|
||||
mockApiObj.listTimeline.mockRejectedValue(new Error("Not found"));
|
||||
await renderPage("nonexistent-id");
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -285,8 +507,8 @@ describe("IssueDetailPage", () => {
|
||||
});
|
||||
|
||||
it("submits a new comment", async () => {
|
||||
mockGetIssue.mockResolvedValueOnce(mockIssue);
|
||||
mockListTimeline.mockResolvedValueOnce(mockTimeline);
|
||||
mockApiObj.getIssue.mockResolvedValueOnce(mockIssue);
|
||||
mockApiObj.listTimeline.mockResolvedValueOnce(mockTimeline);
|
||||
|
||||
const newComment: Comment = {
|
||||
id: "comment-3",
|
||||
@@ -301,7 +523,7 @@ describe("IssueDetailPage", () => {
|
||||
created_at: "2026-01-18T00:00:00Z",
|
||||
updated_at: "2026-01-18T00:00:00Z",
|
||||
};
|
||||
mockCreateComment.mockResolvedValueOnce(newComment);
|
||||
mockApiObj.createComment.mockResolvedValueOnce(newComment);
|
||||
|
||||
const user = userEvent.setup();
|
||||
await renderPage();
|
||||
@@ -333,10 +555,10 @@ describe("IssueDetailPage", () => {
|
||||
await user.click(submitBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreateComment).toHaveBeenCalledWith(
|
||||
"issue-1",
|
||||
"New test comment",
|
||||
);
|
||||
expect(mockApiObj.createComment).toHaveBeenCalled();
|
||||
const [issueId, content] = mockApiObj.createComment.mock.calls[0]!;
|
||||
expect(issueId).toBe("issue-1");
|
||||
expect(content).toBe("New test comment");
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -345,8 +567,8 @@ describe("IssueDetailPage", () => {
|
||||
});
|
||||
|
||||
it("renders breadcrumb navigation", async () => {
|
||||
mockGetIssue.mockResolvedValueOnce(mockIssue);
|
||||
mockListTimeline.mockResolvedValueOnce(mockTimeline);
|
||||
mockApiObj.getIssue.mockResolvedValueOnce(mockIssue);
|
||||
mockApiObj.listTimeline.mockResolvedValueOnce(mockTimeline);
|
||||
await renderPage();
|
||||
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -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,7 +1,9 @@
|
||||
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";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { Issue } from "@multica/core/types";
|
||||
import { WorkspaceIdProvider } from "@multica/core/hooks";
|
||||
|
||||
// Mock next/navigation
|
||||
vi.mock("next/navigation", () => ({
|
||||
@@ -46,6 +48,54 @@ vi.mock("@/features/workspace", () => ({
|
||||
WorkspaceAvatar: ({ name }: { name: string }) => <span>{name.charAt(0)}</span>,
|
||||
}));
|
||||
|
||||
// Mock @multica/core/auth (used by @multica/views pickers like AssigneePicker)
|
||||
const mockAuthUser = { id: "user-1", email: "test@test.com", name: "Test User" };
|
||||
vi.mock("@multica/core/auth", () => ({
|
||||
useAuthStore: Object.assign(
|
||||
(selector?: any) => {
|
||||
const state = { user: mockAuthUser, isAuthenticated: true };
|
||||
return selector ? selector(state) : state;
|
||||
},
|
||||
{ getState: () => ({ user: mockAuthUser, isAuthenticated: true }) },
|
||||
),
|
||||
registerAuthStore: vi.fn(),
|
||||
createAuthStore: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock @multica/core/workspace (used by @multica/views components)
|
||||
vi.mock("@multica/core/workspace", () => ({
|
||||
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: [] }) },
|
||||
),
|
||||
registerWorkspaceStore: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/platform/workspace", () => ({
|
||||
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: [] }) },
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock @multica/views/navigation (AppLink used by views components)
|
||||
vi.mock("@multica/views/navigation", () => ({
|
||||
AppLink: ({ children, href, ...props }: any) => <a href={href} {...props}>{children}</a>,
|
||||
useNavigation: () => ({ push: vi.fn(), pathname: "/issues" }),
|
||||
NavigationProvider: ({ children }: { children: React.ReactNode }) => children,
|
||||
}));
|
||||
|
||||
// Mock @multica/views/workspace/workspace-avatar
|
||||
vi.mock("@multica/views/workspace/workspace-avatar", () => ({
|
||||
WorkspaceAvatar: ({ name }: { name: string }) => <span>{name.charAt(0)}</span>,
|
||||
}));
|
||||
|
||||
// Mock WebSocket context
|
||||
vi.mock("@/features/realtime", () => ({
|
||||
useWSEvent: vi.fn(),
|
||||
@@ -59,38 +109,39 @@ vi.mock("sonner", () => ({
|
||||
toast: { error: vi.fn(), success: vi.fn() },
|
||||
}));
|
||||
|
||||
// Mock api
|
||||
// Mock api (core queries/mutations use @multica/core/api)
|
||||
const mockUpdateIssue = vi.fn();
|
||||
const mockListIssues = vi.hoisted(() => vi.fn().mockResolvedValue({ issues: [], total: 0 }));
|
||||
|
||||
vi.mock("@/shared/api", () => ({
|
||||
vi.mock("@multica/core/api", () => ({
|
||||
api: {
|
||||
listIssues: vi.fn().mockResolvedValue({ issues: [], total: 0 }),
|
||||
listIssues: (...args: any[]) => mockListIssues(...args),
|
||||
updateIssue: (...args: any[]) => mockUpdateIssue(...args),
|
||||
listMembers: () => Promise.resolve([]),
|
||||
listAgents: () => Promise.resolve([]),
|
||||
},
|
||||
getApi: () => ({
|
||||
listIssues: (...args: any[]) => mockListIssues(...args),
|
||||
updateIssue: (...args: any[]) => mockUpdateIssue(...args),
|
||||
listMembers: () => Promise.resolve([]),
|
||||
listAgents: () => Promise.resolve([]),
|
||||
}),
|
||||
setApiInstance: vi.fn(),
|
||||
}));
|
||||
|
||||
// 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", () => ({
|
||||
// Mock issue store — only client state remains
|
||||
const mockIssueClientState = { activeIssueId: null, setActiveIssue: vi.fn() };
|
||||
vi.mock("@multica/core/issues", () => ({
|
||||
useIssueStore: Object.assign(
|
||||
(selector?: any) => (selector ? selector(mockStoreState) : mockStoreState),
|
||||
{ getState: () => mockStoreState },
|
||||
(selector?: any) => (selector ? selector(mockIssueClientState) : mockIssueClientState),
|
||||
{ getState: () => mockIssueClientState },
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/features/issues", () => ({
|
||||
useIssueStore: Object.assign(
|
||||
(selector?: any) => (selector ? selector(mockStoreState) : mockStoreState),
|
||||
{ getState: () => mockStoreState },
|
||||
(selector?: any) => (selector ? selector(mockIssueClientState) : mockIssueClientState),
|
||||
{ getState: () => mockIssueClientState },
|
||||
),
|
||||
StatusIcon: () => null,
|
||||
PriorityIcon: () => null,
|
||||
@@ -129,12 +180,17 @@ const mockViewState = {
|
||||
toggleListCollapsed: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock("@/features/issues/stores/view-store", () => ({
|
||||
vi.mock("@multica/core/issues/stores/view-store", () => ({
|
||||
initFilterWorkspaceSync: vi.fn(),
|
||||
useIssueViewStore: Object.assign(
|
||||
(selector?: any) => (selector ? selector(mockViewState) : mockViewState),
|
||||
{ getState: () => mockViewState, setState: vi.fn() },
|
||||
),
|
||||
createIssueViewStore: () => ({
|
||||
getState: () => mockViewState,
|
||||
setState: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
}),
|
||||
SORT_OPTIONS: [
|
||||
{ value: "position", label: "Manual" },
|
||||
{ value: "priority", label: "Priority" },
|
||||
@@ -150,9 +206,39 @@ vi.mock("@/features/issues/stores/view-store", () => ({
|
||||
],
|
||||
}));
|
||||
|
||||
// Mock view store context (shared components read from context)
|
||||
vi.mock("@multica/core/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 issues scope store
|
||||
vi.mock("@multica/core/issues/stores/issues-scope-store", () => ({
|
||||
useIssuesScopeStore: Object.assign(
|
||||
(selector?: any) => {
|
||||
const state = { scope: "all", setScope: vi.fn() };
|
||||
return selector ? selector(state) : state;
|
||||
},
|
||||
{ getState: () => ({ scope: "all", setScope: vi.fn() }) },
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock selection store
|
||||
vi.mock("@multica/core/issues/stores/selection-store", () => ({
|
||||
useIssueSelectionStore: Object.assign(
|
||||
(selector?: any) => {
|
||||
const state = { selectedIds: new Set(), toggle: vi.fn(), clear: vi.fn(), setAll: vi.fn() };
|
||||
return selector ? selector(state) : state;
|
||||
},
|
||||
{ getState: () => ({ selectedIds: new Set(), toggle: vi.fn(), clear: vi.fn(), setAll: vi.fn() }) },
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock issue config
|
||||
vi.mock("@/features/issues/config", () => ({
|
||||
vi.mock("@multica/core/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" },
|
||||
@@ -174,6 +260,13 @@ vi.mock("@/features/issues/config", () => ({
|
||||
}));
|
||||
|
||||
// Mock modals
|
||||
vi.mock("@multica/core/modals", () => ({
|
||||
useModalStore: Object.assign(
|
||||
() => ({ open: vi.fn() }),
|
||||
{ getState: () => ({ open: vi.fn() }) },
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/features/modals", () => ({
|
||||
useModalStore: Object.assign(
|
||||
() => ({ open: vi.fn() }),
|
||||
@@ -212,6 +305,7 @@ vi.mock("@dnd-kit/utilities", () => ({
|
||||
|
||||
const issueDefaults = {
|
||||
parent_issue_id: null,
|
||||
project_id: null,
|
||||
position: 0,
|
||||
};
|
||||
|
||||
@@ -274,87 +368,86 @@ const mockIssues: Issue[] = [
|
||||
|
||||
import IssuesPage from "./page";
|
||||
|
||||
function renderWithQuery(ui: React.ReactElement) {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 }, mutations: { retry: false } } });
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<WorkspaceIdProvider wsId="ws-1">
|
||||
{ui}
|
||||
</WorkspaceIdProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("IssuesPage", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockStoreState = {
|
||||
issues: [],
|
||||
loading: true,
|
||||
fetch: vi.fn(),
|
||||
setIssues: vi.fn(),
|
||||
addIssue: vi.fn(),
|
||||
updateIssue: vi.fn(),
|
||||
removeIssue: vi.fn(),
|
||||
};
|
||||
mockListIssues.mockResolvedValue({ issues: [], total: 0 });
|
||||
mockViewState.viewMode = "board";
|
||||
mockViewState.statusFilters = [];
|
||||
mockViewState.priorityFilters = [];
|
||||
});
|
||||
|
||||
it("shows loading state initially", () => {
|
||||
mockStoreState.loading = true;
|
||||
mockStoreState.issues = [];
|
||||
render(<IssuesPage />);
|
||||
renderWithQuery(<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;
|
||||
// issueListOptions makes 2 calls: open_only + closed page. Return issues for open, empty for closed.
|
||||
mockListIssues.mockImplementation((params: any) =>
|
||||
Promise.resolve(params?.open_only ? { issues: mockIssues, total: mockIssues.length } : { issues: [], total: 0 }),
|
||||
);
|
||||
|
||||
render(<IssuesPage />);
|
||||
renderWithQuery(<IssuesPage />);
|
||||
|
||||
expect(screen.getByText("Implement auth")).toBeInTheDocument();
|
||||
await screen.findByText("Implement auth");
|
||||
expect(screen.getByText("Design landing page")).toBeInTheDocument();
|
||||
expect(screen.getByText("Write tests")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders board columns", async () => {
|
||||
mockStoreState.loading = false;
|
||||
mockStoreState.issues = mockIssues;
|
||||
mockListIssues.mockImplementation((params: any) =>
|
||||
Promise.resolve(params?.open_only ? { issues: mockIssues, total: mockIssues.length } : { issues: [], total: 0 }),
|
||||
);
|
||||
|
||||
render(<IssuesPage />);
|
||||
renderWithQuery(<IssuesPage />);
|
||||
|
||||
expect(screen.getAllByText("Backlog").length).toBeGreaterThanOrEqual(1);
|
||||
await screen.findByText("Backlog");
|
||||
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 = [];
|
||||
it("shows workspace breadcrumb", async () => {
|
||||
renderWithQuery(<IssuesPage />);
|
||||
|
||||
render(<IssuesPage />);
|
||||
|
||||
expect(screen.getByText("Issues")).toBeInTheDocument();
|
||||
await screen.findByText("Issues");
|
||||
});
|
||||
|
||||
it("shows 'New Issue' button", () => {
|
||||
mockStoreState.loading = false;
|
||||
mockStoreState.issues = [];
|
||||
it("shows scope buttons", async () => {
|
||||
renderWithQuery(<IssuesPage />);
|
||||
|
||||
render(<IssuesPage />);
|
||||
|
||||
expect(screen.getByText("New Issue")).toBeInTheDocument();
|
||||
await screen.findByText("All");
|
||||
expect(screen.getByText("Members")).toBeInTheDocument();
|
||||
expect(screen.getByText("Agents")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows filter buttons", () => {
|
||||
mockStoreState.loading = false;
|
||||
mockStoreState.issues = mockIssues;
|
||||
it("shows filter and display icon buttons", async () => {
|
||||
mockListIssues.mockImplementation((params: any) =>
|
||||
Promise.resolve(params?.open_only ? { issues: mockIssues, total: mockIssues.length } : { issues: [], total: 0 }),
|
||||
);
|
||||
|
||||
render(<IssuesPage />);
|
||||
renderWithQuery(<IssuesPage />);
|
||||
|
||||
expect(screen.getByText("Filter")).toBeInTheDocument();
|
||||
expect(screen.getByText("Display")).toBeInTheDocument();
|
||||
await screen.findByText("Implement auth");
|
||||
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 />);
|
||||
renderWithQuery(<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 />;
|
||||
|
||||
@@ -3,10 +3,13 @@
|
||||
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 { useNavigationStore } from "@multica/core/navigation";
|
||||
import { SidebarProvider, SidebarInset } from "@multica/ui/components/ui/sidebar";
|
||||
import { useAuthStore } from "@/platform/auth";
|
||||
import { useWorkspaceStore } from "@/platform/workspace";
|
||||
import { WorkspaceIdProvider } from "@multica/core/hooks";
|
||||
import { ModalRegistry } from "@multica/views/modals/registry";
|
||||
import { SearchCommand } from "@/features/search";
|
||||
import { AppSidebar } from "./_components/app-sidebar";
|
||||
|
||||
export default function DashboardLayout({
|
||||
@@ -22,7 +25,7 @@ export default function DashboardLayout({
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !user) {
|
||||
router.push("/login");
|
||||
router.push("/");
|
||||
}
|
||||
}, [user, isLoading, router]);
|
||||
|
||||
@@ -40,18 +43,28 @@ export default function DashboardLayout({
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
// AppSidebar (and other dashboard children) call hooks that depend on the
|
||||
// workspace id via useWorkspaceId(), so the entire dashboard tree must
|
||||
// mount inside WorkspaceIdProvider. Show a fullscreen loader while the
|
||||
// workspace is still being resolved.
|
||||
if (!workspace) {
|
||||
return (
|
||||
<div className="flex h-svh items-center justify-center">
|
||||
<MulticaIcon className="size-6 animate-pulse" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
<WorkspaceIdProvider wsId={workspace.id}>
|
||||
<SidebarProvider className="h-svh">
|
||||
<AppSidebar />
|
||||
<SidebarInset className="overflow-hidden">
|
||||
{children}
|
||||
<ModalRegistry />
|
||||
</SidebarInset>
|
||||
<SearchCommand />
|
||||
</SidebarProvider>
|
||||
</WorkspaceIdProvider>
|
||||
);
|
||||
}
|
||||
|
||||
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";
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Camera, Loader2, Save } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Input } from "@multica/ui/components/ui/input";
|
||||
import { Label } from "@multica/ui/components/ui/label";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { Card, CardContent } from "@multica/ui/components/ui/card";
|
||||
import { toast } from "sonner";
|
||||
import { useAuthStore } from "@/features/auth";
|
||||
import { api } from "@/shared/api";
|
||||
import { useFileUpload } from "@/shared/hooks/use-file-upload";
|
||||
import { useAuthStore } from "@/platform/auth";
|
||||
import { api } from "@/platform/api";
|
||||
import { useFileUpload } from "@multica/core/hooks/use-file-upload";
|
||||
|
||||
export function AccountTab() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
@@ -17,7 +17,7 @@ export function AccountTab() {
|
||||
|
||||
const [profileName, setProfileName] = useState(user?.name ?? "");
|
||||
const [profileSaving, setProfileSaving] = useState(false);
|
||||
const { upload, uploading } = useFileUpload();
|
||||
const { upload, uploading } = useFileUpload(api);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useTheme } from "next-themes";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
|
||||
const LIGHT_COLORS = {
|
||||
titleBar: "#e8e8e8",
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { Crown, Shield, User, Plus, MoreHorizontal, UserMinus, Users } from "lucide-react";
|
||||
import type { MemberWithUser, MemberRole } from "@/shared/types";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ActorAvatar } from "@multica/views/common/actor-avatar";
|
||||
import type { MemberWithUser, MemberRole } from "@multica/core/types";
|
||||
import { Input } from "@multica/ui/components/ui/input";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { Card, CardContent } from "@multica/ui/components/ui/card";
|
||||
import { Badge } from "@multica/ui/components/ui/badge";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogContent,
|
||||
@@ -16,14 +17,14 @@ import {
|
||||
AlertDialogFooter,
|
||||
AlertDialogCancel,
|
||||
AlertDialogAction,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
} from "@multica/ui/components/ui/alert-dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
} from "@/components/ui/select";
|
||||
} from "@multica/ui/components/ui/select";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
@@ -33,11 +34,14 @@ import {
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
} from "@multica/ui/components/ui/dropdown-menu";
|
||||
import { toast } from "sonner";
|
||||
import { useAuthStore } from "@/features/auth";
|
||||
import { useWorkspaceStore } from "@/features/workspace";
|
||||
import { api } from "@/shared/api";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useAuthStore } from "@/platform/auth";
|
||||
import { useWorkspaceStore } from "@/platform/workspace";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { memberListOptions, workspaceKeys } from "@multica/core/workspace/queries";
|
||||
import { api } from "@/platform/api";
|
||||
|
||||
const roleConfig: Record<MemberRole, { label: string; icon: typeof Crown; description: string }> = {
|
||||
owner: { label: "Owner", icon: Crown, description: "Full access, manage all settings" },
|
||||
@@ -70,14 +74,7 @@ function MemberRow({
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 px-4 py-3">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-semibold">
|
||||
{member.name
|
||||
.split(" ")
|
||||
.map((w) => w[0])
|
||||
.join("")
|
||||
.toUpperCase()
|
||||
.slice(0, 2)}
|
||||
</div>
|
||||
<ActorAvatar actorType="member" actorId={member.user_id} size={32} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium truncate">{member.name}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">{member.email}</div>
|
||||
@@ -146,8 +143,9 @@ function MemberRow({
|
||||
export function MembersTab() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const workspace = useWorkspaceStore((s) => s.workspace);
|
||||
const members = useWorkspaceStore((s) => s.members);
|
||||
const refreshMembers = useWorkspaceStore((s) => s.refreshMembers);
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: members = [] } = useQuery(memberListOptions(wsId));
|
||||
|
||||
const [inviteEmail, setInviteEmail] = useState("");
|
||||
const [inviteRole, setInviteRole] = useState<MemberRole>("member");
|
||||
@@ -174,7 +172,7 @@ export function MembersTab() {
|
||||
});
|
||||
setInviteEmail("");
|
||||
setInviteRole("member");
|
||||
await refreshMembers();
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.members(wsId) });
|
||||
toast.success("Member added");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to add member");
|
||||
@@ -188,7 +186,7 @@ export function MembersTab() {
|
||||
setMemberActionId(memberId);
|
||||
try {
|
||||
await api.updateMember(workspace.id, memberId, { role });
|
||||
await refreshMembers();
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.members(wsId) });
|
||||
toast.success("Role updated");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to update member");
|
||||
@@ -207,7 +205,7 @@ export function MembersTab() {
|
||||
setMemberActionId(member.id);
|
||||
try {
|
||||
await api.deleteMember(workspace.id, member.id);
|
||||
await refreshMembers();
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.members(wsId) });
|
||||
toast.success("Member removed");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to remove member");
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Save, Plus, Trash2 } from "lucide-react";
|
||||
import { Input } from "@multica/ui/components/ui/input";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { Card, CardContent } from "@multica/ui/components/ui/card";
|
||||
import { toast } from "sonner";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAuthStore } from "@/platform/auth";
|
||||
import { useWorkspaceStore } from "@/platform/workspace";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { memberListOptions } from "@multica/core/workspace/queries";
|
||||
import { api } from "@/platform/api";
|
||||
import type { WorkspaceRepo } from "@multica/core/types";
|
||||
|
||||
export function RepositoriesTab() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const workspace = useWorkspaceStore((s) => s.workspace);
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: members = [] } = useQuery(memberListOptions(wsId));
|
||||
const updateWorkspace = useWorkspaceStore((s) => s.updateWorkspace);
|
||||
|
||||
const [repos, setRepos] = useState<WorkspaceRepo[]>(workspace?.repos ?? []);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const currentMember = members.find((m) => m.user_id === user?.id) ?? null;
|
||||
const canManageWorkspace = currentMember?.role === "owner" || currentMember?.role === "admin";
|
||||
|
||||
useEffect(() => {
|
||||
setRepos(workspace?.repos ?? []);
|
||||
}, [workspace]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!workspace) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const updated = await api.updateWorkspace(workspace.id, { repos });
|
||||
updateWorkspace(updated);
|
||||
toast.success("Repositories saved");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to save repositories");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddRepo = () => {
|
||||
setRepos([...repos, { url: "", description: "" }]);
|
||||
};
|
||||
|
||||
const handleRemoveRepo = (index: number) => {
|
||||
setRepos(repos.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleRepoChange = (index: number, field: keyof WorkspaceRepo, value: string) => {
|
||||
setRepos(repos.map((r, i) => (i === index ? { ...r, [field]: value } : r)));
|
||||
};
|
||||
|
||||
if (!workspace) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-sm font-semibold">Repositories</h2>
|
||||
|
||||
<Card>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
GitHub repositories associated with this workspace. Agents use these to clone and work on code.
|
||||
</p>
|
||||
|
||||
{repos.map((repo, index) => (
|
||||
<div key={index} className="flex gap-2">
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<Input
|
||||
type="url"
|
||||
value={repo.url}
|
||||
onChange={(e) => handleRepoChange(index, "url", e.target.value)}
|
||||
disabled={!canManageWorkspace}
|
||||
placeholder="https://github.com/org/repo"
|
||||
className="text-sm"
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
value={repo.description}
|
||||
onChange={(e) => handleRepoChange(index, "description", e.target.value)}
|
||||
disabled={!canManageWorkspace}
|
||||
placeholder="Description (e.g. Go backend + Next.js frontend)"
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
{canManageWorkspace && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="mt-0.5 shrink-0 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => handleRemoveRepo(index)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{canManageWorkspace && (
|
||||
<div className="flex items-center justify-between pt-1">
|
||||
<Button variant="outline" size="sm" onClick={handleAddRepo}>
|
||||
<Plus className="h-3 w-3" />
|
||||
Add repository
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
>
|
||||
<Save className="h-3 w-3" />
|
||||
{saving ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!canManageWorkspace && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Only admins and owners can manage repositories.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,18 +2,18 @@
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { Key, Trash2, Copy, Check } from "lucide-react";
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
|
||||
import type { PersonalAccessToken } from "@/shared/types";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
|
||||
import type { PersonalAccessToken } from "@multica/core/types";
|
||||
import { Input } from "@multica/ui/components/ui/input";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { Card, CardContent } from "@multica/ui/components/ui/card";
|
||||
import {
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
} from "@/components/ui/select";
|
||||
} from "@multica/ui/components/ui/select";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -21,9 +21,20 @@ import {
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
} from "@multica/ui/components/ui/dialog";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@multica/ui/components/ui/alert-dialog";
|
||||
import { Skeleton } from "@multica/ui/components/ui/skeleton";
|
||||
import { toast } from "sonner";
|
||||
import { api } from "@/shared/api";
|
||||
import { api } from "@/platform/api";
|
||||
|
||||
export function TokensTab() {
|
||||
const [tokens, setTokens] = useState<PersonalAccessToken[]>([]);
|
||||
@@ -33,13 +44,17 @@ export function TokensTab() {
|
||||
const [newToken, setNewToken] = useState<string | null>(null);
|
||||
const [tokenCopied, setTokenCopied] = useState(false);
|
||||
const [tokenRevoking, setTokenRevoking] = useState<string | null>(null);
|
||||
const [revokeConfirmId, setRevokeConfirmId] = useState<string | null>(null);
|
||||
const [tokensLoading, setTokensLoading] = useState(true);
|
||||
|
||||
const loadTokens = useCallback(async () => {
|
||||
try {
|
||||
const list = await api.listPersonalAccessTokens();
|
||||
setTokens(list);
|
||||
} catch {
|
||||
// ignore — tokens section simply stays empty
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to load tokens");
|
||||
} finally {
|
||||
setTokensLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -117,7 +132,21 @@ export function TokensTab() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{tokens.length > 0 && (
|
||||
{tokensLoading ? (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 2 }).map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardContent className="flex items-center gap-3">
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-3 w-48" />
|
||||
</div>
|
||||
<Skeleton className="h-8 w-8 rounded" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : tokens.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{tokens.map((t) => (
|
||||
<Card key={t.id}>
|
||||
@@ -135,7 +164,7 @@ export function TokensTab() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => handleRevokeToken(t.id)}
|
||||
onClick={() => setRevokeConfirmId(t.id)}
|
||||
disabled={tokenRevoking === t.id}
|
||||
aria-label={`Revoke ${t.name}`}
|
||||
>
|
||||
@@ -152,6 +181,29 @@ export function TokensTab() {
|
||||
)}
|
||||
</section>
|
||||
|
||||
<AlertDialog open={!!revokeConfirmId} onOpenChange={(v) => { if (!v) setRevokeConfirmId(null); }}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Revoke token</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This token will be permanently revoked and can no longer be used. This cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
onClick={async () => {
|
||||
if (revokeConfirmId) await handleRevokeToken(revokeConfirmId);
|
||||
setRevokeConfirmId(null);
|
||||
}}
|
||||
>
|
||||
Revoke
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<Dialog open={!!newToken} onOpenChange={(v) => { if (!v) { setNewToken(null); setTokenCopied(false); } }}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Save, LogOut, Plus, Trash2 } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Save, LogOut } from "lucide-react";
|
||||
import { Input } from "@multica/ui/components/ui/input";
|
||||
import { Textarea } from "@multica/ui/components/ui/textarea";
|
||||
import { Label } from "@multica/ui/components/ui/label";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { Card, CardContent } from "@multica/ui/components/ui/card";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogContent,
|
||||
@@ -16,17 +16,20 @@ import {
|
||||
AlertDialogFooter,
|
||||
AlertDialogCancel,
|
||||
AlertDialogAction,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
} from "@multica/ui/components/ui/alert-dialog";
|
||||
import { toast } from "sonner";
|
||||
import { useAuthStore } from "@/features/auth";
|
||||
import { useWorkspaceStore } from "@/features/workspace";
|
||||
import { api } from "@/shared/api";
|
||||
import type { WorkspaceRepo } from "@/shared/types";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAuthStore } from "@/platform/auth";
|
||||
import { useWorkspaceStore } from "@/platform/workspace";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { memberListOptions } from "@multica/core/workspace/queries";
|
||||
import { api } from "@/platform/api";
|
||||
|
||||
export function WorkspaceTab() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const workspace = useWorkspaceStore((s) => s.workspace);
|
||||
const members = useWorkspaceStore((s) => s.members);
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: members = [] } = useQuery(memberListOptions(wsId));
|
||||
const updateWorkspace = useWorkspaceStore((s) => s.updateWorkspace);
|
||||
const leaveWorkspace = useWorkspaceStore((s) => s.leaveWorkspace);
|
||||
const deleteWorkspace = useWorkspaceStore((s) => s.deleteWorkspace);
|
||||
@@ -34,7 +37,6 @@ export function WorkspaceTab() {
|
||||
const [name, setName] = useState(workspace?.name ?? "");
|
||||
const [description, setDescription] = useState(workspace?.description ?? "");
|
||||
const [context, setContext] = useState(workspace?.context ?? "");
|
||||
const [repos, setRepos] = useState<WorkspaceRepo[]>(workspace?.repos ?? []);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [actionId, setActionId] = useState<string | null>(null);
|
||||
const [confirmAction, setConfirmAction] = useState<{
|
||||
@@ -52,7 +54,6 @@ export function WorkspaceTab() {
|
||||
setName(workspace?.name ?? "");
|
||||
setDescription(workspace?.description ?? "");
|
||||
setContext(workspace?.context ?? "");
|
||||
setRepos(workspace?.repos ?? []);
|
||||
}, [workspace]);
|
||||
|
||||
const handleSave = async () => {
|
||||
@@ -63,7 +64,6 @@ export function WorkspaceTab() {
|
||||
name,
|
||||
description,
|
||||
context,
|
||||
repos,
|
||||
});
|
||||
updateWorkspace(updated);
|
||||
toast.success("Workspace settings saved");
|
||||
@@ -74,18 +74,6 @@ export function WorkspaceTab() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddRepo = () => {
|
||||
setRepos([...repos, { url: "", description: "" }]);
|
||||
};
|
||||
|
||||
const handleRemoveRepo = (index: number) => {
|
||||
setRepos(repos.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleRepoChange = (index: number, field: keyof WorkspaceRepo, value: string) => {
|
||||
setRepos(repos.map((r, i) => (i === index ? { ...r, [field]: value } : r)));
|
||||
};
|
||||
|
||||
const handleLeaveWorkspace = () => {
|
||||
if (!workspace) return;
|
||||
setConfirmAction({
|
||||
@@ -191,69 +179,6 @@ export function WorkspaceTab() {
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* Repositories */}
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-sm font-semibold">Repositories</h2>
|
||||
|
||||
<Card>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
GitHub repositories associated with this workspace. Agents use these to clone and work on code.
|
||||
</p>
|
||||
|
||||
{repos.map((repo, index) => (
|
||||
<div key={index} className="flex gap-2">
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<Input
|
||||
type="url"
|
||||
value={repo.url}
|
||||
onChange={(e) => handleRepoChange(index, "url", e.target.value)}
|
||||
disabled={!canManageWorkspace}
|
||||
placeholder="https://github.com/org/repo"
|
||||
className="text-sm"
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
value={repo.description}
|
||||
onChange={(e) => handleRepoChange(index, "description", e.target.value)}
|
||||
disabled={!canManageWorkspace}
|
||||
placeholder="Description (e.g. Go backend + Next.js frontend)"
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
{canManageWorkspace && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="mt-0.5 shrink-0 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => handleRemoveRepo(index)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{canManageWorkspace && (
|
||||
<div className="flex items-center justify-between pt-1">
|
||||
<Button variant="outline" size="sm" onClick={handleAddRepo}>
|
||||
<Plus className="h-3 w-3" />
|
||||
Add repository
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={saving || !name.trim() || !canManageWorkspace}
|
||||
>
|
||||
<Save className="h-3 w-3" />
|
||||
{saving ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* Danger Zone */}
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
"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 { User, Palette, Key, Settings, Users, FolderGit2 } from "lucide-react";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@multica/ui/components/ui/tabs";
|
||||
import { useWorkspaceStore } from "@/platform/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";
|
||||
import { RepositoriesTab } from "./_components/repositories-tab";
|
||||
|
||||
const accountTabs = [
|
||||
{ value: "profile", label: "Profile", icon: User },
|
||||
@@ -17,6 +18,7 @@ const accountTabs = [
|
||||
|
||||
const workspaceTabs = [
|
||||
{ value: "workspace", label: "General", icon: Settings },
|
||||
{ value: "repositories", label: "Repositories", icon: FolderGit2 },
|
||||
{ value: "members", label: "Members", icon: Users },
|
||||
];
|
||||
|
||||
@@ -60,6 +62,7 @@ export default function SettingsPage() {
|
||||
<TabsContent value="appearance"><AppearanceTab /></TabsContent>
|
||||
<TabsContent value="tokens"><TokensTab /></TabsContent>
|
||||
<TabsContent value="workspace"><WorkspaceTab /></TabsContent>
|
||||
<TabsContent value="repositories"><RepositoriesTab /></TabsContent>
|
||||
<TabsContent value="members"><MembersTab /></TabsContent>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1 +1 @@
|
||||
export { SkillsPage as default } from "@/features/skills";
|
||||
export { SkillsPage as default } from "@multica/views/skills";
|
||||
|
||||
21
apps/web/app/(landing)/about/page.tsx
Normal file
21
apps/web/app/(landing)/about/page.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { Metadata } from "next";
|
||||
import { AboutPageClient } from "@/features/landing/components/about-page-client";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "About",
|
||||
description:
|
||||
"Learn about Multica — multiplexed information and computing agent. An open-source AI-native task management platform.",
|
||||
openGraph: {
|
||||
title: "About Multica",
|
||||
description:
|
||||
"The story behind Multica and why we're building AI-native task management.",
|
||||
url: "/about",
|
||||
},
|
||||
alternates: {
|
||||
canonical: "/about",
|
||||
},
|
||||
};
|
||||
|
||||
export default function AboutPage() {
|
||||
return <AboutPageClient />;
|
||||
}
|
||||
20
apps/web/app/(landing)/changelog/page.tsx
Normal file
20
apps/web/app/(landing)/changelog/page.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { Metadata } from "next";
|
||||
import { ChangelogPageClient } from "@/features/landing/components/changelog-page-client";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Changelog",
|
||||
description:
|
||||
"See what's new in Multica — latest features, improvements, and fixes.",
|
||||
openGraph: {
|
||||
title: "Changelog | Multica",
|
||||
description: "Latest updates and releases from Multica.",
|
||||
url: "/changelog",
|
||||
},
|
||||
alternates: {
|
||||
canonical: "/changelog",
|
||||
},
|
||||
};
|
||||
|
||||
export default function ChangelogPage() {
|
||||
return <ChangelogPageClient />;
|
||||
}
|
||||
21
apps/web/app/(landing)/homepage/page.tsx
Normal file
21
apps/web/app/(landing)/homepage/page.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { Metadata } from "next";
|
||||
import { MulticaLanding } from "@/features/landing/components/multica-landing";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Homepage",
|
||||
description:
|
||||
"Multica — open-source platform that turns coding agents into real teammates. Assign tasks, track progress, compound skills.",
|
||||
openGraph: {
|
||||
title: "Multica — AI-Native Task Management",
|
||||
description:
|
||||
"Manage your human + agent workforce in one place.",
|
||||
url: "/homepage",
|
||||
},
|
||||
alternates: {
|
||||
canonical: "/homepage",
|
||||
},
|
||||
};
|
||||
|
||||
export default function HomepagePage() {
|
||||
return <MulticaLanding />;
|
||||
}
|
||||
75
apps/web/app/(landing)/layout.tsx
Normal file
75
apps/web/app/(landing)/layout.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { Instrument_Serif, Noto_Serif_SC } from "next/font/google";
|
||||
import { LocaleProvider } from "@/features/landing/i18n";
|
||||
import type { Locale } from "@/features/landing/i18n";
|
||||
|
||||
const instrumentSerif = Instrument_Serif({
|
||||
subsets: ["latin"],
|
||||
weight: "400",
|
||||
variable: "--font-serif",
|
||||
});
|
||||
|
||||
const notoSerifSC = Noto_Serif_SC({
|
||||
subsets: ["latin"],
|
||||
weight: "400",
|
||||
variable: "--font-serif-zh",
|
||||
});
|
||||
|
||||
const jsonLd = {
|
||||
"@context": "https://schema.org",
|
||||
"@graph": [
|
||||
{
|
||||
"@type": "Organization",
|
||||
name: "Multica",
|
||||
url: "https://www.multica.ai",
|
||||
sameAs: ["https://github.com/multica-ai/multica"],
|
||||
},
|
||||
{
|
||||
"@type": "SoftwareApplication",
|
||||
name: "Multica",
|
||||
applicationCategory: "ProjectManagement",
|
||||
operatingSystem: "Web",
|
||||
description:
|
||||
"AI-native task management platform that turns coding agents into real teammates.",
|
||||
offers: {
|
||||
"@type": "Offer",
|
||||
price: "0",
|
||||
priceCurrency: "USD",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
async function getInitialLocale(): Promise<Locale> {
|
||||
// 1. User's explicit preference (cookie set when they switch language)
|
||||
const cookieStore = await cookies();
|
||||
const stored = cookieStore.get("multica-locale")?.value;
|
||||
if (stored === "en" || stored === "zh") return stored;
|
||||
|
||||
// 2. Detect from Accept-Language header
|
||||
const headersList = await headers();
|
||||
const acceptLang = headersList.get("accept-language") ?? "";
|
||||
if (acceptLang.includes("zh")) return "zh";
|
||||
|
||||
return "en";
|
||||
}
|
||||
|
||||
export default async function LandingLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const initialLocale = await getInitialLocale();
|
||||
|
||||
return (
|
||||
<>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
/>
|
||||
<div className={`${instrumentSerif.variable} ${notoSerifSC.variable} h-full overflow-x-hidden overflow-y-auto bg-white`}>
|
||||
<LocaleProvider initialLocale={initialLocale}>{children}</LocaleProvider>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
23
apps/web/app/(landing)/page.tsx
Normal file
23
apps/web/app/(landing)/page.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { Metadata } from "next";
|
||||
import { MulticaLanding } from "@/features/landing/components/multica-landing";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
absolute: "Multica — AI-Native Task Management",
|
||||
},
|
||||
description:
|
||||
"Open-source platform that turns coding agents into real teammates. Assign tasks, track progress, compound skills.",
|
||||
openGraph: {
|
||||
title: "Multica — AI-Native Task Management",
|
||||
description:
|
||||
"Manage your human + agent workforce in one place.",
|
||||
url: "/",
|
||||
},
|
||||
alternates: {
|
||||
canonical: "/",
|
||||
},
|
||||
};
|
||||
|
||||
export default function LandingPage() {
|
||||
return <MulticaLanding />;
|
||||
}
|
||||
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 "@/platform/auth";
|
||||
import { useWorkspaceStore } from "@/platform/workspace";
|
||||
import { api } from "@/platform/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>
|
||||
);
|
||||
}
|
||||
@@ -30,3 +30,12 @@
|
||||
background-color: var(--sidebar-accent);
|
||||
color: var(--sidebar-accent-foreground);
|
||||
}
|
||||
|
||||
/* Sonner toast: align icon to first line of text, not vertically centered */
|
||||
[data-sonner-toast] {
|
||||
align-items: flex-start !important;
|
||||
}
|
||||
|
||||
[data-sonner-toast] [data-icon] {
|
||||
margin-top: 2.5px;
|
||||
}
|
||||
|
||||
@@ -1,149 +1,14 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
@import "../../../packages/ui/styles/tokens.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;
|
||||
}
|
||||
@source "../../../packages/ui/**/*.{ts,tsx}";
|
||||
@source "../../../packages/core/**/*.{ts,tsx}";
|
||||
@source "../../../packages/views/**/*.{ts,tsx}";
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@@ -161,4 +26,4 @@
|
||||
html {
|
||||
@apply font-sans;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +1,54 @@
|
||||
import type { Metadata } from "next";
|
||||
import type { Metadata, Viewport } from "next";
|
||||
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 { Toaster } from "@multica/ui/components/ui/sonner";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { QueryProvider } from "@multica/core/provider";
|
||||
import { AuthInitializer } from "@/features/auth";
|
||||
import { WSProvider } from "@/features/realtime";
|
||||
import { ModalRegistry } from "@/features/modals";
|
||||
import { WebWSProvider } from "@/platform/ws-provider";
|
||||
import { WebNavigationProvider } from "@/platform/navigation";
|
||||
import { LocaleSync } from "@/components/locale-sync";
|
||||
import "./globals.css";
|
||||
|
||||
const geist = Geist({ subsets: ["latin"], variable: "--font-sans" });
|
||||
const geistMono = Geist_Mono({ subsets: ["latin"], variable: "--font-mono" });
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
themeColor: [
|
||||
{ media: "(prefers-color-scheme: light)", color: "#ffffff" },
|
||||
{ media: "(prefers-color-scheme: dark)", color: "#05070b" },
|
||||
],
|
||||
};
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Multica",
|
||||
description: "AI-native task management",
|
||||
metadataBase: new URL("https://www.multica.ai"),
|
||||
title: {
|
||||
default: "Multica — AI-Native Task Management",
|
||||
template: "%s | Multica",
|
||||
},
|
||||
description:
|
||||
"Open-source platform that turns coding agents into real teammates. Assign tasks, track progress, compound skills.",
|
||||
icons: {
|
||||
icon: [{ url: "/favicon.svg", type: "image/svg+xml" }],
|
||||
shortcut: ["/favicon.svg"],
|
||||
},
|
||||
openGraph: {
|
||||
type: "website",
|
||||
siteName: "Multica",
|
||||
locale: "en_US",
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
},
|
||||
alternates: {
|
||||
canonical: "/",
|
||||
},
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -32,12 +63,16 @@ export default function RootLayout({
|
||||
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 />
|
||||
<Toaster />
|
||||
<QueryProvider>
|
||||
<WebNavigationProvider>
|
||||
<AuthInitializer>
|
||||
<WebWSProvider>{children}</WebWSProvider>
|
||||
</AuthInitializer>
|
||||
</WebNavigationProvider>
|
||||
<Toaster />
|
||||
</QueryProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useNavigationStore } from "@/features/navigation";
|
||||
import { MulticaIcon } from "@/components/multica-icon";
|
||||
|
||||
export default function Home() {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
const lastPath = useNavigationStore.getState().lastPath;
|
||||
router.replace(lastPath);
|
||||
}, [router]);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<MulticaIcon className="size-6" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
28
apps/web/app/robots.ts
Normal file
28
apps/web/app/robots.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
const baseUrl = "https://www.multica.ai";
|
||||
|
||||
return {
|
||||
rules: [
|
||||
{
|
||||
userAgent: "*",
|
||||
allow: ["/", "/about", "/changelog"],
|
||||
disallow: [
|
||||
"/api/",
|
||||
"/ws",
|
||||
"/auth/",
|
||||
"/issues",
|
||||
"/board",
|
||||
"/inbox",
|
||||
"/agents",
|
||||
"/settings",
|
||||
"/my-issues",
|
||||
"/runtimes",
|
||||
"/skills",
|
||||
],
|
||||
},
|
||||
],
|
||||
sitemap: `${baseUrl}/sitemap.xml`,
|
||||
};
|
||||
}
|
||||
26
apps/web/app/sitemap.ts
Normal file
26
apps/web/app/sitemap.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
|
||||
export default function sitemap(): MetadataRoute.Sitemap {
|
||||
const baseUrl = "https://www.multica.ai";
|
||||
|
||||
return [
|
||||
{
|
||||
url: baseUrl,
|
||||
lastModified: new Date("2026-04-01"),
|
||||
changeFrequency: "weekly",
|
||||
priority: 1.0,
|
||||
},
|
||||
{
|
||||
url: `${baseUrl}/about`,
|
||||
lastModified: new Date("2026-04-01"),
|
||||
changeFrequency: "monthly",
|
||||
priority: 0.7,
|
||||
},
|
||||
{
|
||||
url: `${baseUrl}/changelog`,
|
||||
lastModified: new Date("2026-04-01"),
|
||||
changeFrequency: "weekly",
|
||||
priority: 0.6,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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,70 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { Bot } 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 === "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">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted">
|
||||
<Bot className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<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,230 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
forwardRef,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Bot, Hash } from "lucide-react";
|
||||
import { ReactRenderer } from "@tiptap/react";
|
||||
import { computePosition, offset, flip, shift } from "@floating-ui/dom";
|
||||
import { useWorkspaceStore } from "@/features/workspace";
|
||||
import { useIssueStore } from "@/features/issues";
|
||||
import type { SuggestionOptions, SuggestionProps } from "@tiptap/suggestion";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface MentionItem {
|
||||
id: string;
|
||||
label: string;
|
||||
type: "member" | "agent" | "issue";
|
||||
/** Secondary text shown below the label (e.g. issue title) */
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface MentionListProps {
|
||||
items: MentionItem[];
|
||||
command: (item: MentionItem) => void;
|
||||
}
|
||||
|
||||
export interface MentionListRef {
|
||||
onKeyDown: (props: { event: KeyboardEvent }) => boolean;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MentionList — the popup rendered inside the editor
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MentionList = forwardRef<MentionListRef, MentionListProps>(
|
||||
function MentionList({ items, command }, ref) {
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedIndex(0);
|
||||
}, [items]);
|
||||
|
||||
const selectItem = (index: number) => {
|
||||
const item = items[index];
|
||||
if (item) command(item);
|
||||
};
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
onKeyDown: ({ event }) => {
|
||||
if (event.key === "ArrowUp") {
|
||||
setSelectedIndex((i) => (i + items.length - 1) % items.length);
|
||||
return true;
|
||||
}
|
||||
if (event.key === "ArrowDown") {
|
||||
setSelectedIndex((i) => (i + 1) % items.length);
|
||||
return true;
|
||||
}
|
||||
if (event.key === "Enter") {
|
||||
selectItem(selectedIndex);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
}));
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="rounded-md border bg-popover p-2 text-xs text-muted-foreground shadow-md">
|
||||
No results
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-md border bg-popover py-1 shadow-md min-w-[180px] max-h-[240px] overflow-y-auto">
|
||||
{items.map((item, index) => (
|
||||
<button
|
||||
key={`${item.type}-${item.id}`}
|
||||
className={`flex w-full items-center gap-2 px-2.5 py-1.5 text-left text-sm transition-colors ${
|
||||
index === selectedIndex ? "bg-accent" : "hover:bg-accent/50"
|
||||
}`}
|
||||
onClick={() => selectItem(index)}
|
||||
>
|
||||
{item.type === "agent" ? (
|
||||
<span className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-info/10 text-info">
|
||||
<Bot className="h-3 w-3" />
|
||||
</span>
|
||||
) : item.type === "issue" ? (
|
||||
<span className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||
<Hash className="h-3 w-3" />
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground text-[9px] font-medium">
|
||||
{item.label
|
||||
.split(" ")
|
||||
.map((w) => w[0])
|
||||
.join("")
|
||||
.toUpperCase()
|
||||
.slice(0, 2)}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="truncate">{item.label}</span>
|
||||
{item.description && (
|
||||
<span className="truncate text-xs text-muted-foreground">{item.description}</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Suggestion config factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function createMentionSuggestion(): Omit<
|
||||
SuggestionOptions<MentionItem>,
|
||||
"editor"
|
||||
> {
|
||||
return {
|
||||
items: ({ query }) => {
|
||||
const { members, agents } = useWorkspaceStore.getState();
|
||||
const { issues } = useIssueStore.getState();
|
||||
const q = query.toLowerCase();
|
||||
|
||||
const memberItems: MentionItem[] = members
|
||||
.filter((m) => m.name.toLowerCase().includes(q))
|
||||
.map((m) => ({
|
||||
id: m.user_id,
|
||||
label: m.name,
|
||||
type: "member" as const,
|
||||
}));
|
||||
|
||||
const agentItems: MentionItem[] = agents
|
||||
.filter((a) => a.name.toLowerCase().includes(q))
|
||||
.map((a) => ({ id: a.id, label: a.name, type: "agent" as const }));
|
||||
|
||||
const issueItems: MentionItem[] = issues
|
||||
.filter(
|
||||
(i) =>
|
||||
i.identifier.toLowerCase().includes(q) ||
|
||||
i.title.toLowerCase().includes(q),
|
||||
)
|
||||
.map((i) => ({
|
||||
id: i.id,
|
||||
label: i.identifier,
|
||||
type: "issue" as const,
|
||||
description: i.title,
|
||||
}));
|
||||
|
||||
return [...memberItems, ...agentItems, ...issueItems].slice(0, 10);
|
||||
},
|
||||
|
||||
render: () => {
|
||||
let renderer: ReactRenderer<MentionListRef> | null = null;
|
||||
let popup: HTMLDivElement | null = null;
|
||||
|
||||
return {
|
||||
onStart: (props: SuggestionProps<MentionItem>) => {
|
||||
renderer = new ReactRenderer(MentionList, {
|
||||
props: { items: props.items, command: props.command },
|
||||
editor: props.editor,
|
||||
});
|
||||
|
||||
popup = document.createElement("div");
|
||||
popup.style.position = "fixed";
|
||||
popup.style.zIndex = "50";
|
||||
popup.appendChild(renderer.element);
|
||||
document.body.appendChild(popup);
|
||||
|
||||
updatePosition(popup, props.clientRect);
|
||||
},
|
||||
|
||||
onUpdate: (props: SuggestionProps<MentionItem>) => {
|
||||
renderer?.updateProps({
|
||||
items: props.items,
|
||||
command: props.command,
|
||||
});
|
||||
if (popup) updatePosition(popup, props.clientRect);
|
||||
},
|
||||
|
||||
onKeyDown: (props: { event: KeyboardEvent }) => {
|
||||
if (props.event.key === "Escape") {
|
||||
cleanup();
|
||||
return true;
|
||||
}
|
||||
return renderer?.ref?.onKeyDown(props) ?? false;
|
||||
},
|
||||
|
||||
onExit: () => {
|
||||
cleanup();
|
||||
},
|
||||
};
|
||||
|
||||
function updatePosition(
|
||||
el: HTMLDivElement,
|
||||
clientRect: (() => DOMRect | null) | null | undefined,
|
||||
) {
|
||||
if (!clientRect) return;
|
||||
const virtualEl = {
|
||||
getBoundingClientRect: () => clientRect() ?? new DOMRect(),
|
||||
};
|
||||
computePosition(virtualEl, el, {
|
||||
placement: "bottom-start",
|
||||
strategy: "fixed",
|
||||
middleware: [offset(4), flip(), shift({ padding: 8 })],
|
||||
}).then(({ x, y }) => {
|
||||
el.style.left = `${x}px`;
|
||||
el.style.top = `${y}px`;
|
||||
});
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
renderer?.destroy();
|
||||
renderer = null;
|
||||
popup?.remove();
|
||||
popup = null;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, lazy, Suspense } from "react";
|
||||
import { SmilePlus } from "lucide-react";
|
||||
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover";
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
|
||||
import { useActorName } from "@/features/workspace";
|
||||
|
||||
const EmojiPicker = lazy(() =>
|
||||
import("@/components/common/emoji-picker").then((m) => ({ default: m.EmojiPicker })),
|
||||
);
|
||||
|
||||
const QUICK_EMOJIS = ["👍", "👌", "❤️", "😄", "🎉", "😕", "🚀", "👀"];
|
||||
|
||||
interface ReactionItem {
|
||||
id: string;
|
||||
actor_type: string;
|
||||
actor_id: string;
|
||||
emoji: string;
|
||||
}
|
||||
|
||||
interface GroupedReaction {
|
||||
emoji: string;
|
||||
count: number;
|
||||
reacted: boolean;
|
||||
actors: { type: string; id: string }[];
|
||||
}
|
||||
|
||||
function groupReactions(reactions: ReactionItem[], currentUserId?: string): GroupedReaction[] {
|
||||
const map = new Map<string, GroupedReaction>();
|
||||
for (const r of reactions) {
|
||||
let group = map.get(r.emoji);
|
||||
if (!group) {
|
||||
group = { emoji: r.emoji, count: 0, reacted: false, actors: [] };
|
||||
map.set(r.emoji, group);
|
||||
}
|
||||
group.count++;
|
||||
group.actors.push({ type: r.actor_type, id: r.actor_id });
|
||||
if (r.actor_type === "member" && r.actor_id === currentUserId) {
|
||||
group.reacted = true;
|
||||
}
|
||||
}
|
||||
return Array.from(map.values());
|
||||
}
|
||||
|
||||
export function ReactionBar({
|
||||
reactions,
|
||||
currentUserId,
|
||||
onToggle,
|
||||
className,
|
||||
}: {
|
||||
reactions: ReactionItem[];
|
||||
currentUserId?: string;
|
||||
onToggle: (emoji: string) => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [showFullPicker, setShowFullPicker] = useState(false);
|
||||
const grouped = groupReactions(reactions, currentUserId);
|
||||
const { getActorName } = useActorName();
|
||||
|
||||
const handlePickerOpenChange = (open: boolean) => {
|
||||
setPickerOpen(open);
|
||||
if (!open) setShowFullPicker(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`flex flex-wrap items-center gap-1.5 ${className ?? ""}`}>
|
||||
{grouped.map((g) => (
|
||||
<Tooltip key={g.emoji}>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggle(g.emoji)}
|
||||
className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs transition-colors hover:bg-accent ${
|
||||
g.reacted
|
||||
? "border-primary/40 bg-primary/10 text-primary"
|
||||
: "border-border text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
<span>{g.emoji}</span>
|
||||
<span>{g.count}</span>
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent side="top">
|
||||
{g.actors.map((a) => getActorName(a.type, a.id)).join(", ")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
<Popover open={pickerOpen} onOpenChange={handlePickerOpenChange}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center justify-center h-6 w-6 rounded-full text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||
>
|
||||
<SmilePlus className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<PopoverContent align="start" className="w-auto p-0">
|
||||
{showFullPicker ? (
|
||||
<Suspense fallback={<div className="p-4 text-sm text-muted-foreground">Loading...</div>}>
|
||||
<EmojiPicker
|
||||
onSelect={(emoji) => {
|
||||
onToggle(emoji);
|
||||
setPickerOpen(false);
|
||||
setShowFullPicker(false);
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
) : (
|
||||
<div className="p-2">
|
||||
<div className="flex gap-1">
|
||||
{QUICK_EMOJIS.map((emoji) => (
|
||||
<button
|
||||
key={emoji}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onToggle(emoji);
|
||||
setPickerOpen(false);
|
||||
}}
|
||||
className="h-8 w-8 flex items-center justify-center rounded hover:bg-accent text-base transition-colors"
|
||||
>
|
||||
{emoji}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowFullPicker(true)}
|
||||
className="mt-1.5 w-full text-xs text-muted-foreground hover:text-foreground text-center py-1 rounded hover:bg-accent transition-colors"
|
||||
>
|
||||
More emojis...
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,158 +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;
|
||||
}
|
||||
|
||||
/* 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);
|
||||
}
|
||||
@@ -1,354 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
forwardRef,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
} from "react";
|
||||
import { useEditor, EditorContent } from "@tiptap/react";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
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 { cn } from "@/lib/utils";
|
||||
import type { UploadResult } from "@/shared/hooks/use-file-upload";
|
||||
import { createMentionSuggestion } from "./mention-suggestion";
|
||||
import "./rich-text-editor.css";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface RichTextEditorProps {
|
||||
defaultValue?: string;
|
||||
onUpdate?: (markdown: string) => void;
|
||||
placeholder?: string;
|
||||
editable?: boolean;
|
||||
className?: string;
|
||||
debounceMs?: number;
|
||||
onSubmit?: () => 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], 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;
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// File upload extension (paste + drop)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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;
|
||||
try {
|
||||
const result = await handler(file);
|
||||
if (!result) continue;
|
||||
|
||||
const isImage = file.type.startsWith("image/");
|
||||
if (isImage) {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.setImage({ src: result.link, alt: result.filename })
|
||||
.run();
|
||||
} else {
|
||||
// Insert as a markdown link
|
||||
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,
|
||||
onUploadFile,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
const onUpdateRef = useRef(onUpdate);
|
||||
const onSubmitRef = useRef(onSubmit);
|
||||
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;
|
||||
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,
|
||||
}),
|
||||
Placeholder.configure({
|
||||
placeholder: placeholderText,
|
||||
}),
|
||||
LinkExtension,
|
||||
Typography,
|
||||
MentionExtension,
|
||||
Image.configure({
|
||||
inline: false,
|
||||
allowBase64: false,
|
||||
HTMLAttributes: { style: "max-width: 100%; height: auto;" },
|
||||
}),
|
||||
Markdown,
|
||||
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);
|
||||
},
|
||||
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,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Spinner } from "@/components/spinner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
|
||||
export type LoadingVariant = "generating" | "streaming";
|
||||
|
||||
|
||||
20
apps/web/components/locale-sync.tsx
Normal file
20
apps/web/components/locale-sync.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
/**
|
||||
* Reads the locale cookie on the client and updates <html lang>.
|
||||
* This avoids calling cookies() in the root Server Component layout,
|
||||
* which would mark the entire app as dynamic and disable the Router Cache.
|
||||
*/
|
||||
export function LocaleSync() {
|
||||
useEffect(() => {
|
||||
const match = document.cookie.match(/(?:^|;\s*)multica-locale=(\w+)/);
|
||||
const locale = match?.[1];
|
||||
if (locale === "zh") {
|
||||
document.documentElement.lang = "zh";
|
||||
}
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,243 +1 @@
|
||||
import * as React from 'react'
|
||||
import { codeToHtml, bundledLanguages, type BundledLanguage } from 'shiki'
|
||||
import { Copy, Check } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface CodeBlockProps {
|
||||
code: string
|
||||
language?: string
|
||||
className?: string
|
||||
/**
|
||||
* Render mode affects code block styling:
|
||||
* - 'terminal': Minimal, keeps control chars visible
|
||||
* - 'minimal': Clean code, basic styling
|
||||
* - 'full': Rich styling with background, copy button, etc.
|
||||
*/
|
||||
mode?: 'terminal' | 'minimal' | 'full'
|
||||
}
|
||||
|
||||
// Map common aliases to Shiki language names
|
||||
const LANGUAGE_ALIASES: Record<string, BundledLanguage> = {
|
||||
js: 'javascript',
|
||||
ts: 'typescript',
|
||||
py: 'python',
|
||||
sh: 'bash',
|
||||
zsh: 'bash',
|
||||
yml: 'yaml',
|
||||
rb: 'ruby',
|
||||
rs: 'rust',
|
||||
kt: 'kotlin',
|
||||
'objective-c': 'objc',
|
||||
objc: 'objc'
|
||||
}
|
||||
|
||||
// Simple LRU cache for highlighted code
|
||||
const highlightCache = new Map<string, string>()
|
||||
const CACHE_MAX_SIZE = 200
|
||||
|
||||
function getCacheKey(code: string, lang: string): string {
|
||||
return `${lang}:${code}`
|
||||
}
|
||||
|
||||
function isValidLanguage(lang: string): lang is BundledLanguage {
|
||||
const normalized = LANGUAGE_ALIASES[lang] || lang
|
||||
return normalized in bundledLanguages
|
||||
}
|
||||
|
||||
/**
|
||||
* CodeBlock - Syntax highlighted code block using Shiki
|
||||
*
|
||||
* Uses Shiki dual themes with CSS variables for light/dark switching.
|
||||
* No JS-based dark mode detection needed — theme switching is handled
|
||||
* entirely via CSS (see globals.css for .shiki/.dark .shiki rules).
|
||||
*
|
||||
* @see https://shiki.style/guide/dual-themes
|
||||
*/
|
||||
export function CodeBlock({
|
||||
code,
|
||||
language = 'text',
|
||||
className,
|
||||
mode = 'full'
|
||||
}: CodeBlockProps): React.JSX.Element {
|
||||
const [highlighted, setHighlighted] = React.useState<string | null>(null)
|
||||
const [isLoading, setIsLoading] = React.useState(true)
|
||||
const [copied, setCopied] = React.useState(false)
|
||||
|
||||
// Resolve language alias - keep as string to allow 'text' fallback
|
||||
const langLower = language.toLowerCase()
|
||||
const resolvedLang: string = LANGUAGE_ALIASES[langLower] || langLower
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
async function highlight(): Promise<void> {
|
||||
const cacheKey = getCacheKey(code, resolvedLang)
|
||||
|
||||
const cached = highlightCache.get(cacheKey)
|
||||
if (cached) {
|
||||
if (!cancelled) {
|
||||
setHighlighted(cached)
|
||||
setIsLoading(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Use valid language or fallback to plaintext
|
||||
const lang = isValidLanguage(resolvedLang) ? resolvedLang : 'text'
|
||||
|
||||
// Dual themes: Shiki outputs CSS variables for both themes in one pass.
|
||||
// CSS handles switching via .dark selector (see globals.css).
|
||||
const html = await codeToHtml(code, {
|
||||
lang,
|
||||
themes: {
|
||||
light: 'github-light',
|
||||
dark: 'github-dark',
|
||||
},
|
||||
defaultColor: false,
|
||||
})
|
||||
|
||||
// Cache the result
|
||||
if (highlightCache.size >= CACHE_MAX_SIZE) {
|
||||
const firstKey = highlightCache.keys().next().value
|
||||
if (firstKey) highlightCache.delete(firstKey)
|
||||
}
|
||||
highlightCache.set(cacheKey, html)
|
||||
|
||||
if (!cancelled) {
|
||||
setHighlighted(html)
|
||||
setIsLoading(false)
|
||||
}
|
||||
} catch (error) {
|
||||
// Fallback to plain text on error
|
||||
console.warn(`Shiki highlighting failed for language "${resolvedLang}":`, error)
|
||||
if (!cancelled) {
|
||||
setHighlighted(null)
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
highlight()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [code, resolvedLang])
|
||||
|
||||
const handleCopy = React.useCallback(async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(code)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
} catch (err) {
|
||||
console.error('Failed to copy code:', err)
|
||||
}
|
||||
}, [code])
|
||||
|
||||
// Terminal mode: raw monospace with minimal styling
|
||||
if (mode === 'terminal') {
|
||||
return (
|
||||
<pre className={cn('font-mono text-sm whitespace-pre-wrap', className)}>
|
||||
<code className="font-mono">{code}</code>
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
|
||||
// Minimal mode: just syntax highlighting, no chrome
|
||||
if (mode === 'minimal') {
|
||||
if (isLoading || !highlighted) {
|
||||
return (
|
||||
<pre className={cn('font-mono text-sm whitespace-pre-wrap', className)}>
|
||||
<code className="font-mono">{code}</code>
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'font-mono text-sm [&_pre]:!bg-transparent [&_pre]:!p-0 [&_pre]:whitespace-pre-wrap [&_pre]:break-all [&_code]:!bg-transparent [&_code]:font-mono [&_pre]:font-mono',
|
||||
className
|
||||
)}
|
||||
dangerouslySetInnerHTML={{ __html: highlighted }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Full mode: rich styling with header and copy button
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative group rounded-lg overflow-hidden border bg-muted/30 mb-4 last:mb-0',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Language label + copy button */}
|
||||
<div className="flex items-center justify-between px-3 py-1.5 bg-muted/50 border-b text-xs">
|
||||
<span className="text-muted-foreground font-medium uppercase tracking-wide">
|
||||
{resolvedLang !== 'text' ? resolvedLang : 'plain text'}
|
||||
</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={handleCopy}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-foreground"
|
||||
aria-label="Copy code"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="size-3.5 text-success" />
|
||||
) : (
|
||||
<Copy className="size-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>Copy code</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* Code content */}
|
||||
<div className="p-3 overflow-x-auto">
|
||||
{isLoading || !highlighted ? (
|
||||
<pre className="font-mono text-sm whitespace-pre-wrap break-all">
|
||||
<code className="font-mono">{code}</code>
|
||||
</pre>
|
||||
) : (
|
||||
<div
|
||||
className="font-mono text-sm [&_pre]:!bg-transparent [&_pre]:!m-0 [&_pre]:!p-0 [&_pre]:whitespace-pre-wrap [&_pre]:break-all [&_code]:!bg-transparent [&_code]:font-mono [&_pre]:font-mono"
|
||||
dangerouslySetInnerHTML={{ __html: highlighted }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* InlineCode - Styled inline code span
|
||||
* Features: subtle background (3%), subtle border (5%), 75% opacity text
|
||||
*/
|
||||
export function InlineCode({
|
||||
children,
|
||||
className
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<code
|
||||
className={cn(
|
||||
'px-1.5 py-0.5 rounded bg-foreground/[0.03] border border-foreground/[0.05] font-mono text-sm text-foreground/75',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
)
|
||||
}
|
||||
export { CodeBlock, InlineCode, type CodeBlockProps } from '@multica/ui/markdown'
|
||||
|
||||
@@ -1,352 +1,36 @@
|
||||
import * as React from 'react'
|
||||
import ReactMarkdown, { type Components, defaultUrlTransform } from 'react-markdown'
|
||||
import rehypeRaw from 'rehype-raw'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { CodeBlock, InlineCode } from './CodeBlock'
|
||||
import { preprocessLinks } from './linkify'
|
||||
import { IssueMentionCard } from '@/features/issues/components/issue-mention-card'
|
||||
import {
|
||||
Markdown as MarkdownBase,
|
||||
MemoizedMarkdown as MemoizedMarkdownBase,
|
||||
type MarkdownProps as MarkdownBaseProps,
|
||||
type RenderMode
|
||||
} from '@multica/ui/markdown'
|
||||
import { IssueMentionCard } from '@multica/views/issues/components'
|
||||
|
||||
export type { RenderMode }
|
||||
|
||||
export type MarkdownProps = MarkdownBaseProps
|
||||
|
||||
/**
|
||||
* Render modes for markdown content:
|
||||
*
|
||||
* - 'terminal': Raw output with minimal formatting, control chars visible
|
||||
* Best for: Debug output, raw logs, when you want to see exactly what's there
|
||||
*
|
||||
* - 'minimal': Clean rendering with syntax highlighting but no extra chrome
|
||||
* Best for: Chat messages, inline content, when you want readability without clutter
|
||||
*
|
||||
* - 'full': Rich rendering with beautiful tables, styled code blocks, proper typography
|
||||
* Best for: Documentation, long-form content, when presentation matters
|
||||
* Default renderMention that delegates to IssueMentionCard for issue mentions
|
||||
* and renders a styled span for other mention types.
|
||||
*/
|
||||
export type RenderMode = 'terminal' | 'minimal' | 'full'
|
||||
|
||||
export interface MarkdownProps {
|
||||
children: string
|
||||
/**
|
||||
* Render mode controlling formatting level
|
||||
* @default 'minimal'
|
||||
*/
|
||||
mode?: RenderMode
|
||||
className?: string
|
||||
/**
|
||||
* Message ID for memoization (optional)
|
||||
* When provided, memoizes parsed blocks to avoid re-parsing during streaming
|
||||
*/
|
||||
id?: string
|
||||
/**
|
||||
* Callback when a URL is clicked
|
||||
*/
|
||||
onUrlClick?: (url: string) => void
|
||||
/**
|
||||
* Callback when a file path is clicked
|
||||
*/
|
||||
onFileClick?: (path: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom URL transform that allows mention:// protocol (used for @mentions)
|
||||
* while keeping the default security for all other URLs.
|
||||
*/
|
||||
function urlTransform(url: string): string {
|
||||
if (url.startsWith('mention://')) return url
|
||||
return defaultUrlTransform(url)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert legacy mention shortcodes [@ id="UUID" label="LABEL"] to markdown
|
||||
* link format [@LABEL](mention://member/UUID) so they render as styled mentions.
|
||||
*/
|
||||
function preprocessMentionShortcodes(text: string): string {
|
||||
if (!text.includes('[@ ')) return text
|
||||
return text.replace(
|
||||
/\[@\s+([^\]]*)\]/g,
|
||||
(match, 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
|
||||
return `[@${label}](mention://member/${id})`
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// File path detection regex - matches paths starting with /, ~/, or ./
|
||||
const FILE_PATH_REGEX =
|
||||
/^(?:\/|~\/|\.\/)[\w\-./@]+\.(?:ts|tsx|js|jsx|mjs|cjs|md|json|yaml|yml|py|go|rs|css|scss|less|html|htm|txt|log|sh|bash|zsh|swift|kt|java|c|cpp|h|hpp|rb|php|xml|toml|ini|cfg|conf|env|sql|graphql|vue|svelte|astro|prisma)$/i
|
||||
|
||||
/**
|
||||
* Create custom components based on render mode
|
||||
*/
|
||||
function createComponents(
|
||||
mode: RenderMode,
|
||||
onUrlClick?: (url: string) => void,
|
||||
onFileClick?: (path: string) => void
|
||||
): Partial<Components> {
|
||||
const baseComponents: Partial<Components> = {
|
||||
// Images: render uploaded images with constrained sizing
|
||||
img: ({ src, alt }) => (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt ?? ""}
|
||||
className="max-w-full h-auto rounded-md my-2"
|
||||
loading="lazy"
|
||||
/>
|
||||
),
|
||||
// Links: Make clickable with callbacks, or render as mention
|
||||
a: ({ href, children }) => {
|
||||
// Mention links: mention://member/id, mention://agent/id, mention://issue/id
|
||||
if (href?.startsWith('mention://')) {
|
||||
const mentionMatch = href.match(/^mention:\/\/(member|agent|issue)\/(.+)$/)
|
||||
if (mentionMatch?.[1] === 'issue' && mentionMatch[2]) {
|
||||
const label = typeof children === 'string' ? children : Array.isArray(children) ? children.join('') : undefined
|
||||
return <IssueMentionCard issueId={mentionMatch[2]} fallbackLabel={label} />
|
||||
}
|
||||
return (
|
||||
<span className="text-primary font-semibold mx-0.5">
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const handleClick = (e: React.MouseEvent): void => {
|
||||
e.preventDefault()
|
||||
if (href) {
|
||||
// Check if it's a file path
|
||||
if (FILE_PATH_REGEX.test(href) && onFileClick) {
|
||||
onFileClick(href)
|
||||
} else if (onUrlClick) {
|
||||
onUrlClick(href)
|
||||
} else {
|
||||
// Default: open in new window
|
||||
window.open(href, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
onClick={handleClick}
|
||||
className="text-primary hover:underline cursor-pointer"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Terminal mode: minimal formatting
|
||||
if (mode === 'terminal') {
|
||||
return {
|
||||
...baseComponents,
|
||||
// No special code handling - just monospace
|
||||
code: ({ children }) => <code className="font-mono">{children}</code>,
|
||||
pre: ({ children }) => <pre className="font-mono whitespace-pre-wrap my-2">{children}</pre>,
|
||||
// Minimal paragraph spacing
|
||||
p: ({ children }) => <p className="my-1">{children}</p>,
|
||||
// Simple lists
|
||||
ul: ({ children }) => <ul className="list-disc list-inside my-1">{children}</ul>,
|
||||
ol: ({ children }) => <ol className="list-decimal list-inside my-1">{children}</ol>,
|
||||
li: ({ children }) => <li className="my-0.5">{children}</li>,
|
||||
// Plain tables
|
||||
table: ({ children }) => <table className="my-2 font-mono text-sm">{children}</table>,
|
||||
th: ({ children }) => <th className="text-left pr-4">{children}</th>,
|
||||
td: ({ children }) => <td className="pr-4">{children}</td>
|
||||
}
|
||||
}
|
||||
|
||||
// Minimal mode: clean with syntax highlighting
|
||||
if (mode === 'minimal') {
|
||||
return {
|
||||
...baseComponents,
|
||||
// Inline code
|
||||
code: ({ className, children, ...props }) => {
|
||||
const match = /language-(\w+)/.exec(className || '')
|
||||
const isBlock =
|
||||
'node' in props && props.node?.position?.start.line !== props.node?.position?.end.line
|
||||
|
||||
// Block code - use CodeBlock with full mode
|
||||
if (match || isBlock) {
|
||||
const code = String(children).replace(/\n$/, '')
|
||||
return <CodeBlock code={code} language={match?.[1]} mode="full" className="my-1" />
|
||||
}
|
||||
|
||||
// Inline code
|
||||
return <InlineCode>{children}</InlineCode>
|
||||
},
|
||||
pre: ({ children }) => <>{children}</>,
|
||||
// Comfortable paragraph spacing
|
||||
p: ({ children }) => <p className="my-2 leading-relaxed">{children}</p>,
|
||||
// Styled lists
|
||||
ul: ({ children }) => (
|
||||
<ul className="my-2 space-y-1 ps-4 pe-2 list-disc marker:text-muted-foreground">
|
||||
{children}
|
||||
</ul>
|
||||
),
|
||||
ol: ({ children }) => <ol className="my-2 space-y-1 pl-6 list-decimal">{children}</ol>,
|
||||
li: ({ children }) => <li>{children}</li>,
|
||||
// Clean tables
|
||||
table: ({ children }) => (
|
||||
<div className="my-3 overflow-x-auto">
|
||||
<table className="min-w-full text-sm">{children}</table>
|
||||
</div>
|
||||
),
|
||||
thead: ({ children }) => <thead className="border-b">{children}</thead>,
|
||||
th: ({ children }) => (
|
||||
<th className="text-left py-2 px-3 font-semibold text-muted-foreground">{children}</th>
|
||||
),
|
||||
td: ({ children }) => <td className="py-2 px-3 border-b border-border/50">{children}</td>,
|
||||
// Headings - H1/H2 same size, differentiated by weight
|
||||
h1: ({ children }) => <h1 className="font-sans text-base font-bold mt-5 mb-3">{children}</h1>,
|
||||
h2: ({ children }) => (
|
||||
<h2 className="font-sans text-base font-semibold mt-4 mb-3">{children}</h2>
|
||||
),
|
||||
h3: ({ children }) => (
|
||||
<h3 className="font-sans text-sm font-semibold mt-4 mb-2">{children}</h3>
|
||||
),
|
||||
// Blockquotes
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className="border-l-2 border-muted-foreground/30 pl-3 my-2 text-muted-foreground italic">
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
// Horizontal rules
|
||||
hr: () => <hr className="my-4 border-border" />,
|
||||
// Strong/emphasis
|
||||
strong: ({ children }) => <strong className="font-semibold">{children}</strong>,
|
||||
em: ({ children }) => <em className="italic">{children}</em>
|
||||
}
|
||||
}
|
||||
|
||||
// Full mode: rich styling
|
||||
return {
|
||||
...baseComponents,
|
||||
// Full code blocks with copy button
|
||||
code: ({ className, children, ...props }) => {
|
||||
const match = /language-(\w+)/.exec(className || '')
|
||||
const isBlock =
|
||||
'node' in props && props.node?.position?.start.line !== props.node?.position?.end.line
|
||||
|
||||
if (match || isBlock) {
|
||||
const code = String(children).replace(/\n$/, '')
|
||||
return <CodeBlock code={code} language={match?.[1]} mode="full" className="my-1" />
|
||||
}
|
||||
|
||||
return <InlineCode>{children}</InlineCode>
|
||||
},
|
||||
pre: ({ children }) => <>{children}</>,
|
||||
// Rich paragraph spacing
|
||||
p: ({ children }) => <p className="my-3 leading-relaxed">{children}</p>,
|
||||
// Styled lists
|
||||
ul: ({ children }) => (
|
||||
<ul className="my-3 space-y-1.5 ps-4 pe-2 list-disc marker:text-muted-foreground">
|
||||
{children}
|
||||
</ul>
|
||||
),
|
||||
ol: ({ children }) => <ol className="my-3 space-y-1.5 pl-6 list-decimal">{children}</ol>,
|
||||
li: ({ children }) => <li className="leading-relaxed">{children}</li>,
|
||||
// Beautiful tables
|
||||
table: ({ children }) => (
|
||||
<div className="my-4 overflow-x-auto rounded-md border">
|
||||
<table className="min-w-full divide-y divide-border">{children}</table>
|
||||
</div>
|
||||
),
|
||||
thead: ({ children }) => <thead className="bg-muted/50">{children}</thead>,
|
||||
tbody: ({ children }) => <tbody className="divide-y divide-border">{children}</tbody>,
|
||||
th: ({ children }) => <th className="text-left py-3 px-4 font-semibold text-sm">{children}</th>,
|
||||
td: ({ children }) => <td className="py-3 px-4 text-sm">{children}</td>,
|
||||
tr: ({ children }) => <tr className="hover:bg-muted/30 transition-colors">{children}</tr>,
|
||||
// Rich headings
|
||||
h1: ({ children }) => <h1 className="font-sans text-base font-bold mt-7 mb-4">{children}</h1>,
|
||||
h2: ({ children }) => (
|
||||
<h2 className="font-sans text-base font-semibold mt-6 mb-3">{children}</h2>
|
||||
),
|
||||
h3: ({ children }) => <h3 className="font-sans text-sm font-semibold mt-5 mb-3">{children}</h3>,
|
||||
h4: ({ children }) => <h4 className="text-sm font-semibold mt-3 mb-1">{children}</h4>,
|
||||
// Styled blockquotes
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className="border-l-4 border-foreground/30 bg-muted/30 pl-4 pr-3 py-2 my-3 rounded-r-md">
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
// Task lists (GFM)
|
||||
input: ({ type, checked }) => {
|
||||
if (type === 'checkbox') {
|
||||
return (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
readOnly
|
||||
className="mr-2 rounded border-muted-foreground"
|
||||
/>
|
||||
)
|
||||
}
|
||||
return <input type={type} />
|
||||
},
|
||||
// Horizontal rules
|
||||
hr: () => <hr className="my-6 border-border" />,
|
||||
// Strong/emphasis
|
||||
strong: ({ children }) => <strong className="font-semibold">{children}</strong>,
|
||||
em: ({ children }) => <em className="italic">{children}</em>,
|
||||
del: ({ children }) => <del className="line-through text-muted-foreground">{children}</del>
|
||||
function defaultRenderMention({ type, id }: { type: string; id: string }): React.ReactNode {
|
||||
if (type === 'issue') {
|
||||
return <IssueMentionCard issueId={id} />
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Markdown - Customizable markdown renderer with multiple render modes
|
||||
*
|
||||
* Features:
|
||||
* - Three render modes: terminal, minimal, full
|
||||
* - Syntax highlighting via Shiki
|
||||
* - GFM support (tables, task lists, strikethrough)
|
||||
* - Clickable links and file paths
|
||||
* - Memoization for streaming performance
|
||||
* App-level Markdown wrapper that injects IssueMentionCard via renderMention.
|
||||
* Callers that need custom mention rendering can pass their own renderMention prop.
|
||||
*/
|
||||
export function Markdown({
|
||||
children,
|
||||
mode = 'minimal',
|
||||
className,
|
||||
onUrlClick,
|
||||
onFileClick
|
||||
}: MarkdownProps): React.JSX.Element {
|
||||
const components = React.useMemo(
|
||||
() => createComponents(mode, onUrlClick, onFileClick),
|
||||
[mode, onUrlClick, onFileClick]
|
||||
)
|
||||
|
||||
// Preprocess: convert mention shortcodes and raw URLs/file paths to markdown links
|
||||
const processedContent = React.useMemo(
|
||||
() => preprocessLinks(preprocessMentionShortcodes(children)),
|
||||
[children]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={cn('markdown-content break-words', className)}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[[remarkGfm, { singleTilde: false }]]}
|
||||
rehypePlugins={[rehypeRaw]}
|
||||
urlTransform={urlTransform}
|
||||
components={components}
|
||||
>
|
||||
{processedContent}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)
|
||||
export function Markdown(props: MarkdownProps): React.JSX.Element {
|
||||
return <MarkdownBase renderMention={defaultRenderMention} {...props} />
|
||||
}
|
||||
|
||||
/**
|
||||
* MemoizedMarkdown - Optimized for streaming scenarios
|
||||
*
|
||||
* Splits content into blocks and memoizes each block separately,
|
||||
* so only new/changed blocks re-render during streaming.
|
||||
*/
|
||||
export const MemoizedMarkdown = React.memo(Markdown, (prevProps, nextProps) => {
|
||||
// If id is provided, use it for memoization
|
||||
if (prevProps.id && nextProps.id) {
|
||||
return (
|
||||
prevProps.id === nextProps.id &&
|
||||
@@ -354,7 +38,6 @@ export const MemoizedMarkdown = React.memo(Markdown, (prevProps, nextProps) => {
|
||||
prevProps.mode === nextProps.mode
|
||||
)
|
||||
}
|
||||
// Otherwise compare content and mode
|
||||
return prevProps.children === nextProps.children && prevProps.mode === nextProps.mode
|
||||
})
|
||||
MemoizedMarkdown.displayName = 'MemoizedMarkdown'
|
||||
|
||||
@@ -1,225 +1,22 @@
|
||||
import * as React from 'react'
|
||||
import { Markdown, type RenderMode } from './Markdown'
|
||||
import {
|
||||
StreamingMarkdown as StreamingMarkdownBase,
|
||||
type StreamingMarkdownProps as StreamingMarkdownBaseProps
|
||||
} from '@multica/ui/markdown'
|
||||
import { IssueMentionCard } from '@multica/views/issues/components'
|
||||
|
||||
export interface StreamingMarkdownProps {
|
||||
content: string
|
||||
isStreaming: boolean
|
||||
mode?: RenderMode
|
||||
className?: string
|
||||
onUrlClick?: (url: string) => void
|
||||
onFileClick?: (path: string) => void
|
||||
}
|
||||
export type StreamingMarkdownProps = StreamingMarkdownBaseProps
|
||||
|
||||
interface Block {
|
||||
content: string
|
||||
isCodeBlock: boolean
|
||||
function defaultRenderMention({ type, id }: { type: string; id: string }): React.ReactNode {
|
||||
if (type === 'issue') {
|
||||
return <IssueMentionCard issueId={id} />
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* djb2 hash (XOR variant) by Daniel J. Bernstein.
|
||||
* Used to generate stable React keys for completed content blocks.
|
||||
*
|
||||
* - 5381: empirically chosen initial value that produces fewer collisions
|
||||
* - (hash << 5) + hash: equivalent to hash * 33
|
||||
* - ^ charCode: XOR variant, favored by Bernstein over additive version
|
||||
* - >>> 0: convert to unsigned 32-bit integer
|
||||
*
|
||||
* Not cryptographic — just fast with good distribution for short strings.
|
||||
* @see http://www.cse.yorku.ca/~oz/hash.html
|
||||
* App-level StreamingMarkdown wrapper that injects IssueMentionCard via renderMention.
|
||||
*/
|
||||
function simpleHash(str: string): string {
|
||||
let hash = 5381
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = ((hash << 5) + hash) ^ str.charCodeAt(i)
|
||||
}
|
||||
return (hash >>> 0).toString(36)
|
||||
}
|
||||
|
||||
/**
|
||||
* Split content into blocks (paragraphs and code blocks)
|
||||
*
|
||||
* Block boundaries:
|
||||
* - Double newlines (paragraph separators)
|
||||
* - Code fences (```)
|
||||
*
|
||||
* This is intentionally simple - just string scanning, no regex per line.
|
||||
*/
|
||||
function splitIntoBlocks(content: string): Block[] {
|
||||
const blocks: Block[] = []
|
||||
const lines = content.split('\n')
|
||||
let currentBlock = ''
|
||||
let inCodeBlock = false
|
||||
let inMathBlock = false
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i] ?? ''
|
||||
|
||||
// Check for code fence (``` at start of line, optionally followed by language)
|
||||
if (line.startsWith('```')) {
|
||||
if (!inCodeBlock) {
|
||||
// Starting a code block - flush current paragraph first
|
||||
if (currentBlock.trim()) {
|
||||
blocks.push({ content: currentBlock.trim(), isCodeBlock: false })
|
||||
currentBlock = ''
|
||||
}
|
||||
inCodeBlock = true
|
||||
currentBlock = line + '\n'
|
||||
} else {
|
||||
// Ending a code block
|
||||
currentBlock += line
|
||||
blocks.push({ content: currentBlock, isCodeBlock: true })
|
||||
currentBlock = ''
|
||||
inCodeBlock = false
|
||||
}
|
||||
} else if (inCodeBlock) {
|
||||
// Inside code block - append line
|
||||
currentBlock += line + '\n'
|
||||
// Check for display math fence ($$)
|
||||
} else if (line.trim() === '$$') {
|
||||
if (!inMathBlock) {
|
||||
// Starting a math block - flush current paragraph first
|
||||
if (currentBlock.trim()) {
|
||||
blocks.push({ content: currentBlock.trim(), isCodeBlock: false })
|
||||
currentBlock = ''
|
||||
}
|
||||
inMathBlock = true
|
||||
currentBlock = line + '\n'
|
||||
} else {
|
||||
// Ending a math block
|
||||
currentBlock += line
|
||||
blocks.push({ content: currentBlock, isCodeBlock: false })
|
||||
currentBlock = ''
|
||||
inMathBlock = false
|
||||
}
|
||||
} else if (inMathBlock) {
|
||||
// Inside math block - append line (don't split on blank lines)
|
||||
currentBlock += line + '\n'
|
||||
} else if (line === '') {
|
||||
// Empty line outside code block = paragraph boundary
|
||||
if (currentBlock.trim()) {
|
||||
blocks.push({ content: currentBlock.trim(), isCodeBlock: false })
|
||||
currentBlock = ''
|
||||
}
|
||||
} else {
|
||||
// Regular text line
|
||||
if (currentBlock) {
|
||||
currentBlock += '\n' + line
|
||||
} else {
|
||||
currentBlock = line
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Flush remaining content
|
||||
if (currentBlock) {
|
||||
blocks.push({
|
||||
content: inCodeBlock || inMathBlock ? currentBlock : currentBlock.trim(),
|
||||
isCodeBlock: inCodeBlock
|
||||
})
|
||||
}
|
||||
|
||||
return blocks
|
||||
}
|
||||
|
||||
/**
|
||||
* Memoized block component
|
||||
*
|
||||
* Only re-renders if content or mode changes.
|
||||
* The key is assigned by the parent based on content hash,
|
||||
* so identical content won't even attempt to render.
|
||||
*/
|
||||
const MemoizedBlock = React.memo(
|
||||
function Block({
|
||||
content,
|
||||
mode,
|
||||
className,
|
||||
onUrlClick,
|
||||
onFileClick
|
||||
}: {
|
||||
content: string
|
||||
mode: RenderMode
|
||||
className?: string
|
||||
onUrlClick?: (url: string) => void
|
||||
onFileClick?: (path: string) => void
|
||||
}) {
|
||||
return (
|
||||
<Markdown mode={mode} className={className} onUrlClick={onUrlClick} onFileClick={onFileClick}>
|
||||
{content}
|
||||
</Markdown>
|
||||
)
|
||||
},
|
||||
(prev, next) => {
|
||||
// Only re-render if content actually changed
|
||||
return prev.content === next.content && prev.mode === next.mode && prev.className === next.className
|
||||
}
|
||||
)
|
||||
MemoizedBlock.displayName = 'MemoizedBlock'
|
||||
|
||||
/**
|
||||
* StreamingMarkdown - Optimized markdown renderer for streaming content
|
||||
*
|
||||
* Splits content into blocks (paragraphs, code blocks) and memoizes each block
|
||||
* independently. Only the last (active) block re-renders during streaming.
|
||||
*
|
||||
* Key insight: Completed blocks get a content-hash as their React key.
|
||||
* Same content = same key = React skips re-render entirely.
|
||||
*
|
||||
* @example
|
||||
* Content: "Hello\n\n```js\ncode\n```\n\nMore..."
|
||||
*
|
||||
* Block 1: "Hello" -> key="block-abc123" -> memoized
|
||||
* Block 2: "```js\ncode\n```" -> key="block-xyz789" -> memoized
|
||||
* Block 3: "More..." -> key="active-2" -> re-renders
|
||||
*/
|
||||
export function StreamingMarkdown({
|
||||
content,
|
||||
isStreaming,
|
||||
mode = 'minimal',
|
||||
className,
|
||||
onUrlClick,
|
||||
onFileClick
|
||||
}: StreamingMarkdownProps): React.JSX.Element {
|
||||
// Split into blocks - memoized to avoid recomputation
|
||||
// Must be called unconditionally to satisfy Rules of Hooks
|
||||
const blocks = React.useMemo(
|
||||
() => (isStreaming ? splitIntoBlocks(content) : []),
|
||||
[content, isStreaming]
|
||||
)
|
||||
|
||||
// Not streaming - use simple Markdown (no block splitting needed)
|
||||
if (!isStreaming) {
|
||||
return (
|
||||
<Markdown mode={mode} className={className} onUrlClick={onUrlClick} onFileClick={onFileClick}>
|
||||
{content}
|
||||
</Markdown>
|
||||
)
|
||||
}
|
||||
|
||||
// Empty content - return null, let parent handle loading indicator
|
||||
if (blocks.length === 0) {
|
||||
return <></>
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{blocks.map((block, i) => {
|
||||
const isLastBlock = i === blocks.length - 1
|
||||
|
||||
// Complete blocks use content hash as key -> stable identity -> memoized
|
||||
// Last block uses "active" prefix -> always re-renders on content change
|
||||
const key = isLastBlock ? `active-${i}` : `block-${i}-${simpleHash(block.content)}`
|
||||
|
||||
return (
|
||||
<MemoizedBlock
|
||||
key={key}
|
||||
content={block.content}
|
||||
mode={mode}
|
||||
className={className}
|
||||
onUrlClick={onUrlClick}
|
||||
onFileClick={onFileClick}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
export function StreamingMarkdown(props: StreamingMarkdownProps): React.JSX.Element {
|
||||
return <StreamingMarkdownBase renderMention={defaultRenderMention} {...props} />
|
||||
}
|
||||
|
||||
@@ -2,3 +2,4 @@ export { Markdown, MemoizedMarkdown, type MarkdownProps, type RenderMode } from
|
||||
export { CodeBlock, InlineCode, type CodeBlockProps } from './CodeBlock'
|
||||
export { StreamingMarkdown, type StreamingMarkdownProps } from './StreamingMarkdown'
|
||||
export { preprocessLinks, detectLinks, hasLinks } from './linkify'
|
||||
export { preprocessMentionShortcodes } from './mentions'
|
||||
|
||||
@@ -1,215 +1 @@
|
||||
import LinkifyIt from 'linkify-it'
|
||||
|
||||
/**
|
||||
* Linkify - URL and file path detection for markdown preprocessing
|
||||
*
|
||||
* Uses linkify-it (12M downloads/week) for battle-tested URL detection,
|
||||
* plus custom regex for local file paths.
|
||||
*/
|
||||
|
||||
// Initialize linkify-it with default settings (fuzzy URLs, emails enabled)
|
||||
const linkify = new LinkifyIt()
|
||||
|
||||
// File path regex - detects /path, ~/path, ./path with common extensions
|
||||
// Matches paths that start with /, ~/, or ./ followed by path chars and a file extension
|
||||
const FILE_PATH_REGEX =
|
||||
/(?:^|[\s([{<])((\/|~\/|\.\/)[\w\-./@]+\.(?:ts|tsx|js|jsx|mjs|cjs|md|json|yaml|yml|py|go|rs|css|scss|less|html|htm|txt|log|sh|bash|zsh|swift|kt|java|c|cpp|h|hpp|rb|php|xml|toml|ini|cfg|conf|env|sql|graphql|vue|svelte|astro|prisma|dockerfile|makefile|gitignore))(?=[\s)\]}.,;:!?>]|$)/gi
|
||||
|
||||
interface DetectedLink {
|
||||
type: 'url' | 'email' | 'file'
|
||||
text: string
|
||||
url: string
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
interface CodeRange {
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all code block and inline code ranges in text
|
||||
* These ranges should be excluded from link detection
|
||||
*/
|
||||
function findCodeRanges(text: string): CodeRange[] {
|
||||
const ranges: CodeRange[] = []
|
||||
|
||||
// Find fenced code blocks (```...```)
|
||||
const fencedRegex = /```[\s\S]*?```/g
|
||||
let match
|
||||
while ((match = fencedRegex.exec(text)) !== null) {
|
||||
ranges.push({ start: match.index, end: match.index + match[0].length })
|
||||
}
|
||||
|
||||
// Find display math blocks ($$...$$)
|
||||
const displayMathRegex = /\$\$[\s\S]*?\$\$/g
|
||||
while ((match = displayMathRegex.exec(text)) !== null) {
|
||||
const pos = match.index
|
||||
const insideOther = ranges.some((r) => pos >= r.start && pos < r.end)
|
||||
if (!insideOther) {
|
||||
ranges.push({ start: pos, end: pos + match[0].length })
|
||||
}
|
||||
}
|
||||
|
||||
// Find inline math ($...$)
|
||||
const inlineMathRegex = /(?<!\$)\$(?!\$)([^\$\n]+)\$(?!\$)/g
|
||||
while ((match = inlineMathRegex.exec(text)) !== null) {
|
||||
const pos = match.index
|
||||
const insideOther = ranges.some((r) => pos >= r.start && pos < r.end)
|
||||
if (!insideOther) {
|
||||
ranges.push({ start: pos, end: pos + match[0].length })
|
||||
}
|
||||
}
|
||||
|
||||
// Find inline code (`...`)
|
||||
// But skip escaped backticks and code inside fenced blocks
|
||||
const inlineRegex = /(?<!`)`(?!`)([^`\n]+)`(?!`)/g
|
||||
while ((match = inlineRegex.exec(text)) !== null) {
|
||||
const pos = match.index
|
||||
// Check if this is inside a fenced block or math block
|
||||
const insideOther = ranges.some((r) => pos >= r.start && pos < r.end)
|
||||
if (!insideOther) {
|
||||
ranges.push({ start: pos, end: pos + match[0].length })
|
||||
}
|
||||
}
|
||||
|
||||
return ranges
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a position is inside any code range
|
||||
*/
|
||||
function isInsideCode(pos: number, ranges: CodeRange[]): boolean {
|
||||
return ranges.some((r) => pos >= r.start && pos < r.end)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a link at given position is already a markdown link
|
||||
* Looks for patterns like [text](url) or [text][ref]
|
||||
*/
|
||||
function isAlreadyLinked(text: string, linkStart: number, linkEnd: number): boolean {
|
||||
// Check if preceded by ]( which indicates we're inside a markdown link href
|
||||
// Pattern: [text](URL) - we're checking if URL is our link
|
||||
const before = text.slice(Math.max(0, linkStart - 2), linkStart)
|
||||
if (before.endsWith('](')) return true
|
||||
|
||||
// Check if preceded by ][ for reference links
|
||||
if (before.endsWith('][')) return true
|
||||
|
||||
// Check if the link text is wrapped in []
|
||||
// Pattern: [URL](href) - URL is being used as link text
|
||||
const charBefore = text[linkStart - 1]
|
||||
const charAfter = text[linkEnd]
|
||||
if (charBefore === '[' && charAfter === ']') return true
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if ranges overlap
|
||||
*/
|
||||
function rangesOverlap(
|
||||
a: { start: number; end: number },
|
||||
b: { start: number; end: number }
|
||||
): boolean {
|
||||
return a.start < b.end && b.start < a.end
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect all links (URLs, emails, file paths) in text
|
||||
*/
|
||||
export function detectLinks(text: string): DetectedLink[] {
|
||||
const links: DetectedLink[] = []
|
||||
|
||||
// 1. Detect URLs and emails with linkify-it
|
||||
const urlMatches = linkify.match(text) || []
|
||||
for (const match of urlMatches) {
|
||||
links.push({
|
||||
type: match.schema === 'mailto:' ? 'email' : 'url',
|
||||
text: match.text,
|
||||
url: match.url,
|
||||
start: match.index,
|
||||
end: match.lastIndex
|
||||
})
|
||||
}
|
||||
|
||||
// 2. Detect file paths with custom regex
|
||||
// Reset regex state
|
||||
FILE_PATH_REGEX.lastIndex = 0
|
||||
let fileMatch
|
||||
while ((fileMatch = FILE_PATH_REGEX.exec(text)) !== null) {
|
||||
const path = fileMatch[1]
|
||||
if (!path) continue // Skip if no capture group
|
||||
|
||||
// Calculate actual start position (after any leading whitespace/punctuation)
|
||||
const fullMatch = fileMatch[0]
|
||||
const pathOffset = fullMatch.indexOf(path)
|
||||
const start = fileMatch.index + pathOffset
|
||||
|
||||
// Check for overlaps with URL matches (URLs take precedence)
|
||||
const pathRange = { start, end: start + path.length }
|
||||
const overlapsUrl = links.some((link) => rangesOverlap(pathRange, link))
|
||||
if (overlapsUrl) continue
|
||||
|
||||
links.push({
|
||||
type: 'file',
|
||||
text: path,
|
||||
url: path, // File paths are passed as-is to onFileClick handler
|
||||
start,
|
||||
end: start + path.length
|
||||
})
|
||||
}
|
||||
|
||||
// Sort by position
|
||||
return links.sort((a, b) => a.start - b.start)
|
||||
}
|
||||
|
||||
/**
|
||||
* Preprocess text to convert raw URLs and file paths into markdown links
|
||||
* Skips code blocks and already-linked content
|
||||
*/
|
||||
export function preprocessLinks(text: string): string {
|
||||
// Quick check - if no potential links, return early
|
||||
if (!linkify.pretest(text) && !/[~/.]\//.test(text)) {
|
||||
return text
|
||||
}
|
||||
|
||||
const codeRanges = findCodeRanges(text)
|
||||
const links = detectLinks(text)
|
||||
|
||||
if (links.length === 0) return text
|
||||
|
||||
// Build result, converting raw links to markdown links
|
||||
let result = ''
|
||||
let lastIndex = 0
|
||||
|
||||
for (const link of links) {
|
||||
// Skip if inside code block
|
||||
if (isInsideCode(link.start, codeRanges)) continue
|
||||
|
||||
// Skip if already a markdown link
|
||||
if (isAlreadyLinked(text, link.start, link.end)) continue
|
||||
|
||||
// Add text before this link
|
||||
result += text.slice(lastIndex, link.start)
|
||||
|
||||
// Convert to markdown link
|
||||
result += `[${link.text}](${link.url})`
|
||||
|
||||
lastIndex = link.end
|
||||
}
|
||||
|
||||
// Add remaining text
|
||||
result += text.slice(lastIndex)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Test if text contains any detectable links
|
||||
* Useful for optimization - skip preprocessing if no links present
|
||||
*/
|
||||
export function hasLinks(text: string): boolean {
|
||||
return linkify.pretest(text) || /[~/.]\/[\w]/.test(text)
|
||||
}
|
||||
export { preprocessLinks, detectLinks, hasLinks } from '@multica/ui/markdown'
|
||||
|
||||
1
apps/web/components/markdown/mentions.ts
Normal file
1
apps/web/components/markdown/mentions.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { preprocessMentionShortcodes } from '@multica/ui/markdown'
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
|
||||
interface MulticaIconProps extends React.ComponentProps<"span"> {
|
||||
/**
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* Inherits color from `currentColor` (use Tailwind `text-*`).
|
||||
* Scales with font-size (use Tailwind `text-*` for size).
|
||||
*/
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@multica/ui/lib/utils"
|
||||
|
||||
export interface SpinnerProps {
|
||||
/** Additional className for styling (color via text-*, size via Tailwind text-*) */
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes"
|
||||
import { TooltipProvider } from "@/components/ui/tooltip"
|
||||
import { TooltipProvider } from "@multica/ui/components/ui/tooltip"
|
||||
|
||||
function ThemeProvider({
|
||||
children,
|
||||
|
||||
@@ -7,8 +7,8 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { SidebarMenuButton } from "@/components/ui/sidebar"
|
||||
} from "@multica/ui/components/ui/dropdown-menu"
|
||||
import { SidebarMenuButton } from "@multica/ui/components/ui/sidebar"
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { setTheme } = useTheme()
|
||||
|
||||
9
apps/web/features/auth/auth-cookie.ts
Normal file
9
apps/web/features/auth/auth-cookie.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
const COOKIE_NAME = "multica_logged_in";
|
||||
|
||||
export function setLoggedInCookie() {
|
||||
document.cookie = `${COOKIE_NAME}=1; path=/; max-age=31536000; samesite=lax`;
|
||||
}
|
||||
|
||||
export function clearLoggedInCookie() {
|
||||
document.cookie = `${COOKIE_NAME}=; path=/; max-age=0`;
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
export { useAuthStore } from "./store";
|
||||
export { useAuthStore } from "@/platform/auth";
|
||||
export { AuthInitializer } from "./initializer";
|
||||
export { setLoggedInCookie } from "./auth-cookie";
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, type ReactNode } from "react";
|
||||
import { useAuthStore } from "./store";
|
||||
import { useWorkspaceStore } from "@/features/workspace";
|
||||
import { api } from "@/shared/api";
|
||||
import { createLogger } from "@/shared/logger";
|
||||
import { useAuthStore } from "@/platform/auth";
|
||||
import { useWorkspaceStore } from "@/platform/workspace";
|
||||
import { api } from "@/platform/api";
|
||||
import { createLogger } from "@multica/core/logger";
|
||||
import { setLoggedInCookie, clearLoggedInCookie } from "./auth-cookie";
|
||||
|
||||
const logger = createLogger("auth");
|
||||
|
||||
@@ -16,6 +17,7 @@ export function AuthInitializer({ children }: { children: ReactNode }) {
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem("multica_token");
|
||||
if (!token) {
|
||||
clearLoggedInCookie();
|
||||
useAuthStore.setState({ isLoading: false });
|
||||
return;
|
||||
}
|
||||
@@ -29,6 +31,7 @@ export function AuthInitializer({ children }: { children: ReactNode }) {
|
||||
|
||||
Promise.all([mePromise, wsPromise])
|
||||
.then(([user, wsList]) => {
|
||||
setLoggedInCookie();
|
||||
useAuthStore.setState({ user, isLoading: false });
|
||||
useWorkspaceStore.getState().hydrateWorkspace(wsList, wsId);
|
||||
})
|
||||
@@ -38,6 +41,7 @@ export function AuthInitializer({ children }: { children: ReactNode }) {
|
||||
api.setWorkspaceId(null);
|
||||
localStorage.removeItem("multica_token");
|
||||
localStorage.removeItem("multica_workspace_id");
|
||||
clearLoggedInCookie();
|
||||
useAuthStore.setState({ user: null, isLoading: false });
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { create } from "zustand";
|
||||
import type { User } from "@/shared/types";
|
||||
import { api } from "@/shared/api";
|
||||
|
||||
interface AuthState {
|
||||
user: User | null;
|
||||
isLoading: boolean;
|
||||
|
||||
initialize: () => Promise<void>;
|
||||
sendCode: (email: string) => Promise<void>;
|
||||
verifyCode: (email: string, code: string) => Promise<User>;
|
||||
logout: () => void;
|
||||
setUser: (user: User) => void;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>((set) => ({
|
||||
user: null,
|
||||
isLoading: true,
|
||||
|
||||
initialize: async () => {
|
||||
const token = localStorage.getItem("multica_token");
|
||||
if (!token) {
|
||||
set({ isLoading: false });
|
||||
return;
|
||||
}
|
||||
|
||||
api.setToken(token);
|
||||
|
||||
try {
|
||||
const user = await api.getMe();
|
||||
set({ user, isLoading: false });
|
||||
} catch {
|
||||
api.setToken(null);
|
||||
api.setWorkspaceId(null);
|
||||
localStorage.removeItem("multica_token");
|
||||
localStorage.removeItem("multica_workspace_id");
|
||||
set({ user: null, isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
sendCode: async (email: string) => {
|
||||
await api.sendCode(email);
|
||||
},
|
||||
|
||||
verifyCode: async (email: string, code: string) => {
|
||||
const { token, user } = await api.verifyCode(email, code);
|
||||
localStorage.setItem("multica_token", token);
|
||||
api.setToken(token);
|
||||
set({ user });
|
||||
return user;
|
||||
},
|
||||
|
||||
logout: () => {
|
||||
localStorage.removeItem("multica_token");
|
||||
localStorage.removeItem("multica_workspace_id");
|
||||
api.setToken(null);
|
||||
api.setWorkspaceId(null);
|
||||
set({ user: null });
|
||||
},
|
||||
|
||||
setUser: (user: User) => {
|
||||
set({ user });
|
||||
},
|
||||
}));
|
||||
@@ -1 +0,0 @@
|
||||
export { useInboxStore } from "./store";
|
||||
@@ -1,117 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { create } from "zustand";
|
||||
import type { InboxItem, IssueStatus } from "@/shared/types";
|
||||
import { api } from "@/shared/api";
|
||||
import { createLogger } from "@/shared/logger";
|
||||
|
||||
const logger = createLogger("inbox-store");
|
||||
|
||||
/**
|
||||
* Deduplicate inbox items by issue_id (one entry per issue, Linear-style),
|
||||
* keep latest, sort by time DESC.
|
||||
* Memoized by reference — returns the same array if `items` hasn't changed.
|
||||
*/
|
||||
let _prevItems: InboxItem[] = [];
|
||||
let _prevDeduped: InboxItem[] = [];
|
||||
|
||||
function deduplicateInboxItems(items: InboxItem[]): InboxItem[] {
|
||||
if (items === _prevItems) return _prevDeduped;
|
||||
_prevItems = items;
|
||||
|
||||
const active = items.filter((i) => !i.archived);
|
||||
const groups = new Map<string, InboxItem[]>();
|
||||
active.forEach((item) => {
|
||||
const key = item.issue_id ?? item.id;
|
||||
const group = groups.get(key) ?? [];
|
||||
group.push(item);
|
||||
groups.set(key, group);
|
||||
});
|
||||
const merged: InboxItem[] = [];
|
||||
groups.forEach((group) => {
|
||||
const sorted = group.sort(
|
||||
(a, b) =>
|
||||
new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
|
||||
);
|
||||
if (sorted[0]) merged.push(sorted[0]);
|
||||
});
|
||||
_prevDeduped = merged.sort(
|
||||
(a, b) =>
|
||||
new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
|
||||
);
|
||||
return _prevDeduped;
|
||||
}
|
||||
|
||||
interface InboxState {
|
||||
items: InboxItem[];
|
||||
loading: boolean;
|
||||
fetch: () => Promise<void>;
|
||||
setItems: (items: InboxItem[]) => void;
|
||||
addItem: (item: InboxItem) => void;
|
||||
markRead: (id: string) => void;
|
||||
archive: (id: string) => void;
|
||||
markAllRead: () => void;
|
||||
archiveAll: () => void;
|
||||
archiveAllRead: () => void;
|
||||
updateIssueStatus: (issueId: string, status: IssueStatus) => void;
|
||||
dedupedItems: () => InboxItem[];
|
||||
unreadCount: () => number;
|
||||
}
|
||||
|
||||
export const useInboxStore = create<InboxState>((set, get) => ({
|
||||
items: [],
|
||||
loading: true,
|
||||
|
||||
fetch: async () => {
|
||||
logger.debug("fetch start");
|
||||
const isInitialLoad = get().items.length === 0;
|
||||
if (isInitialLoad) set({ loading: true });
|
||||
try {
|
||||
const data = await api.listInbox();
|
||||
logger.info("fetched", data.length, "items");
|
||||
set({ items: data, loading: false });
|
||||
} catch (err) {
|
||||
logger.error("fetch failed", err);
|
||||
if (isInitialLoad) set({ loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
setItems: (items) => set({ items }),
|
||||
addItem: (item) =>
|
||||
set((s) => ({
|
||||
items: s.items.some((i) => i.id === item.id)
|
||||
? s.items
|
||||
: [item, ...s.items],
|
||||
})),
|
||||
markRead: (id) =>
|
||||
set((s) => ({
|
||||
items: s.items.map((i) => (i.id === id ? { ...i, read: true } : i)),
|
||||
})),
|
||||
archive: (id) =>
|
||||
set((s) => ({
|
||||
items: s.items.map((i) => (i.id === id ? { ...i, archived: true } : i)),
|
||||
})),
|
||||
markAllRead: () =>
|
||||
set((s) => ({
|
||||
items: s.items.map((i) => (!i.archived ? { ...i, read: true } : i)),
|
||||
})),
|
||||
archiveAll: () =>
|
||||
set((s) => ({
|
||||
items: s.items.map((i) => (!i.archived ? { ...i, archived: true } : i)),
|
||||
})),
|
||||
archiveAllRead: () =>
|
||||
set((s) => ({
|
||||
items: s.items.map((i) =>
|
||||
i.read && !i.archived ? { ...i, archived: true } : i
|
||||
),
|
||||
})),
|
||||
updateIssueStatus: (issueId, status) =>
|
||||
set((s) => ({
|
||||
items: s.items.map((i) =>
|
||||
i.issue_id === issueId ? { ...i, issue_status: status } : i
|
||||
),
|
||||
})),
|
||||
dedupedItems: () => deduplicateInboxItems(get().items),
|
||||
unreadCount: () =>
|
||||
get().dedupedItems().filter((i) => !i.read).length,
|
||||
}));
|
||||
@@ -1,342 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { X, Trash2, Bot, Lock, UserMinus } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import {
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
} from "@/components/ui/popover";
|
||||
import type { Agent, UpdateIssueRequest } from "@/shared/types";
|
||||
import { ALL_STATUSES, STATUS_CONFIG, PRIORITY_ORDER, PRIORITY_CONFIG } from "@/features/issues/config";
|
||||
import { useAuthStore } from "@/features/auth";
|
||||
import { useWorkspaceStore, useActorName } from "@/features/workspace";
|
||||
import { useIssueStore } from "@/features/issues/store";
|
||||
import { useIssueSelectionStore } from "@/features/issues/stores/selection-store";
|
||||
import { api } from "@/shared/api";
|
||||
import { StatusIcon } from "./status-icon";
|
||||
import { PriorityIcon } from "./priority-icon";
|
||||
|
||||
export function BatchActionToolbar() {
|
||||
const selectedIds = useIssueSelectionStore((s) => s.selectedIds);
|
||||
const clear = useIssueSelectionStore((s) => s.clear);
|
||||
const count = selectedIds.size;
|
||||
|
||||
const [statusOpen, setStatusOpen] = useState(false);
|
||||
const [priorityOpen, setPriorityOpen] = useState(false);
|
||||
const [assigneeOpen, setAssigneeOpen] = useState(false);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
if (count === 0) return null;
|
||||
|
||||
const ids = Array.from(selectedIds);
|
||||
|
||||
const handleBatchUpdate = async (updates: UpdateIssueRequest) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await api.batchUpdateIssues(ids, updates);
|
||||
for (const id of ids) {
|
||||
useIssueStore.getState().updateIssue(id, updates);
|
||||
}
|
||||
toast.success(`Updated ${count} issue${count > 1 ? "s" : ""}`);
|
||||
} catch {
|
||||
toast.error("Failed to update issues");
|
||||
api.listIssues({ limit: 200 }).then((res) => {
|
||||
useIssueStore.getState().setIssues(res.issues);
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchDelete = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await api.batchDeleteIssues(ids);
|
||||
for (const id of ids) {
|
||||
useIssueStore.getState().removeIssue(id);
|
||||
}
|
||||
clear();
|
||||
toast.success(`Deleted ${count} issue${count > 1 ? "s" : ""}`);
|
||||
} catch {
|
||||
toast.error("Failed to delete issues");
|
||||
api.listIssues({ limit: 200 }).then((res) => {
|
||||
useIssueStore.getState().setIssues(res.issues);
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setDeleteOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 flex items-center gap-1 rounded-lg border bg-background px-2 py-1.5 shadow-lg">
|
||||
<div className="flex items-center gap-1.5 pl-1 pr-2 border-r mr-1">
|
||||
<span className="text-sm font-medium">{count} selected</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={clear}
|
||||
className="rounded p-0.5 hover:bg-accent transition-colors"
|
||||
>
|
||||
<X className="size-3.5 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<Popover open={statusOpen} onOpenChange={setStatusOpen}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button variant="ghost" size="sm" disabled={loading} />
|
||||
}
|
||||
>
|
||||
<StatusIcon status="todo" className="h-3.5 w-3.5 mr-1" />
|
||||
Status
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="center" className="w-44 p-1">
|
||||
{ALL_STATUSES.map((s) => {
|
||||
const cfg = STATUS_CONFIG[s];
|
||||
return (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
handleBatchUpdate({ status: s });
|
||||
setStatusOpen(false);
|
||||
}}
|
||||
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm ${cfg.hoverBg} transition-colors`}
|
||||
>
|
||||
<StatusIcon status={s} className="h-3.5 w-3.5" />
|
||||
<span>{cfg.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* Priority */}
|
||||
<Popover open={priorityOpen} onOpenChange={setPriorityOpen}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button variant="ghost" size="sm" disabled={loading} />
|
||||
}
|
||||
>
|
||||
<PriorityIcon priority="high" className="mr-1" />
|
||||
Priority
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="center" className="w-44 p-1">
|
||||
{PRIORITY_ORDER.map((p) => {
|
||||
const cfg = PRIORITY_CONFIG[p];
|
||||
return (
|
||||
<button
|
||||
key={p}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
handleBatchUpdate({ priority: p });
|
||||
setPriorityOpen(false);
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors"
|
||||
>
|
||||
<span className={`inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs font-medium ${cfg.badgeBg} ${cfg.badgeText}`}>
|
||||
<PriorityIcon priority={p} className="h-3 w-3" inheritColor />
|
||||
{cfg.label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* Assignee */}
|
||||
<BatchAssigneePicker
|
||||
open={assigneeOpen}
|
||||
onOpenChange={setAssigneeOpen}
|
||||
onUpdate={handleBatchUpdate}
|
||||
loading={loading}
|
||||
/>
|
||||
|
||||
{/* Delete */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={loading}
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
className="text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="size-3.5 mr-1" />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
Delete {count} issue{count > 1 ? "s" : ""}?
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This action cannot be undone. This will permanently delete the
|
||||
selected issue{count > 1 ? "s" : ""} and all associated data.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleBatchDelete}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function canAssignAgent(agent: Agent, userId: string | undefined, memberRole: string | undefined): boolean {
|
||||
if (agent.visibility !== "private") return true;
|
||||
if (agent.owner_id === userId) return true;
|
||||
if (memberRole === "owner" || memberRole === "admin") return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function BatchAssigneePicker({
|
||||
open,
|
||||
onOpenChange,
|
||||
onUpdate,
|
||||
loading,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
onUpdate: (updates: UpdateIssueRequest) => void;
|
||||
loading: boolean;
|
||||
}) {
|
||||
const [filter, setFilter] = useState("");
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const members = useWorkspaceStore((s) => s.members);
|
||||
const agents = useWorkspaceStore((s) => s.agents);
|
||||
const { getActorInitials } = useActorName();
|
||||
|
||||
const currentMember = members.find((m) => m.user_id === user?.id);
|
||||
const memberRole = currentMember?.role;
|
||||
|
||||
const query = filter.toLowerCase();
|
||||
const filteredMembers = members.filter((m) =>
|
||||
m.name.toLowerCase().includes(query),
|
||||
);
|
||||
const filteredAgents = agents.filter((a) =>
|
||||
a.name.toLowerCase().includes(query),
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={(v) => {
|
||||
onOpenChange(v);
|
||||
if (!v) setFilter("");
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button variant="ghost" size="sm" disabled={loading} />
|
||||
}
|
||||
>
|
||||
Assignee
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="center" className="w-52 p-0">
|
||||
<div className="px-2 py-1.5 border-b">
|
||||
<input
|
||||
type="text"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
placeholder="Assign to..."
|
||||
className="w-full bg-transparent text-sm placeholder:text-muted-foreground outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="p-1 max-h-60 overflow-y-auto">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onUpdate({ assignee_type: null, assignee_id: null });
|
||||
onOpenChange(false);
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors"
|
||||
>
|
||||
<UserMinus className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">Unassigned</span>
|
||||
</button>
|
||||
|
||||
{filteredMembers.length > 0 && (
|
||||
<div>
|
||||
<div className="px-2 pt-2 pb-1 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Members
|
||||
</div>
|
||||
{filteredMembers.map((m) => (
|
||||
<button
|
||||
key={m.user_id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onUpdate({ assignee_type: "member", assignee_id: m.user_id });
|
||||
onOpenChange(false);
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent transition-colors"
|
||||
>
|
||||
<div className="inline-flex size-4.5 shrink-0 items-center justify-center rounded-full bg-muted text-[8px] font-medium text-muted-foreground">
|
||||
{getActorInitials("member", m.user_id)}
|
||||
</div>
|
||||
<span>{m.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredAgents.length > 0 && (
|
||||
<div>
|
||||
<div className="px-2 pt-2 pb-1 text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Agents
|
||||
</div>
|
||||
{filteredAgents.map((a) => {
|
||||
const allowed = canAssignAgent(a, user?.id, memberRole);
|
||||
return (
|
||||
<button
|
||||
key={a.id}
|
||||
type="button"
|
||||
disabled={!allowed}
|
||||
onClick={() => {
|
||||
if (!allowed) return;
|
||||
onUpdate({ assignee_type: "agent", assignee_id: a.id });
|
||||
onOpenChange(false);
|
||||
}}
|
||||
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors ${allowed ? "hover:bg-accent" : "opacity-50 cursor-not-allowed"}`}
|
||||
>
|
||||
<div className={`inline-flex size-4.5 shrink-0 items-center justify-center rounded-full ${allowed ? "bg-info/10 text-info" : "bg-muted text-muted-foreground"}`}>
|
||||
<Bot className="size-2.5" />
|
||||
</div>
|
||||
<span className={allowed ? "" : "text-muted-foreground"}>{a.name}</span>
|
||||
{a.visibility === "private" && (
|
||||
<Lock className="ml-auto h-3 w-3 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -1,260 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import {
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
pointerWithin,
|
||||
closestCenter,
|
||||
type CollisionDetection,
|
||||
type DragStartEvent,
|
||||
type DragEndEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import { Eye, MoreHorizontal } from "lucide-react";
|
||||
import type { Issue, IssueStatus } from "@/shared/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { ALL_STATUSES, STATUS_CONFIG } from "@/features/issues/config";
|
||||
import { useIssueViewStore } from "@/features/issues/stores/view-store";
|
||||
import { StatusIcon } from "./status-icon";
|
||||
import { BoardColumn } from "./board-column";
|
||||
import { BoardCardContent } from "./board-card";
|
||||
|
||||
const COLUMN_IDS = new Set<string>(ALL_STATUSES);
|
||||
|
||||
const kanbanCollision: CollisionDetection = (args) => {
|
||||
const pointer = pointerWithin(args);
|
||||
if (pointer.length > 0) {
|
||||
// Prefer card collisions over column collisions so that
|
||||
// dragging down within a column finds the target card
|
||||
// instead of the column droppable.
|
||||
const cards = pointer.filter((c) => !COLUMN_IDS.has(c.id as string));
|
||||
if (cards.length > 0) return cards;
|
||||
}
|
||||
// Fallback: closestCenter finds the nearest card even when
|
||||
// the pointer is in a gap between cards (common when dragging down).
|
||||
return closestCenter(args);
|
||||
};
|
||||
|
||||
/** Compute a float position to place an item at `targetIndex` within `siblings`. */
|
||||
function computePosition(siblings: Issue[], targetIndex: number): number {
|
||||
if (siblings.length === 0) return 0;
|
||||
if (targetIndex <= 0) return siblings[0]!.position - 1;
|
||||
if (targetIndex >= siblings.length)
|
||||
return siblings[siblings.length - 1]!.position + 1;
|
||||
return (siblings[targetIndex - 1]!.position + siblings[targetIndex]!.position) / 2;
|
||||
}
|
||||
|
||||
export function BoardView({
|
||||
issues,
|
||||
allIssues,
|
||||
visibleStatuses,
|
||||
hiddenStatuses,
|
||||
onMoveIssue,
|
||||
}: {
|
||||
issues: Issue[];
|
||||
allIssues: Issue[];
|
||||
visibleStatuses: IssueStatus[];
|
||||
hiddenStatuses: IssueStatus[];
|
||||
onMoveIssue: (
|
||||
issueId: string,
|
||||
newStatus: IssueStatus,
|
||||
newPosition?: number
|
||||
) => void;
|
||||
}) {
|
||||
const [activeIssue, setActiveIssue] = useState<Issue | null>(null);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: { distance: 5 },
|
||||
})
|
||||
);
|
||||
|
||||
// Pre-sort issues by position per status for position calculations
|
||||
const issuesByStatus = useMemo(() => {
|
||||
const map: Record<string, Issue[]> = {};
|
||||
for (const status of visibleStatuses) {
|
||||
map[status] = issues
|
||||
.filter((i) => i.status === status)
|
||||
.sort((a, b) => a.position - b.position);
|
||||
}
|
||||
return map;
|
||||
}, [issues, visibleStatuses]);
|
||||
|
||||
const handleDragStart = useCallback(
|
||||
(event: DragStartEvent) => {
|
||||
const issue = issues.find((i) => i.id === event.active.id);
|
||||
if (issue) setActiveIssue(issue);
|
||||
},
|
||||
[issues]
|
||||
);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
setActiveIssue(null);
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
|
||||
const issueId = active.id as string;
|
||||
const currentIssue = issues.find((i) => i.id === issueId);
|
||||
if (!currentIssue) return;
|
||||
|
||||
// Determine target status
|
||||
let targetStatus: IssueStatus;
|
||||
let overIsColumn = false;
|
||||
|
||||
if (visibleStatuses.includes(over.id as IssueStatus)) {
|
||||
targetStatus = over.id as IssueStatus;
|
||||
overIsColumn = true;
|
||||
} else {
|
||||
const targetIssue = issues.find((i) => i.id === over.id);
|
||||
if (!targetIssue) return;
|
||||
targetStatus = targetIssue.status;
|
||||
}
|
||||
|
||||
// Get sorted siblings in the target column (excluding the dragged item)
|
||||
const siblings = (issuesByStatus[targetStatus] ?? []).filter(
|
||||
(i) => i.id !== issueId
|
||||
);
|
||||
|
||||
// Compute new position
|
||||
let newPosition: number;
|
||||
|
||||
if (overIsColumn) {
|
||||
// Dropped on empty area of column → append to end
|
||||
newPosition = computePosition(siblings, siblings.length);
|
||||
} else {
|
||||
// Dropped on a specific card → insert at that card's index
|
||||
const overIndex = siblings.findIndex((i) => i.id === over.id);
|
||||
if (overIndex === -1) {
|
||||
newPosition = computePosition(siblings, siblings.length);
|
||||
} else {
|
||||
const isSameColumn = currentIssue.status === targetStatus;
|
||||
const overIssuePosition = siblings[overIndex]!.position;
|
||||
|
||||
if (isSameColumn && currentIssue.position < overIssuePosition) {
|
||||
// Moving down → insert after the over card
|
||||
newPosition = computePosition(siblings, overIndex + 1);
|
||||
} else {
|
||||
// Moving up or cross-column → insert before the over card
|
||||
newPosition = computePosition(siblings, overIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Skip if nothing changed
|
||||
if (
|
||||
currentIssue.status === targetStatus &&
|
||||
currentIssue.position === newPosition
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
onMoveIssue(issueId, targetStatus, newPosition);
|
||||
},
|
||||
[issues, issuesByStatus, onMoveIssue, visibleStatuses]
|
||||
);
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={kanbanCollision}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<div className="flex flex-1 min-h-0 gap-4 overflow-x-auto p-4">
|
||||
{visibleStatuses.map((status) => (
|
||||
<BoardColumn
|
||||
key={status}
|
||||
status={status}
|
||||
issues={issues.filter((i) => i.status === status)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{hiddenStatuses.length > 0 && (
|
||||
<HiddenColumnsPanel
|
||||
hiddenStatuses={hiddenStatuses}
|
||||
issues={allIssues}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DragOverlay>
|
||||
{activeIssue ? (
|
||||
<div className="w-[280px] rotate-1 cursor-grabbing opacity-95 shadow-md">
|
||||
<BoardCardContent issue={activeIssue} />
|
||||
</div>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
|
||||
function HiddenColumnsPanel({
|
||||
hiddenStatuses,
|
||||
issues,
|
||||
}: {
|
||||
hiddenStatuses: IssueStatus[];
|
||||
issues: Issue[];
|
||||
}) {
|
||||
return (
|
||||
<div className="flex w-[240px] shrink-0 flex-col">
|
||||
<div className="mb-2 flex items-center gap-2 px-1">
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
Hidden columns
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 space-y-0.5">
|
||||
{hiddenStatuses.map((status) => {
|
||||
const cfg = STATUS_CONFIG[status];
|
||||
const count = issues.filter((i) => i.status === status).length;
|
||||
return (
|
||||
<div
|
||||
key={status}
|
||||
className="flex items-center justify-between rounded-lg px-2.5 py-2 hover:bg-muted/50"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusIcon status={status} className="h-3.5 w-3.5" />
|
||||
<span className="text-sm">{cfg.label}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs text-muted-foreground">{count}</span>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="rounded-full text-muted-foreground"
|
||||
>
|
||||
<MoreHorizontal className="size-3.5" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
useIssueViewStore.getState().showStatus(status)
|
||||
}
|
||||
>
|
||||
<Eye className="size-3.5" />
|
||||
Show column
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { ArrowUp, Loader2, Paperclip } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { RichTextEditor, type RichTextEditorRef } from "@/components/common/rich-text-editor";
|
||||
import { useFileUpload } from "@/shared/hooks/use-file-upload";
|
||||
|
||||
interface CommentInputProps {
|
||||
issueId: string;
|
||||
onSubmit: (content: string, attachmentIds?: string[]) => Promise<void>;
|
||||
}
|
||||
|
||||
function CommentInput({ issueId, onSubmit }: CommentInputProps) {
|
||||
const editorRef = useRef<RichTextEditorRef>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const attachmentIdsRef = useRef<string[]>([]);
|
||||
const [isEmpty, setIsEmpty] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const { uploadWithToast, uploading } = useFileUpload();
|
||||
|
||||
const handleUpload = async (file: File) => {
|
||||
const result = await uploadWithToast(file, { issueId });
|
||||
if (result) attachmentIdsRef.current.push(result.id);
|
||||
return result;
|
||||
};
|
||||
|
||||
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
e.target.value = "";
|
||||
const result = await handleUpload(file);
|
||||
if (result) {
|
||||
editorRef.current?.insertFile(result.filename, result.link, file.type.startsWith("image/"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const content = editorRef.current?.getMarkdown()?.replace(/(\n\s*)+$/, "").trim();
|
||||
if (!content || submitting) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const ids = attachmentIdsRef.current.length > 0 ? [...attachmentIdsRef.current] : undefined;
|
||||
await onSubmit(content, ids);
|
||||
editorRef.current?.clearContent();
|
||||
attachmentIdsRef.current = [];
|
||||
setIsEmpty(true);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative rounded-lg bg-card ring-1 ring-border">
|
||||
<div className="min-h-20 max-h-48 overflow-y-auto px-3 py-2 pb-8">
|
||||
<RichTextEditor
|
||||
ref={editorRef}
|
||||
placeholder="Leave a comment..."
|
||||
onUpdate={(md) => setIsEmpty(!md.trim())}
|
||||
onSubmit={handleSubmit}
|
||||
onUploadFile={handleUpload}
|
||||
debounceMs={100}
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute bottom-1.5 right-1.5 flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Paperclip className="h-4 w-4" />
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
<Button
|
||||
size="icon-sm"
|
||||
disabled={isEmpty || submitting}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : <ArrowUp className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { CommentInput };
|
||||
@@ -1,37 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useIssueStore } from "@/features/issues/store";
|
||||
import { StatusIcon } from "./status-icon";
|
||||
|
||||
interface IssueMentionCardProps {
|
||||
issueId: string;
|
||||
/** Fallback text when issue is not in store (e.g. "MUL-7") */
|
||||
fallbackLabel?: string;
|
||||
}
|
||||
|
||||
export function IssueMentionCard({ issueId, fallbackLabel }: IssueMentionCardProps) {
|
||||
const issue = useIssueStore((s) => s.issues.find((i) => i.id === issueId));
|
||||
|
||||
if (!issue) {
|
||||
return (
|
||||
<Link
|
||||
href={`/issues/${issueId}`}
|
||||
className="text-primary font-medium cursor-pointer hover:underline"
|
||||
>
|
||||
{fallbackLabel ?? issueId.slice(0, 8)}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/issues/${issueId}`}
|
||||
className="inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-sm hover:bg-accent transition-colors cursor-pointer no-underline"
|
||||
>
|
||||
<StatusIcon status={issue.status} className="h-3.5 w-3.5" />
|
||||
<span className="font-medium text-muted-foreground">{issue.identifier}</span>
|
||||
<span className="text-foreground">{issue.title}</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import type { IssueStatus } from "@/shared/types";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useIssueStore } from "@/features/issues/store";
|
||||
import { useIssueViewStore, initFilterWorkspaceSync } from "@/features/issues/stores/view-store";
|
||||
import { filterIssues } from "@/features/issues/utils/filter";
|
||||
import { useWorkspaceStore } from "@/features/workspace";
|
||||
import { WorkspaceAvatar } from "@/features/workspace";
|
||||
import { api } from "@/shared/api";
|
||||
import { useIssueSelectionStore } from "@/features/issues/stores/selection-store";
|
||||
import { IssuesHeader } from "./issues-header";
|
||||
import { BoardView } from "./board-view";
|
||||
import { ListView } from "./list-view";
|
||||
import { BatchActionToolbar } from "./batch-action-toolbar";
|
||||
|
||||
const BOARD_STATUSES: IssueStatus[] = [
|
||||
"backlog",
|
||||
"todo",
|
||||
"in_progress",
|
||||
"in_review",
|
||||
"done",
|
||||
"blocked",
|
||||
];
|
||||
|
||||
export function IssuesPage() {
|
||||
const allIssues = useIssueStore((s) => s.issues);
|
||||
const loading = useIssueStore((s) => s.loading);
|
||||
const workspace = useWorkspaceStore((s) => s.workspace);
|
||||
const viewMode = useIssueViewStore((s) => s.viewMode);
|
||||
const statusFilters = useIssueViewStore((s) => s.statusFilters);
|
||||
const priorityFilters = useIssueViewStore((s) => s.priorityFilters);
|
||||
const assigneeFilters = useIssueViewStore((s) => s.assigneeFilters);
|
||||
const includeNoAssignee = useIssueViewStore((s) => s.includeNoAssignee);
|
||||
const creatorFilters = useIssueViewStore((s) => s.creatorFilters);
|
||||
|
||||
useEffect(() => {
|
||||
initFilterWorkspaceSync();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
useIssueSelectionStore.getState().clear();
|
||||
}, [viewMode]);
|
||||
|
||||
const issues = useMemo(
|
||||
() => filterIssues(allIssues, { statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters }),
|
||||
[allIssues, statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters],
|
||||
);
|
||||
|
||||
const visibleStatuses = useMemo(() => {
|
||||
if (statusFilters.length > 0)
|
||||
return BOARD_STATUSES.filter((s) => statusFilters.includes(s));
|
||||
return BOARD_STATUSES;
|
||||
}, [statusFilters]);
|
||||
|
||||
const hiddenStatuses = useMemo(() => {
|
||||
return BOARD_STATUSES.filter((s) => !visibleStatuses.includes(s));
|
||||
}, [visibleStatuses]);
|
||||
|
||||
const handleMoveIssue = useCallback(
|
||||
(issueId: string, newStatus: IssueStatus, newPosition?: number) => {
|
||||
// Auto-switch to manual sort so drag ordering is preserved
|
||||
if (useIssueViewStore.getState().sortBy !== "position") {
|
||||
useIssueViewStore.getState().setSortBy("position");
|
||||
useIssueViewStore.getState().setSortDirection("asc");
|
||||
}
|
||||
|
||||
const updates: Partial<{ status: IssueStatus; position: number }> = {
|
||||
status: newStatus,
|
||||
};
|
||||
if (newPosition !== undefined) updates.position = newPosition;
|
||||
|
||||
useIssueStore.getState().updateIssue(issueId, updates);
|
||||
|
||||
api.updateIssue(issueId, updates).catch(() => {
|
||||
toast.error("Failed to move issue");
|
||||
api.listIssues({ limit: 200 }).then((res) => {
|
||||
useIssueStore.getState().setIssues(res.issues);
|
||||
});
|
||||
});
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-1 min-h-0 flex-col">
|
||||
<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>
|
||||
<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>
|
||||
<div className="flex flex-1 min-h-0 gap-4 overflow-x-auto p-4">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="flex min-w-52 flex-1 flex-col gap-2">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-24 w-full rounded-lg" />
|
||||
<Skeleton className="h-24 w-full rounded-lg" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 min-h-0 flex-col">
|
||||
{/* Header 1: Workspace breadcrumb */}
|
||||
<div className="flex h-12 shrink-0 items-center gap-1.5 border-b px-4">
|
||||
<WorkspaceAvatar name={workspace?.name ?? "W"} size="sm" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{workspace?.name ?? "Workspace"}
|
||||
</span>
|
||||
<ChevronRight className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">Issues</span>
|
||||
</div>
|
||||
|
||||
{/* Header 2: View toggle + filters */}
|
||||
<IssuesHeader />
|
||||
|
||||
{/* Content: scrollable */}
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
{viewMode === "board" ? (
|
||||
<BoardView
|
||||
issues={issues}
|
||||
allIssues={allIssues}
|
||||
visibleStatuses={visibleStatuses}
|
||||
hiddenStatuses={hiddenStatuses}
|
||||
onMoveIssue={handleMoveIssue}
|
||||
/>
|
||||
) : (
|
||||
<ListView issues={issues} visibleStatuses={visibleStatuses} />
|
||||
)}
|
||||
</div>
|
||||
{viewMode === "list" && <BatchActionToolbar />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { ArrowUp, Loader2, Paperclip } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { RichTextEditor, type RichTextEditorRef } from "@/components/common/rich-text-editor";
|
||||
import { ActorAvatar } from "@/components/common/actor-avatar";
|
||||
import { useFileUpload } from "@/shared/hooks/use-file-upload";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ReplyInputProps {
|
||||
issueId: string;
|
||||
placeholder?: string;
|
||||
avatarType: string;
|
||||
avatarId: string;
|
||||
onSubmit: (content: string, attachmentIds?: string[]) => Promise<void>;
|
||||
size?: "sm" | "default";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ReplyInput
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ReplyInput({
|
||||
issueId,
|
||||
placeholder = "Leave a reply...",
|
||||
avatarType,
|
||||
avatarId,
|
||||
onSubmit,
|
||||
size = "default",
|
||||
}: ReplyInputProps) {
|
||||
const editorRef = useRef<RichTextEditorRef>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const attachmentIdsRef = useRef<string[]>([]);
|
||||
const [isEmpty, setIsEmpty] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const { uploadWithToast, uploading } = useFileUpload();
|
||||
|
||||
const handleUpload = async (file: File) => {
|
||||
const result = await uploadWithToast(file, { issueId });
|
||||
if (result) attachmentIdsRef.current.push(result.id);
|
||||
return result;
|
||||
};
|
||||
|
||||
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
e.target.value = "";
|
||||
const result = await handleUpload(file);
|
||||
if (result) {
|
||||
editorRef.current?.insertFile(result.filename, result.link, file.type.startsWith("image/"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const content = editorRef.current?.getMarkdown()?.replace(/(\n\s*)+$/, "").trim();
|
||||
if (!content || submitting) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const ids = attachmentIdsRef.current.length > 0 ? [...attachmentIdsRef.current] : undefined;
|
||||
await onSubmit(content, ids);
|
||||
editorRef.current?.clearContent();
|
||||
attachmentIdsRef.current = [];
|
||||
setIsEmpty(true);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const avatarSize = size === "sm" ? 22 : 28;
|
||||
|
||||
return (
|
||||
<div className="flex items-start gap-2.5">
|
||||
<ActorAvatar
|
||||
actorType={avatarType}
|
||||
actorId={avatarId}
|
||||
size={avatarSize}
|
||||
className="mt-0.5 shrink-0"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div
|
||||
className={`overflow-y-auto text-sm ${
|
||||
size === "sm" ? "max-h-32" : "max-h-48"
|
||||
}`}
|
||||
>
|
||||
<RichTextEditor
|
||||
ref={editorRef}
|
||||
placeholder={placeholder}
|
||||
onUpdate={(md) => setIsEmpty(!md.trim())}
|
||||
onSubmit={handleSubmit}
|
||||
onUploadFile={handleUpload}
|
||||
debounceMs={100}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={`grid transition-all duration-150 ${
|
||||
isEmpty ? "grid-rows-[0fr] opacity-0" : "grid-rows-[1fr] opacity-100"
|
||||
}`}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<div className="flex items-center justify-end gap-1 pt-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
tabIndex={isEmpty ? -1 : 0}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Paperclip className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
<Button
|
||||
size="icon-xs"
|
||||
disabled={isEmpty || submitting}
|
||||
onClick={handleSubmit}
|
||||
tabIndex={isEmpty ? -1 : 0}
|
||||
>
|
||||
{submitting ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <ArrowUp className="h-3.5 w-3.5" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { ReplyInput, type ReplyInputProps };
|
||||
@@ -1,2 +0,0 @@
|
||||
export { STATUS_ORDER, ALL_STATUSES, STATUS_CONFIG } from "./status";
|
||||
export { PRIORITY_ORDER, PRIORITY_CONFIG } from "./priority";
|
||||
@@ -1,109 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import type { IssueReaction } from "@/shared/types";
|
||||
import type {
|
||||
IssueReactionAddedPayload,
|
||||
IssueReactionRemovedPayload,
|
||||
} from "@/shared/types";
|
||||
import { api } from "@/shared/api";
|
||||
import { useWSEvent, useWSReconnect } from "@/features/realtime";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export function useIssueReactions(issueId: string, userId?: string) {
|
||||
const [reactions, setReactions] = useState<IssueReaction[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Initial fetch
|
||||
useEffect(() => {
|
||||
setReactions([]);
|
||||
setLoading(true);
|
||||
api
|
||||
.getIssue(issueId)
|
||||
.then((iss) => setReactions(iss.reactions ?? []))
|
||||
.catch(console.error)
|
||||
.finally(() => setLoading(false));
|
||||
}, [issueId]);
|
||||
|
||||
// Reconnect recovery
|
||||
useWSReconnect(
|
||||
useCallback(() => {
|
||||
api.getIssue(issueId).then((iss) => setReactions(iss.reactions ?? [])).catch(console.error);
|
||||
}, [issueId]),
|
||||
);
|
||||
|
||||
// --- WS event handlers ---
|
||||
|
||||
useWSEvent(
|
||||
"issue_reaction:added",
|
||||
useCallback(
|
||||
(payload: unknown) => {
|
||||
const { reaction, issue_id } = payload as IssueReactionAddedPayload;
|
||||
if (issue_id !== issueId) return;
|
||||
if (reaction.actor_type === "member" && reaction.actor_id === userId) return;
|
||||
setReactions((prev) => {
|
||||
if (prev.some((r) => r.id === reaction.id)) return prev;
|
||||
return [...prev, reaction];
|
||||
});
|
||||
},
|
||||
[issueId, userId],
|
||||
),
|
||||
);
|
||||
|
||||
useWSEvent(
|
||||
"issue_reaction:removed",
|
||||
useCallback(
|
||||
(payload: unknown) => {
|
||||
const p = payload as IssueReactionRemovedPayload;
|
||||
if (p.issue_id !== issueId) return;
|
||||
if (p.actor_type === "member" && p.actor_id === userId) return;
|
||||
setReactions((prev) =>
|
||||
prev.filter(
|
||||
(r) => !(r.emoji === p.emoji && r.actor_type === p.actor_type && r.actor_id === p.actor_id),
|
||||
),
|
||||
);
|
||||
},
|
||||
[issueId, userId],
|
||||
),
|
||||
);
|
||||
|
||||
// --- Mutation ---
|
||||
|
||||
const toggleReaction = useCallback(
|
||||
async (emoji: string) => {
|
||||
if (!userId) return;
|
||||
const existing = reactions.find(
|
||||
(r) => r.emoji === emoji && r.actor_type === "member" && r.actor_id === userId,
|
||||
);
|
||||
if (existing) {
|
||||
setReactions((prev) => prev.filter((r) => r.id !== existing.id));
|
||||
try {
|
||||
await api.removeIssueReaction(issueId, emoji);
|
||||
} catch {
|
||||
setReactions((prev) => [...prev, existing]);
|
||||
toast.error("Failed to remove reaction");
|
||||
}
|
||||
} else {
|
||||
const temp: IssueReaction = {
|
||||
id: `temp-${Date.now()}`,
|
||||
issue_id: issueId,
|
||||
actor_type: "member",
|
||||
actor_id: userId,
|
||||
emoji,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
setReactions((prev) => [...prev, temp]);
|
||||
try {
|
||||
const reaction = await api.addIssueReaction(issueId, emoji);
|
||||
setReactions((prev) => prev.map((r) => (r.id === temp.id ? reaction : r)));
|
||||
} catch {
|
||||
setReactions((prev) => prev.filter((r) => r.id !== temp.id));
|
||||
toast.error("Failed to add reaction");
|
||||
}
|
||||
}
|
||||
},
|
||||
[issueId, userId, reactions],
|
||||
);
|
||||
|
||||
return { reactions, loading, toggleReaction };
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import type { IssueSubscriber } from "@/shared/types";
|
||||
import type {
|
||||
SubscriberAddedPayload,
|
||||
SubscriberRemovedPayload,
|
||||
} from "@/shared/types";
|
||||
import { api } from "@/shared/api";
|
||||
import { useWSEvent, useWSReconnect } from "@/features/realtime";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export function useIssueSubscribers(issueId: string, userId?: string) {
|
||||
const [subscribers, setSubscribers] = useState<IssueSubscriber[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Initial fetch
|
||||
useEffect(() => {
|
||||
setSubscribers([]);
|
||||
setLoading(true);
|
||||
api
|
||||
.listIssueSubscribers(issueId)
|
||||
.then((subs) => setSubscribers(subs))
|
||||
.catch(console.error)
|
||||
.finally(() => setLoading(false));
|
||||
}, [issueId]);
|
||||
|
||||
// Reconnect recovery
|
||||
useWSReconnect(
|
||||
useCallback(() => {
|
||||
api.listIssueSubscribers(issueId).then(setSubscribers).catch(console.error);
|
||||
}, [issueId]),
|
||||
);
|
||||
|
||||
// --- WS event handlers ---
|
||||
|
||||
useWSEvent(
|
||||
"subscriber:added",
|
||||
useCallback(
|
||||
(payload: unknown) => {
|
||||
const p = payload as SubscriberAddedPayload;
|
||||
if (p.issue_id !== issueId) return;
|
||||
setSubscribers((prev) => {
|
||||
if (prev.some((s) => s.user_id === p.user_id && s.user_type === p.user_type)) return prev;
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
issue_id: p.issue_id,
|
||||
user_type: p.user_type as "member" | "agent",
|
||||
user_id: p.user_id,
|
||||
reason: p.reason as IssueSubscriber["reason"],
|
||||
created_at: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
});
|
||||
},
|
||||
[issueId],
|
||||
),
|
||||
);
|
||||
|
||||
useWSEvent(
|
||||
"subscriber:removed",
|
||||
useCallback(
|
||||
(payload: unknown) => {
|
||||
const p = payload as SubscriberRemovedPayload;
|
||||
if (p.issue_id !== issueId) return;
|
||||
setSubscribers((prev) =>
|
||||
prev.filter((s) => !(s.user_id === p.user_id && s.user_type === p.user_type)),
|
||||
);
|
||||
},
|
||||
[issueId],
|
||||
),
|
||||
);
|
||||
|
||||
// --- Mutations ---
|
||||
|
||||
const isSubscribed = subscribers.some(
|
||||
(s) => s.user_type === "member" && s.user_id === userId,
|
||||
);
|
||||
|
||||
const toggleSubscriber = useCallback(
|
||||
async (subUserId: string, userType: "member" | "agent", currentlySubscribed: boolean) => {
|
||||
if (currentlySubscribed) {
|
||||
// Optimistic remove + rollback on error
|
||||
const removed = subscribers.find(
|
||||
(s) => s.user_id === subUserId && s.user_type === userType,
|
||||
);
|
||||
setSubscribers((prev) =>
|
||||
prev.filter((s) => !(s.user_id === subUserId && s.user_type === userType)),
|
||||
);
|
||||
try {
|
||||
await api.unsubscribeFromIssue(issueId, subUserId, userType);
|
||||
} catch {
|
||||
if (removed) setSubscribers((prev) => [...prev, removed]);
|
||||
toast.error("Failed to update subscriber");
|
||||
}
|
||||
} else {
|
||||
// Optimistic add
|
||||
const tempSub: IssueSubscriber = {
|
||||
issue_id: issueId,
|
||||
user_type: userType,
|
||||
user_id: subUserId,
|
||||
reason: "manual" as const,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
setSubscribers((prev) => {
|
||||
if (prev.some((s) => s.user_id === subUserId && s.user_type === userType)) return prev;
|
||||
return [...prev, tempSub];
|
||||
});
|
||||
try {
|
||||
await api.subscribeToIssue(issueId, subUserId, userType);
|
||||
} catch {
|
||||
setSubscribers((prev) =>
|
||||
prev.filter((s) => !(s.user_id === subUserId && s.user_type === userType && s.reason === "manual")),
|
||||
);
|
||||
toast.error("Failed to update subscriber");
|
||||
}
|
||||
}
|
||||
},
|
||||
[issueId, subscribers],
|
||||
);
|
||||
|
||||
const toggleSubscribe = useCallback(() => {
|
||||
if (userId) toggleSubscriber(userId, "member", isSubscribed);
|
||||
}, [userId, isSubscribed, toggleSubscriber]);
|
||||
|
||||
return { subscribers, loading, isSubscribed, toggleSubscribe, toggleSubscriber };
|
||||
}
|
||||
@@ -1,345 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import type { Comment, TimelineEntry } from "@/shared/types";
|
||||
import type {
|
||||
CommentCreatedPayload,
|
||||
CommentUpdatedPayload,
|
||||
CommentDeletedPayload,
|
||||
ActivityCreatedPayload,
|
||||
ReactionAddedPayload,
|
||||
ReactionRemovedPayload,
|
||||
} from "@/shared/types";
|
||||
import { api } from "@/shared/api";
|
||||
import { useWSEvent, useWSReconnect } from "@/features/realtime";
|
||||
import { toast } from "sonner";
|
||||
|
||||
function commentToTimelineEntry(c: Comment): TimelineEntry {
|
||||
return {
|
||||
type: "comment",
|
||||
id: c.id,
|
||||
actor_type: c.author_type,
|
||||
actor_id: c.author_id,
|
||||
content: c.content,
|
||||
parent_id: c.parent_id,
|
||||
created_at: c.created_at,
|
||||
updated_at: c.updated_at,
|
||||
comment_type: c.type,
|
||||
reactions: c.reactions ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
export function useIssueTimeline(issueId: string, userId?: string) {
|
||||
const [timeline, setTimeline] = useState<TimelineEntry[]>([]);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Initial fetch + reset on id change
|
||||
useEffect(() => {
|
||||
setTimeline([]);
|
||||
setLoading(true);
|
||||
api
|
||||
.listTimeline(issueId)
|
||||
.then((entries) => setTimeline(entries))
|
||||
.catch(console.error)
|
||||
.finally(() => setLoading(false));
|
||||
}, [issueId]);
|
||||
|
||||
// Reconnect recovery
|
||||
useWSReconnect(
|
||||
useCallback(() => {
|
||||
api.listTimeline(issueId).then(setTimeline).catch(console.error);
|
||||
}, [issueId]),
|
||||
);
|
||||
|
||||
// --- WS event handlers ---
|
||||
|
||||
useWSEvent(
|
||||
"comment:created",
|
||||
useCallback(
|
||||
(payload: unknown) => {
|
||||
const { comment } = payload as CommentCreatedPayload;
|
||||
if (comment.issue_id !== issueId) return;
|
||||
if (comment.author_type === "member" && comment.author_id === userId) return;
|
||||
setTimeline((prev) => {
|
||||
if (prev.some((e) => e.id === comment.id)) return prev;
|
||||
return [...prev, commentToTimelineEntry(comment)];
|
||||
});
|
||||
},
|
||||
[issueId, userId],
|
||||
),
|
||||
);
|
||||
|
||||
useWSEvent(
|
||||
"comment:updated",
|
||||
useCallback(
|
||||
(payload: unknown) => {
|
||||
const { comment } = payload as CommentUpdatedPayload;
|
||||
if (comment.issue_id === issueId) {
|
||||
setTimeline((prev) =>
|
||||
prev.map((e) => (e.id === comment.id ? commentToTimelineEntry(comment) : e)),
|
||||
);
|
||||
}
|
||||
},
|
||||
[issueId],
|
||||
),
|
||||
);
|
||||
|
||||
useWSEvent(
|
||||
"comment:deleted",
|
||||
useCallback(
|
||||
(payload: unknown) => {
|
||||
const { comment_id, issue_id } = payload as CommentDeletedPayload;
|
||||
if (issue_id === issueId) {
|
||||
setTimeline((prev) => {
|
||||
const idsToRemove = new Set<string>([comment_id]);
|
||||
let added = true;
|
||||
while (added) {
|
||||
added = false;
|
||||
for (const e of prev) {
|
||||
if (e.parent_id && idsToRemove.has(e.parent_id) && !idsToRemove.has(e.id)) {
|
||||
idsToRemove.add(e.id);
|
||||
added = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return prev.filter((e) => !idsToRemove.has(e.id));
|
||||
});
|
||||
}
|
||||
},
|
||||
[issueId],
|
||||
),
|
||||
);
|
||||
|
||||
useWSEvent(
|
||||
"activity:created",
|
||||
useCallback(
|
||||
(payload: unknown) => {
|
||||
const p = payload as ActivityCreatedPayload;
|
||||
if (p.issue_id !== issueId) return;
|
||||
const entry = p.entry;
|
||||
if (!entry || !entry.id) return;
|
||||
setTimeline((prev) => {
|
||||
if (prev.some((e) => e.id === entry.id)) return prev;
|
||||
return [...prev, entry];
|
||||
});
|
||||
},
|
||||
[issueId],
|
||||
),
|
||||
);
|
||||
|
||||
useWSEvent(
|
||||
"reaction:added",
|
||||
useCallback(
|
||||
(payload: unknown) => {
|
||||
const { reaction, issue_id } = payload as ReactionAddedPayload;
|
||||
if (issue_id !== issueId) return;
|
||||
if (reaction.actor_type === "member" && reaction.actor_id === userId) return;
|
||||
setTimeline((prev) =>
|
||||
prev.map((e) => {
|
||||
if (e.id !== reaction.comment_id) return e;
|
||||
const existing = e.reactions ?? [];
|
||||
if (existing.some((r) => r.id === reaction.id)) return e;
|
||||
return { ...e, reactions: [...existing, reaction] };
|
||||
}),
|
||||
);
|
||||
},
|
||||
[issueId, userId],
|
||||
),
|
||||
);
|
||||
|
||||
useWSEvent(
|
||||
"reaction:removed",
|
||||
useCallback(
|
||||
(payload: unknown) => {
|
||||
const p = payload as ReactionRemovedPayload;
|
||||
if (p.issue_id !== issueId) return;
|
||||
if (p.actor_type === "member" && p.actor_id === userId) return;
|
||||
setTimeline((prev) =>
|
||||
prev.map((e) => {
|
||||
if (e.id !== p.comment_id) return e;
|
||||
return {
|
||||
...e,
|
||||
reactions: (e.reactions ?? []).filter(
|
||||
(r) => !(r.emoji === p.emoji && r.actor_type === p.actor_type && r.actor_id === p.actor_id),
|
||||
),
|
||||
};
|
||||
}),
|
||||
);
|
||||
},
|
||||
[issueId, userId],
|
||||
),
|
||||
);
|
||||
|
||||
// --- Mutation functions ---
|
||||
|
||||
const submitComment = useCallback(
|
||||
async (content: string, attachmentIds?: string[]) => {
|
||||
if (!content.trim() || submitting || !userId) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const comment = await api.createComment(issueId, content, undefined, undefined, attachmentIds);
|
||||
setTimeline((prev) => {
|
||||
if (prev.some((e) => e.id === comment.id)) return prev;
|
||||
return [...prev, commentToTimelineEntry(comment)];
|
||||
});
|
||||
} catch {
|
||||
toast.error("Failed to send comment");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
},
|
||||
[issueId, userId],
|
||||
);
|
||||
|
||||
const submitReply = useCallback(
|
||||
async (parentId: string, content: string, attachmentIds?: string[]) => {
|
||||
if (!content.trim() || !userId) return;
|
||||
try {
|
||||
const comment = await api.createComment(issueId, content, "comment", parentId, attachmentIds);
|
||||
setTimeline((prev) => {
|
||||
if (prev.some((e) => e.id === comment.id)) return prev;
|
||||
return [...prev, commentToTimelineEntry(comment)];
|
||||
});
|
||||
} catch {
|
||||
toast.error("Failed to send reply");
|
||||
}
|
||||
},
|
||||
[issueId, userId],
|
||||
);
|
||||
|
||||
const editComment = useCallback(
|
||||
async (commentId: string, content: string) => {
|
||||
// Optimistic: update content immediately
|
||||
let prevContent: string | undefined;
|
||||
setTimeline((prev) =>
|
||||
prev.map((e) => {
|
||||
if (e.id !== commentId) return e;
|
||||
prevContent = e.content;
|
||||
return { ...e, content, updated_at: new Date().toISOString() };
|
||||
}),
|
||||
);
|
||||
try {
|
||||
const updated = await api.updateComment(commentId, content);
|
||||
setTimeline((prev) =>
|
||||
prev.map((e) => (e.id === updated.id ? commentToTimelineEntry(updated) : e)),
|
||||
);
|
||||
} catch {
|
||||
// Rollback
|
||||
if (prevContent !== undefined) {
|
||||
setTimeline((prev) =>
|
||||
prev.map((e) => (e.id === commentId ? { ...e, content: prevContent! } : e)),
|
||||
);
|
||||
}
|
||||
toast.error("Failed to update comment");
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const deleteComment = useCallback(
|
||||
async (commentId: string) => {
|
||||
// Capture entries for rollback
|
||||
let removedEntries: TimelineEntry[] = [];
|
||||
setTimeline((prev) => {
|
||||
const idsToRemove = new Set<string>([commentId]);
|
||||
let added = true;
|
||||
while (added) {
|
||||
added = false;
|
||||
for (const e of prev) {
|
||||
if (e.parent_id && idsToRemove.has(e.parent_id) && !idsToRemove.has(e.id)) {
|
||||
idsToRemove.add(e.id);
|
||||
added = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
removedEntries = prev.filter((e) => idsToRemove.has(e.id));
|
||||
return prev.filter((e) => !idsToRemove.has(e.id));
|
||||
});
|
||||
try {
|
||||
await api.deleteComment(commentId);
|
||||
} catch {
|
||||
// Rollback: re-add removed entries
|
||||
setTimeline((prev) => [...prev, ...removedEntries]);
|
||||
toast.error("Failed to delete comment");
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const toggleReaction = useCallback(
|
||||
async (commentId: string, emoji: string) => {
|
||||
if (!userId) return;
|
||||
const entry = timeline.find((e) => e.id === commentId);
|
||||
const existing = (entry?.reactions ?? []).find(
|
||||
(r) => r.emoji === emoji && r.actor_type === "member" && r.actor_id === userId,
|
||||
);
|
||||
if (existing) {
|
||||
setTimeline((prev) =>
|
||||
prev.map((e) => {
|
||||
if (e.id !== commentId) return e;
|
||||
return { ...e, reactions: (e.reactions ?? []).filter((r) => r.id !== existing.id) };
|
||||
}),
|
||||
);
|
||||
try {
|
||||
await api.removeReaction(commentId, emoji);
|
||||
} catch {
|
||||
setTimeline((prev) =>
|
||||
prev.map((e) => {
|
||||
if (e.id !== commentId) return e;
|
||||
return { ...e, reactions: [...(e.reactions ?? []), existing] };
|
||||
}),
|
||||
);
|
||||
toast.error("Failed to remove reaction");
|
||||
}
|
||||
} else {
|
||||
const tempReaction = {
|
||||
id: `temp-${Date.now()}`,
|
||||
comment_id: commentId,
|
||||
actor_type: "member",
|
||||
actor_id: userId,
|
||||
emoji,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
setTimeline((prev) =>
|
||||
prev.map((e) => {
|
||||
if (e.id !== commentId) return e;
|
||||
return { ...e, reactions: [...(e.reactions ?? []), tempReaction] };
|
||||
}),
|
||||
);
|
||||
try {
|
||||
const reaction = await api.addReaction(commentId, emoji);
|
||||
setTimeline((prev) =>
|
||||
prev.map((e) => {
|
||||
if (e.id !== commentId) return e;
|
||||
return {
|
||||
...e,
|
||||
reactions: (e.reactions ?? []).map((r) => (r.id === tempReaction.id ? reaction : r)),
|
||||
};
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
setTimeline((prev) =>
|
||||
prev.map((e) => {
|
||||
if (e.id !== commentId) return e;
|
||||
return { ...e, reactions: (e.reactions ?? []).filter((r) => r.id !== tempReaction.id) };
|
||||
}),
|
||||
);
|
||||
toast.error("Failed to add reaction");
|
||||
}
|
||||
}
|
||||
},
|
||||
[userId, timeline],
|
||||
);
|
||||
|
||||
return {
|
||||
timeline,
|
||||
loading,
|
||||
submitting,
|
||||
submitComment,
|
||||
submitReply,
|
||||
editComment,
|
||||
deleteComment,
|
||||
toggleReaction,
|
||||
};
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export { useIssueStore } from "./store";
|
||||
export { useIssueViewStore } from "./stores/view-store";
|
||||
export { StatusIcon, PriorityIcon, StatusPicker, PriorityPicker, AssigneePicker } from "./components";
|
||||
export * from "./config";
|
||||
@@ -1,55 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { create } from "zustand";
|
||||
import type { Issue } from "@/shared/types";
|
||||
import { api } from "@/shared/api";
|
||||
import { createLogger } from "@/shared/logger";
|
||||
|
||||
const logger = createLogger("issue-store");
|
||||
|
||||
interface IssueState {
|
||||
issues: Issue[];
|
||||
loading: boolean;
|
||||
activeIssueId: string | null;
|
||||
fetch: () => Promise<void>;
|
||||
setIssues: (issues: Issue[]) => void;
|
||||
addIssue: (issue: Issue) => void;
|
||||
updateIssue: (id: string, updates: Partial<Issue>) => void;
|
||||
removeIssue: (id: string) => void;
|
||||
setActiveIssue: (id: string | null) => void;
|
||||
}
|
||||
|
||||
export const useIssueStore = create<IssueState>((set, get) => ({
|
||||
issues: [],
|
||||
loading: true,
|
||||
activeIssueId: null,
|
||||
|
||||
fetch: async () => {
|
||||
logger.debug("fetch start");
|
||||
const isInitialLoad = get().issues.length === 0;
|
||||
if (isInitialLoad) set({ loading: true });
|
||||
try {
|
||||
const res = await api.listIssues({ limit: 200 });
|
||||
logger.info("fetched", res.issues.length, "issues");
|
||||
set({ issues: res.issues, loading: false });
|
||||
} catch (err) {
|
||||
logger.error("fetch failed", err);
|
||||
if (isInitialLoad) set({ loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
setIssues: (issues) => set({ issues }),
|
||||
addIssue: (issue) =>
|
||||
set((s) => ({
|
||||
issues: s.issues.some((i) => i.id === issue.id)
|
||||
? s.issues
|
||||
: [...s.issues, issue],
|
||||
})),
|
||||
updateIssue: (id, updates) =>
|
||||
set((s) => ({
|
||||
issues: s.issues.map((i) => (i.id === id ? { ...i, ...updates } : i)),
|
||||
})),
|
||||
removeIssue: (id) =>
|
||||
set((s) => ({ issues: s.issues.filter((i) => i.id !== id) })),
|
||||
setActiveIssue: (id) => set({ activeIssueId: id }),
|
||||
}));
|
||||
@@ -1,201 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
import type { IssueStatus, IssuePriority } from "@/shared/types";
|
||||
import { ALL_STATUSES } from "@/features/issues/config";
|
||||
|
||||
export type ViewMode = "board" | "list";
|
||||
export type SortField = "position" | "priority" | "due_date" | "created_at" | "title";
|
||||
export type SortDirection = "asc" | "desc";
|
||||
|
||||
export interface CardProperties {
|
||||
priority: boolean;
|
||||
description: boolean;
|
||||
assignee: boolean;
|
||||
dueDate: boolean;
|
||||
}
|
||||
|
||||
export interface ActorFilterValue {
|
||||
type: "member" | "agent";
|
||||
id: string;
|
||||
}
|
||||
|
||||
export const SORT_OPTIONS: { value: SortField; label: string }[] = [
|
||||
{ value: "position", label: "Manual" },
|
||||
{ value: "priority", label: "Priority" },
|
||||
{ value: "due_date", label: "Due date" },
|
||||
{ value: "created_at", label: "Created date" },
|
||||
{ value: "title", label: "Title" },
|
||||
];
|
||||
|
||||
export const CARD_PROPERTY_OPTIONS: { key: keyof CardProperties; label: string }[] = [
|
||||
{ key: "priority", label: "Priority" },
|
||||
{ key: "description", label: "Description" },
|
||||
{ key: "assignee", label: "Assignee" },
|
||||
{ key: "dueDate", label: "Due date" },
|
||||
];
|
||||
|
||||
interface IssueViewState {
|
||||
viewMode: ViewMode;
|
||||
statusFilters: IssueStatus[];
|
||||
priorityFilters: IssuePriority[];
|
||||
assigneeFilters: ActorFilterValue[];
|
||||
includeNoAssignee: boolean;
|
||||
creatorFilters: ActorFilterValue[];
|
||||
sortBy: SortField;
|
||||
sortDirection: SortDirection;
|
||||
cardProperties: CardProperties;
|
||||
listCollapsedStatuses: IssueStatus[];
|
||||
setViewMode: (mode: ViewMode) => void;
|
||||
toggleStatusFilter: (status: IssueStatus) => void;
|
||||
togglePriorityFilter: (priority: IssuePriority) => void;
|
||||
toggleAssigneeFilter: (value: ActorFilterValue) => void;
|
||||
toggleNoAssignee: () => void;
|
||||
toggleCreatorFilter: (value: ActorFilterValue) => void;
|
||||
hideStatus: (status: IssueStatus) => void;
|
||||
showStatus: (status: IssueStatus) => void;
|
||||
clearFilters: () => void;
|
||||
setSortBy: (field: SortField) => void;
|
||||
setSortDirection: (dir: SortDirection) => void;
|
||||
toggleCardProperty: (key: keyof CardProperties) => void;
|
||||
toggleListCollapsed: (status: IssueStatus) => void;
|
||||
}
|
||||
|
||||
export const useIssueViewStore = create<IssueViewState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
viewMode: "board",
|
||||
statusFilters: [],
|
||||
priorityFilters: [],
|
||||
assigneeFilters: [],
|
||||
includeNoAssignee: false,
|
||||
creatorFilters: [],
|
||||
sortBy: "position",
|
||||
sortDirection: "asc",
|
||||
cardProperties: {
|
||||
priority: true,
|
||||
description: true,
|
||||
assignee: true,
|
||||
dueDate: true,
|
||||
},
|
||||
listCollapsedStatuses: [],
|
||||
|
||||
setViewMode: (mode) => set({ viewMode: mode }),
|
||||
toggleStatusFilter: (status) =>
|
||||
set((state) => ({
|
||||
statusFilters: state.statusFilters.includes(status)
|
||||
? state.statusFilters.filter((s) => s !== status)
|
||||
: [...state.statusFilters, status],
|
||||
})),
|
||||
togglePriorityFilter: (priority) =>
|
||||
set((state) => ({
|
||||
priorityFilters: state.priorityFilters.includes(priority)
|
||||
? state.priorityFilters.filter((p) => p !== priority)
|
||||
: [...state.priorityFilters, priority],
|
||||
})),
|
||||
toggleAssigneeFilter: (value) =>
|
||||
set((state) => {
|
||||
const exists = state.assigneeFilters.some(
|
||||
(f) => f.type === value.type && f.id === value.id,
|
||||
);
|
||||
return {
|
||||
assigneeFilters: exists
|
||||
? state.assigneeFilters.filter(
|
||||
(f) => !(f.type === value.type && f.id === value.id),
|
||||
)
|
||||
: [...state.assigneeFilters, value],
|
||||
};
|
||||
}),
|
||||
toggleNoAssignee: () =>
|
||||
set((state) => ({ includeNoAssignee: !state.includeNoAssignee })),
|
||||
toggleCreatorFilter: (value) =>
|
||||
set((state) => {
|
||||
const exists = state.creatorFilters.some(
|
||||
(f) => f.type === value.type && f.id === value.id,
|
||||
);
|
||||
return {
|
||||
creatorFilters: exists
|
||||
? state.creatorFilters.filter(
|
||||
(f) => !(f.type === value.type && f.id === value.id),
|
||||
)
|
||||
: [...state.creatorFilters, value],
|
||||
};
|
||||
}),
|
||||
hideStatus: (status) =>
|
||||
set((state) => {
|
||||
// If no filter active, activate filter with all EXCEPT this one
|
||||
if (state.statusFilters.length === 0) {
|
||||
return { statusFilters: ALL_STATUSES.filter((s) => s !== status) };
|
||||
}
|
||||
return {
|
||||
statusFilters: state.statusFilters.filter((s) => s !== status),
|
||||
};
|
||||
}),
|
||||
showStatus: (status) =>
|
||||
set((state) => {
|
||||
if (state.statusFilters.length === 0) return state;
|
||||
if (state.statusFilters.includes(status)) return state;
|
||||
return { statusFilters: [...state.statusFilters, status] };
|
||||
}),
|
||||
clearFilters: () =>
|
||||
set({
|
||||
statusFilters: [],
|
||||
priorityFilters: [],
|
||||
assigneeFilters: [],
|
||||
includeNoAssignee: false,
|
||||
creatorFilters: [],
|
||||
}),
|
||||
setSortBy: (field) => set({ sortBy: field }),
|
||||
setSortDirection: (dir) => set({ sortDirection: dir }),
|
||||
toggleCardProperty: (key) =>
|
||||
set((state) => ({
|
||||
cardProperties: {
|
||||
...state.cardProperties,
|
||||
[key]: !state.cardProperties[key],
|
||||
},
|
||||
})),
|
||||
toggleListCollapsed: (status) =>
|
||||
set((state) => ({
|
||||
listCollapsedStatuses: state.listCollapsedStatuses.includes(status)
|
||||
? state.listCollapsedStatuses.filter((s) => s !== status)
|
||||
: [...state.listCollapsedStatuses, status],
|
||||
})),
|
||||
}),
|
||||
{
|
||||
name: "multica_issues_view",
|
||||
partialize: (state) => ({
|
||||
viewMode: state.viewMode,
|
||||
statusFilters: state.statusFilters,
|
||||
priorityFilters: state.priorityFilters,
|
||||
assigneeFilters: state.assigneeFilters,
|
||||
includeNoAssignee: state.includeNoAssignee,
|
||||
creatorFilters: state.creatorFilters,
|
||||
sortBy: state.sortBy,
|
||||
sortDirection: state.sortDirection,
|
||||
cardProperties: state.cardProperties,
|
||||
listCollapsedStatuses: state.listCollapsedStatuses,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Clear actor-based filters when workspace switches (IDs are workspace-scoped).
|
||||
// Deferred to avoid circular dependency: view-store → workspace → issues → view-store.
|
||||
let _filterSubInitialized = false;
|
||||
export function initFilterWorkspaceSync() {
|
||||
if (_filterSubInitialized) return;
|
||||
_filterSubInitialized = true;
|
||||
|
||||
// Dynamic import breaks the circular module evaluation chain.
|
||||
import("@/features/workspace").then(({ useWorkspaceStore }) => {
|
||||
let prevId: string | undefined;
|
||||
useWorkspaceStore.subscribe((state) => {
|
||||
const id = state.workspace?.id;
|
||||
if (prevId && id !== prevId) {
|
||||
useIssueViewStore.getState().clearFilters();
|
||||
}
|
||||
prevId = id;
|
||||
});
|
||||
});
|
||||
}
|
||||
62
apps/web/features/landing/components/about-page-client.tsx
Normal file
62
apps/web/features/landing/components/about-page-client.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { LandingHeader } from "./landing-header";
|
||||
import { LandingFooter } from "./landing-footer";
|
||||
import { GitHubMark, githubUrl } from "./shared";
|
||||
import { useLocale } from "../i18n";
|
||||
|
||||
export function AboutPageClient() {
|
||||
const { t } = useLocale();
|
||||
const n = t.about.nameLine;
|
||||
|
||||
return (
|
||||
<>
|
||||
<LandingHeader variant="light" />
|
||||
<main className="bg-white text-[#0a0d12]">
|
||||
<div className="mx-auto max-w-[720px] px-4 py-16 sm:px-6 sm:py-20 lg:py-24">
|
||||
<h1 className="font-[family-name:var(--font-serif)] text-[2.6rem] leading-[1.05] tracking-[-0.03em] sm:text-[3.4rem]">
|
||||
{t.about.title}
|
||||
</h1>
|
||||
<div className="mt-8 space-y-6 text-[15px] leading-[1.8] text-[#0a0d12]/70 sm:text-[16px]">
|
||||
<p>
|
||||
{n.prefix}
|
||||
<strong className="font-semibold text-[#0a0d12]">
|
||||
{n.mul}
|
||||
</strong>
|
||||
{n.tiplexed}
|
||||
<strong className="font-semibold text-[#0a0d12]">
|
||||
{n.i}
|
||||
</strong>
|
||||
{n.nformationAnd}
|
||||
<strong className="font-semibold text-[#0a0d12]">
|
||||
{n.c}
|
||||
</strong>
|
||||
{n.omputing}
|
||||
<strong className="font-semibold text-[#0a0d12]">
|
||||
{n.a}
|
||||
</strong>
|
||||
{n.gent}
|
||||
</p>
|
||||
{t.about.paragraphs.map((p, i) => (
|
||||
<p key={i}>{p}</p>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-12">
|
||||
<Link
|
||||
href={githubUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-2.5 rounded-[12px] bg-[#0a0d12] px-5 py-3 text-[14px] font-semibold text-white transition-colors hover:bg-[#0a0d12]/88"
|
||||
>
|
||||
<GitHubMark className="size-4" />
|
||||
{t.about.cta}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<LandingFooter />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import { LandingHeader } from "./landing-header";
|
||||
import { LandingFooter } from "./landing-footer";
|
||||
import { useLocale } from "../i18n";
|
||||
|
||||
export function ChangelogPageClient() {
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
<>
|
||||
<LandingHeader variant="light" />
|
||||
<main className="bg-white text-[#0a0d12]">
|
||||
<div className="mx-auto max-w-[720px] px-4 py-16 sm:px-6 sm:py-20 lg:py-24">
|
||||
<h1 className="font-[family-name:var(--font-serif)] text-[2.6rem] leading-[1.05] tracking-[-0.03em] sm:text-[3.4rem]">
|
||||
{t.changelog.title}
|
||||
</h1>
|
||||
<p className="mt-4 text-[15px] leading-7 text-[#0a0d12]/60 sm:text-[16px]">
|
||||
{t.changelog.subtitle}
|
||||
</p>
|
||||
|
||||
<div className="mt-16 space-y-16">
|
||||
{t.changelog.entries.map((release) => (
|
||||
<div key={release.version} className="relative">
|
||||
<div className="flex items-baseline gap-3">
|
||||
<span className="text-[13px] font-semibold tabular-nums">
|
||||
v{release.version}
|
||||
</span>
|
||||
<span className="text-[13px] text-[#0a0d12]/40">
|
||||
{release.date}
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="mt-2 text-[20px] font-semibold leading-snug sm:text-[22px]">
|
||||
{release.title}
|
||||
</h2>
|
||||
<ul className="mt-4 space-y-2">
|
||||
{release.changes.map((change) => (
|
||||
<li
|
||||
key={change}
|
||||
className="flex items-start gap-2.5 text-[14px] leading-[1.7] text-[#0a0d12]/60 sm:text-[15px]"
|
||||
>
|
||||
<span className="mt-2.5 h-1 w-1 shrink-0 rounded-full bg-[#0a0d12]/30" />
|
||||
{change}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<LandingFooter />
|
||||
</>
|
||||
);
|
||||
}
|
||||
70
apps/web/features/landing/components/faq-section.tsx
Normal file
70
apps/web/features/landing/components/faq-section.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { useLocale } from "../i18n";
|
||||
|
||||
export function FAQSection() {
|
||||
const { t } = useLocale();
|
||||
const [openIndex, setOpenIndex] = useState<number | null>(null);
|
||||
|
||||
return (
|
||||
<section id="faq" className="bg-[#f8f8f8] text-[#0a0d12]">
|
||||
<div className="mx-auto max-w-[860px] px-4 py-24 sm:px-6 sm:py-32 lg:py-40">
|
||||
<div className="text-center">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-[#0a0d12]/40">
|
||||
{t.faq.label}
|
||||
</p>
|
||||
<h2 className="mt-4 font-[family-name:var(--font-serif)] text-[2.6rem] leading-[1.05] tracking-[-0.03em] sm:text-[3.4rem] lg:text-[4.2rem]">
|
||||
{t.faq.headline}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="mt-14 divide-y divide-[#0a0d12]/10 sm:mt-16">
|
||||
{t.faq.items.map((faq, i) => (
|
||||
<div key={i}>
|
||||
<button
|
||||
onClick={() => setOpenIndex(openIndex === i ? null : i)}
|
||||
className="flex w-full items-start justify-between gap-4 py-6 text-left"
|
||||
>
|
||||
<span className="text-[16px] font-semibold leading-snug text-[#0a0d12] sm:text-[17px]">
|
||||
{faq.question}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full border border-[#0a0d12]/12 text-[#0a0d12]/40 transition-transform",
|
||||
openIndex === i && "rotate-45",
|
||||
)}
|
||||
>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 12 12"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
>
|
||||
<path d="M6 1v10M1 6h10" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
<div
|
||||
className={cn(
|
||||
"grid transition-[grid-template-rows] duration-200 ease-out",
|
||||
openIndex === i ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
|
||||
)}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<p className="pb-6 pr-12 text-[14px] leading-[1.7] text-[#0a0d12]/56 sm:text-[15px]">
|
||||
{faq.answer}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
1100
apps/web/features/landing/components/features-section.tsx
Normal file
1100
apps/web/features/landing/components/features-section.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useAuthStore } from "@/features/auth";
|
||||
import { useLocale } from "../i18n";
|
||||
import { GitHubMark, githubUrl, heroButtonClassName } from "./shared";
|
||||
|
||||
export function HowItWorksSection() {
|
||||
const { t } = useLocale();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
|
||||
return (
|
||||
<section id="how-it-works" className="bg-[#05070b] text-white">
|
||||
<div className="mx-auto max-w-[1320px] px-4 py-24 sm:px-6 sm:py-32 lg:px-8 lg:py-40">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-white/40">
|
||||
{t.howItWorks.label}
|
||||
</p>
|
||||
<h2 className="mt-4 font-[family-name:var(--font-serif)] text-[2.6rem] leading-[1.05] tracking-[-0.03em] sm:text-[3.4rem] lg:text-[4.2rem]">
|
||||
{t.howItWorks.headlineMain}
|
||||
<br />
|
||||
<span className="text-white/40">{t.howItWorks.headlineFaded}</span>
|
||||
</h2>
|
||||
|
||||
<div className="mt-20 grid gap-px overflow-hidden rounded-2xl border border-white/10 bg-white/10 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{t.howItWorks.steps.map((step, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex flex-col bg-[#05070b] p-8 lg:p-10"
|
||||
>
|
||||
<span className="text-[13px] font-semibold tabular-nums text-white/28">
|
||||
{String(i + 1).padStart(2, "0")}
|
||||
</span>
|
||||
<h3 className="mt-4 text-[17px] font-semibold leading-snug text-white sm:text-[18px]">
|
||||
{step.title}
|
||||
</h3>
|
||||
<p className="mt-3 text-[14px] leading-[1.7] text-white/50 sm:text-[15px]">
|
||||
{step.description}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-14 flex flex-wrap items-center gap-4">
|
||||
<Link href={user ? "/issues" : "/login"} className={heroButtonClassName("solid")}>
|
||||
{user ? t.header.dashboard : t.howItWorks.cta}
|
||||
</Link>
|
||||
<Link
|
||||
href={githubUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={heroButtonClassName("ghost")}
|
||||
>
|
||||
<GitHubMark className="size-4" />
|
||||
{t.howItWorks.ctaGithub}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
109
apps/web/features/landing/components/landing-footer.tsx
Normal file
109
apps/web/features/landing/components/landing-footer.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { MulticaIcon } from "@/components/multica-icon";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { useAuthStore } from "@/features/auth";
|
||||
import { useLocale, locales, localeLabels } from "../i18n";
|
||||
|
||||
export function LandingFooter() {
|
||||
const { t, locale, setLocale } = useLocale();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const groups = Object.values(t.footer.groups);
|
||||
|
||||
return (
|
||||
<footer className="bg-[#0a0d12] text-white">
|
||||
<div className="mx-auto max-w-[1320px] px-4 sm:px-6 lg:px-8">
|
||||
{/* Top: CTA + link columns */}
|
||||
<div className="flex flex-col gap-12 border-b border-white/10 py-16 sm:py-20 lg:flex-row lg:gap-20">
|
||||
{/* Left — newsletter / CTA */}
|
||||
<div className="lg:w-[340px] lg:shrink-0">
|
||||
<Link href="#product" className="flex items-center gap-3">
|
||||
<MulticaIcon className="size-5 text-white" noSpin />
|
||||
<span className="text-[18px] font-semibold tracking-[0.04em] lowercase">
|
||||
multica
|
||||
</span>
|
||||
</Link>
|
||||
<p className="mt-4 max-w-[300px] text-[14px] leading-[1.7] text-white/50 sm:text-[15px]">
|
||||
{t.footer.tagline}
|
||||
</p>
|
||||
<div className="mt-6">
|
||||
<Link
|
||||
href={user ? "/issues" : "/login"}
|
||||
className="inline-flex items-center justify-center rounded-[11px] bg-white px-5 py-2.5 text-[13px] font-semibold text-[#0a0d12] transition-colors hover:bg-white/88"
|
||||
>
|
||||
{user ? t.header.dashboard : t.footer.cta}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right — link columns */}
|
||||
<div className="grid flex-1 grid-cols-2 gap-8 sm:grid-cols-4">
|
||||
{groups.map((group) => (
|
||||
<div key={group.label}>
|
||||
<h4 className="text-[12px] font-semibold uppercase tracking-[0.1em] text-white/40">
|
||||
{group.label}
|
||||
</h4>
|
||||
<ul className="mt-4 flex flex-col gap-2.5">
|
||||
{group.links.map((link) => (
|
||||
<li key={link.label}>
|
||||
<Link
|
||||
href={link.href}
|
||||
{...(link.href.startsWith("http")
|
||||
? { target: "_blank", rel: "noreferrer" }
|
||||
: {})}
|
||||
className="text-[14px] text-white/50 transition-colors hover:text-white"
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom: copyright + language switcher */}
|
||||
<div className="flex items-center justify-between py-6">
|
||||
<p className="text-[13px] text-white/36">
|
||||
{t.footer.copyright.replace(
|
||||
"{year}",
|
||||
String(new Date().getFullYear()),
|
||||
)}
|
||||
</p>
|
||||
<div className="flex items-center">
|
||||
{locales.map((l, i) => (
|
||||
<button
|
||||
key={l}
|
||||
onClick={() => setLocale(l)}
|
||||
className={cn(
|
||||
"px-1.5 py-1 text-[12px] font-medium transition-colors",
|
||||
l === locale
|
||||
? "text-white/70"
|
||||
: "text-white/30 hover:text-white/50",
|
||||
i > 0 && "border-l border-white/16",
|
||||
)}
|
||||
>
|
||||
{localeLabels[l]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Giant logo */}
|
||||
<div className="relative overflow-hidden pb-4">
|
||||
<div className="flex items-end gap-6 sm:gap-8">
|
||||
<MulticaIcon
|
||||
className="size-[clamp(4rem,12vw,10rem)] shrink-0 text-white"
|
||||
noSpin
|
||||
/>
|
||||
<span className="font-[family-name:var(--font-serif)] text-[clamp(6rem,22vw,16rem)] font-normal leading-[0.82] tracking-[-0.04em] text-white lowercase">
|
||||
multica
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
66
apps/web/features/landing/components/landing-header.tsx
Normal file
66
apps/web/features/landing/components/landing-header.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { MulticaIcon } from "@/components/multica-icon";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { useAuthStore } from "@/features/auth";
|
||||
import { useLocale } from "../i18n";
|
||||
import { GitHubMark, githubUrl, headerButtonClassName } from "./shared";
|
||||
|
||||
export function LandingHeader({
|
||||
variant = "dark",
|
||||
}: {
|
||||
variant?: "dark" | "light";
|
||||
}) {
|
||||
const { t } = useLocale();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
"inset-x-0 top-0 z-30",
|
||||
variant === "dark"
|
||||
? "absolute bg-transparent"
|
||||
: "border-b border-[#0a0d12]/8 bg-white",
|
||||
)}
|
||||
>
|
||||
<div className="mx-auto flex h-[76px] max-w-[1320px] items-center justify-between px-4 sm:px-6 lg:px-8">
|
||||
<Link href="/" className="flex items-center gap-3">
|
||||
<MulticaIcon
|
||||
className={cn(
|
||||
"size-5",
|
||||
variant === "dark" ? "text-white" : "text-[#0a0d12]",
|
||||
)}
|
||||
noSpin
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"text-[18px] font-semibold tracking-[0.04em] lowercase sm:text-[20px]",
|
||||
variant === "dark" ? "text-white/92" : "text-[#0a0d12]",
|
||||
)}
|
||||
>
|
||||
multica
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-2.5 sm:gap-3">
|
||||
<Link
|
||||
href={githubUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={headerButtonClassName("ghost", variant)}
|
||||
>
|
||||
<GitHubMark className="size-3.5" />
|
||||
{t.header.github}
|
||||
</Link>
|
||||
<Link
|
||||
href={user ? "/issues" : "/login"}
|
||||
className={headerButtonClassName("solid", variant)}
|
||||
>
|
||||
{user ? t.header.dashboard : t.header.login}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
110
apps/web/features/landing/components/landing-hero.tsx
Normal file
110
apps/web/features/landing/components/landing-hero.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useAuthStore } from "@/features/auth";
|
||||
import { useLocale } from "../i18n";
|
||||
import {
|
||||
ClaudeCodeLogo,
|
||||
CodexLogo,
|
||||
GitHubMark,
|
||||
githubUrl,
|
||||
heroButtonClassName,
|
||||
} from "./shared";
|
||||
|
||||
export function LandingHero() {
|
||||
const { t } = useLocale();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
|
||||
return (
|
||||
<div className="relative min-h-full overflow-hidden bg-[#05070b] text-white">
|
||||
<LandingBackdrop />
|
||||
|
||||
<main className="relative z-10">
|
||||
<section
|
||||
id="product"
|
||||
className="mx-auto max-w-[1320px] px-4 pb-16 pt-28 sm:px-6 sm:pt-32 lg:px-8 lg:pb-24 lg:pt-36"
|
||||
>
|
||||
<div className="mx-auto max-w-[1120px] text-center">
|
||||
<h1 className="font-[family-name:var(--font-serif)] text-[3.65rem] leading-[0.93] tracking-[-0.038em] text-white drop-shadow-[0_10px_34px_rgba(0,0,0,0.32)] sm:text-[4.85rem] lg:text-[6.4rem]">
|
||||
{t.hero.headlineLine1}
|
||||
<br />
|
||||
{t.hero.headlineLine2}
|
||||
</h1>
|
||||
|
||||
<p className="mx-auto mt-7 max-w-[820px] text-[15px] leading-7 text-white/84 sm:text-[17px]">
|
||||
{t.hero.subheading}
|
||||
</p>
|
||||
|
||||
<div className="mt-8 flex flex-wrap items-center justify-center gap-3">
|
||||
<Link href={user ? "/issues" : "/login"} className={heroButtonClassName("solid")}>
|
||||
{user ? t.header.dashboard : t.hero.cta}
|
||||
</Link>
|
||||
<Link
|
||||
href={githubUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={heroButtonClassName("ghost")}
|
||||
>
|
||||
<GitHubMark className="size-4" />
|
||||
GitHub
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 flex items-center justify-center gap-8">
|
||||
<span className="text-[15px] text-white/50">
|
||||
{t.hero.worksWith}
|
||||
</span>
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="flex items-center gap-2.5 text-white/80">
|
||||
<ClaudeCodeLogo className="size-5" />
|
||||
<span className="text-[15px] font-medium">Claude Code</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2.5 text-white/80">
|
||||
<CodexLogo className="size-5" />
|
||||
<span className="text-[15px] font-medium">Codex</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="preview" className="mt-10 sm:mt-12">
|
||||
<ProductImage alt={t.hero.imageAlt} />
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LandingBackdrop() {
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-0">
|
||||
<Image
|
||||
src="/images/landing-bg.jpg"
|
||||
alt=""
|
||||
fill
|
||||
priority
|
||||
className="object-cover object-center"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProductImage({ alt }: { alt: string }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="relative overflow-hidden border border-white/14">
|
||||
<Image
|
||||
src="/images/landing-hero.png"
|
||||
alt={alt}
|
||||
width={3532}
|
||||
height={2382}
|
||||
className="block h-auto w-full"
|
||||
sizes="(max-width: 1320px) 100vw, 1320px"
|
||||
quality={85}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
26
apps/web/features/landing/components/multica-landing.tsx
Normal file
26
apps/web/features/landing/components/multica-landing.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { LandingHeader } from "./landing-header";
|
||||
import { LandingHero } from "./landing-hero";
|
||||
import { FeaturesSection } from "./features-section";
|
||||
import { HowItWorksSection } from "./how-it-works-section";
|
||||
import { OpenSourceSection } from "./open-source-section";
|
||||
import { FAQSection } from "./faq-section";
|
||||
import { LandingFooter } from "./landing-footer";
|
||||
|
||||
export function MulticaLanding() {
|
||||
return (
|
||||
<>
|
||||
<div className="relative">
|
||||
<LandingHeader />
|
||||
<LandingHero />
|
||||
</div>
|
||||
|
||||
<FeaturesSection />
|
||||
<HowItWorksSection />
|
||||
<OpenSourceSection />
|
||||
<FAQSection />
|
||||
<LandingFooter />
|
||||
</>
|
||||
);
|
||||
}
|
||||
59
apps/web/features/landing/components/open-source-section.tsx
Normal file
59
apps/web/features/landing/components/open-source-section.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useLocale } from "../i18n";
|
||||
import { GitHubMark, githubUrl } from "./shared";
|
||||
|
||||
export function OpenSourceSection() {
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
<section id="open-source" className="bg-white text-[#0a0d12]">
|
||||
<div className="mx-auto max-w-[1320px] px-4 py-24 sm:px-6 sm:py-32 lg:px-8 lg:py-40">
|
||||
<div className="flex flex-col gap-16 lg:flex-row lg:items-start lg:gap-24">
|
||||
{/* Left column — heading + CTA */}
|
||||
<div className="lg:w-[480px] lg:shrink-0">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-[#0a0d12]/40">
|
||||
{t.openSource.label}
|
||||
</p>
|
||||
<h2 className="mt-4 font-[family-name:var(--font-serif)] text-[2.6rem] leading-[1.05] tracking-[-0.03em] sm:text-[3.4rem] lg:text-[4.2rem]">
|
||||
{t.openSource.headlineLine1}
|
||||
<br />
|
||||
{t.openSource.headlineLine2}
|
||||
</h2>
|
||||
<p className="mt-6 max-w-[420px] text-[15px] leading-7 text-[#0a0d12]/60 sm:text-[16px]">
|
||||
{t.openSource.description}
|
||||
</p>
|
||||
<div className="mt-8 flex flex-wrap items-center gap-3">
|
||||
<Link
|
||||
href={githubUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center justify-center gap-2.5 rounded-[12px] bg-[#0a0d12] px-5 py-3 text-[14px] font-semibold text-white transition-colors hover:bg-[#0a0d12]/88"
|
||||
>
|
||||
<GitHubMark className="size-4" />
|
||||
{t.openSource.cta}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right column — highlight grid */}
|
||||
<div className="flex-1">
|
||||
<div className="grid gap-px overflow-hidden rounded-2xl border border-[#0a0d12]/8 bg-[#0a0d12]/8 sm:grid-cols-2">
|
||||
{t.openSource.highlights.map((item) => (
|
||||
<div key={item.title} className="bg-white p-8 lg:p-10">
|
||||
<h3 className="text-[17px] font-semibold leading-snug text-[#0a0d12] sm:text-[18px]">
|
||||
{item.title}
|
||||
</h3>
|
||||
<p className="mt-3 text-[14px] leading-[1.7] text-[#0a0d12]/56 sm:text-[15px]">
|
||||
{item.description}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
87
apps/web/features/landing/components/shared.tsx
Normal file
87
apps/web/features/landing/components/shared.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
|
||||
export const githubUrl = "https://github.com/multica-ai/multica";
|
||||
|
||||
export function GitHubMark({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
aria-hidden="true"
|
||||
className={className}
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M8 0C3.58 0 0 3.58 0 8a8 8 0 0 0 5.47 7.59c.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2 .37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82A7.65 7.65 0 0 1 8 4.84c.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ImageIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
className={className}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<rect x="3.5" y="5" width="17" height="14" rx="2.5" />
|
||||
<circle cx="9" cy="10" r="1.6" />
|
||||
<path d="m20.5 16-4.8-4.8a1 1 0 0 0-1.4 0L8 17.5" />
|
||||
<path d="m11.5 14.5 1.8-1.8a1 1 0 0 1 1.4 0l2.8 2.8" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ClaudeCodeLogo({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 248 248"
|
||||
aria-hidden="true"
|
||||
className={className}
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M52.4285 162.873L98.7844 136.879L99.5485 134.602L98.7844 133.334H96.4921L88.7237 132.862L62.2346 132.153L39.3113 131.207L17.0249 130.026L11.4214 128.844L6.2 121.873L6.7094 118.447L11.4214 115.257L18.171 115.847L33.0711 116.911L55.485 118.447L71.6586 119.392L95.728 121.873H99.5485L100.058 120.337L98.7844 119.392L97.7656 118.447L74.5877 102.732L49.4995 86.1905L36.3823 76.62L29.3779 71.7757L25.8121 67.2858L24.2839 57.3608L30.6515 50.2716L39.3113 50.8623L41.4763 51.4531L50.2636 58.1879L68.9842 72.7209L93.4357 90.6804L97.0015 93.6343L98.4374 92.6652L98.6571 91.9801L97.0015 89.2625L83.757 65.2772L69.621 40.8192L63.2534 30.6579L61.5978 24.632C60.9565 22.1032 60.579 20.0111 60.579 17.4246L67.8381 7.49965L71.9133 6.19995L81.7193 7.49965L85.7946 11.0443L91.9074 24.9865L101.714 46.8451L116.996 76.62L121.453 85.4816L123.873 93.6343L124.764 96.1155H126.292V94.6976L127.566 77.9197L129.858 57.3608L132.15 30.8942L132.915 23.4505L136.608 14.4708L143.994 9.62643L149.725 12.344L154.437 19.0788L153.8 23.4505L150.998 41.6463L145.522 70.1215L141.957 89.2625H143.994L146.414 86.7813L156.093 74.0206L172.266 53.698L179.398 45.6635L187.803 36.802L193.152 32.5484H203.34L210.726 43.6549L207.415 55.1159L196.972 68.3492L188.312 79.5739L175.896 96.2095L168.191 109.585L168.882 110.689L170.738 110.53L198.755 104.504L213.91 101.787L231.994 98.7149L240.144 102.496L241.036 106.395L237.852 114.311L218.495 119.037L195.826 123.645L162.07 131.592L161.696 131.893L162.137 132.547L177.36 133.925L183.855 134.279H199.774L229.447 136.524L237.215 141.605L241.8 147.867L241.036 152.711L229.065 158.737L213.019 154.956L175.45 145.977L162.587 142.787H160.805V143.85L171.502 154.366L191.242 172.089L215.82 195.011L217.094 200.682L213.91 205.172L210.599 204.699L188.949 188.394L180.544 181.069L161.696 165.118H160.422V166.772L164.752 173.152L187.803 207.771L188.949 218.405L187.294 221.832L181.308 223.959L174.813 222.777L161.187 203.754L147.305 182.486L136.098 163.345L134.745 164.2L128.075 235.42L125.019 239.082L117.887 241.8L111.902 237.31L108.718 229.984L111.902 215.452L115.722 196.547L118.779 181.541L121.58 162.873L123.291 156.636L123.14 156.219L121.773 156.449L107.699 175.752L86.304 204.699L69.3663 222.777L65.291 224.431L58.2867 220.768L58.9235 214.27L62.8713 208.48L86.304 178.705L100.44 160.155L109.551 149.507L109.462 147.967L108.959 147.924L46.6977 188.512L35.6182 189.93L30.7788 185.44L31.4156 178.115L33.7079 175.752L52.4285 162.873Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function CodexLogo({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
className={className}
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M22.282 9.821a5.985 5.985 0 0 0-.516-4.91 6.046 6.046 0 0 0-6.51-2.9A6.065 6.065 0 0 0 4.981 4.18a5.985 5.985 0 0 0-3.998 2.9 6.046 6.046 0 0 0 .743 7.097 5.98 5.98 0 0 0 .51 4.911 6.051 6.051 0 0 0 6.515 2.9A5.985 5.985 0 0 0 13.26 24a6.056 6.056 0 0 0 5.772-4.206 5.99 5.99 0 0 0 3.997-2.9 6.056 6.056 0 0 0-.747-7.073ZM13.26 22.43a4.476 4.476 0 0 1-2.876-1.04l.141-.081 4.779-2.758a.795.795 0 0 0 .392-.681v-6.737l2.02 1.168a.071.071 0 0 1 .038.052v5.583a4.504 4.504 0 0 1-4.494 4.494ZM3.6 18.304a4.47 4.47 0 0 1-.535-3.014l.142.085 4.783 2.759a.771.771 0 0 0 .78 0l5.843-3.369v2.332a.08.08 0 0 1-.033.062L9.74 19.95a4.5 4.5 0 0 1-6.14-1.646ZM2.34 7.896a4.485 4.485 0 0 1 2.366-1.973V11.6a.766.766 0 0 0 .388.676l5.815 3.355-2.02 1.168a.076.076 0 0 1-.071 0l-4.83-2.786A4.504 4.504 0 0 1 2.34 7.872v.024Zm16.597 3.855-5.833-3.387L15.119 7.2a.076.076 0 0 1 .071 0l4.83 2.791a4.494 4.494 0 0 1-.676 8.105v-5.678a.79.79 0 0 0-.407-.667Zm2.01-3.023-.141-.085-4.774-2.782a.776.776 0 0 0-.785 0L9.409 9.23V6.897a.066.066 0 0 1 .028-.061l4.83-2.787a4.5 4.5 0 0 1 6.68 4.66v.018ZM8.318 12.898l-2.024-1.168a.074.074 0 0 1-.038-.052V6.095a4.494 4.494 0 0 1 7.37-3.456l-.14.081-4.78 2.758a.795.795 0 0 0-.392.681l-.014 6.739h.018Zm1.1-2.36 2.602-1.5 2.595 1.5v2.999l-2.595 1.5-2.602-1.5v-3Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function headerButtonClassName(
|
||||
tone: "ghost" | "solid",
|
||||
variant: "dark" | "light" = "dark",
|
||||
) {
|
||||
return cn(
|
||||
"inline-flex items-center justify-center gap-2 rounded-[11px] px-4 py-2.5 text-[13px] font-semibold transition-colors",
|
||||
variant === "dark"
|
||||
? tone === "solid"
|
||||
? "bg-white text-[#0a0d12] hover:bg-white/92"
|
||||
: "border border-white/18 bg-black/16 text-white backdrop-blur-sm hover:bg-black/24"
|
||||
: tone === "solid"
|
||||
? "bg-[#0a0d12] text-white hover:bg-[#0a0d12]/88"
|
||||
: "border border-[#0a0d12]/12 bg-white text-[#0a0d12] hover:bg-[#0a0d12]/5",
|
||||
);
|
||||
}
|
||||
|
||||
export function heroButtonClassName(tone: "ghost" | "solid") {
|
||||
return cn(
|
||||
"inline-flex items-center justify-center gap-2 rounded-[12px] px-5 py-3 text-[14px] font-semibold transition-colors",
|
||||
tone === "solid"
|
||||
? "bg-white text-[#0a0d12] hover:bg-white/92"
|
||||
: "border border-white/18 bg-black/16 text-white backdrop-blur-sm hover:bg-black/24",
|
||||
);
|
||||
}
|
||||
48
apps/web/features/landing/i18n/context.tsx
Normal file
48
apps/web/features/landing/i18n/context.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useState, useCallback } from "react";
|
||||
import { en } from "./en";
|
||||
import { zh } from "./zh";
|
||||
import type { LandingDict, Locale } from "./types";
|
||||
|
||||
const dictionaries: Record<Locale, LandingDict> = { en, zh };
|
||||
|
||||
const COOKIE_NAME = "multica-locale";
|
||||
const COOKIE_MAX_AGE = 60 * 60 * 24 * 365; // 1 year
|
||||
|
||||
type LocaleContextValue = {
|
||||
locale: Locale;
|
||||
t: LandingDict;
|
||||
setLocale: (locale: Locale) => void;
|
||||
};
|
||||
|
||||
const LocaleContext = createContext<LocaleContextValue | null>(null);
|
||||
|
||||
export function LocaleProvider({
|
||||
children,
|
||||
initialLocale = "en",
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
initialLocale?: Locale;
|
||||
}) {
|
||||
const [locale, setLocaleState] = useState<Locale>(initialLocale);
|
||||
|
||||
const setLocale = useCallback((l: Locale) => {
|
||||
setLocaleState(l);
|
||||
document.cookie = `${COOKIE_NAME}=${l}; path=/; max-age=${COOKIE_MAX_AGE}; SameSite=Lax`;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<LocaleContext.Provider
|
||||
value={{ locale, t: dictionaries[locale], setLocale }}
|
||||
>
|
||||
{children}
|
||||
</LocaleContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useLocale() {
|
||||
const ctx = useContext(LocaleContext);
|
||||
if (!ctx) throw new Error("useLocale must be used within LocaleProvider");
|
||||
return ctx;
|
||||
}
|
||||
430
apps/web/features/landing/i18n/en.ts
Normal file
430
apps/web/features/landing/i18n/en.ts
Normal file
@@ -0,0 +1,430 @@
|
||||
import { githubUrl } from "../components/shared";
|
||||
import type { LandingDict } from "./types";
|
||||
|
||||
export const en: LandingDict = {
|
||||
header: {
|
||||
github: "GitHub",
|
||||
login: "Log in",
|
||||
dashboard: "Dashboard",
|
||||
},
|
||||
|
||||
hero: {
|
||||
headlineLine1: "Your next 10 hires",
|
||||
headlineLine2: "won\u2019t be human.",
|
||||
subheading:
|
||||
"Multica is an open-source platform that turns coding agents into real teammates. Assign tasks, track progress, compound skills \u2014 manage your human + agent workforce in one place.",
|
||||
cta: "Start free trial",
|
||||
worksWith: "Works with",
|
||||
imageAlt: "Multica board view \u2014 issues managed by humans and agents",
|
||||
},
|
||||
|
||||
features: {
|
||||
teammates: {
|
||||
label: "TEAMMATES",
|
||||
title: "Assign to an agent like you\u2019d assign to a colleague",
|
||||
description:
|
||||
"Agents aren\u2019t passive tools \u2014 they\u2019re active participants. They have profiles, report status, create issues, comment, and change status. Your activity feed shows humans and agents working side by side.",
|
||||
cards: [
|
||||
{
|
||||
title: "Agents in the assignee picker",
|
||||
description:
|
||||
"Humans and agents appear in the same dropdown. Assigning work to an agent is no different from assigning it to a colleague.",
|
||||
},
|
||||
{
|
||||
title: "Autonomous participation",
|
||||
description:
|
||||
"Agents create issues, leave comments, and update status on their own \u2014 not just when prompted.",
|
||||
},
|
||||
{
|
||||
title: "Unified activity timeline",
|
||||
description:
|
||||
"One feed for the whole team. Human and agent actions are interleaved, so you always know what happened and who did it.",
|
||||
},
|
||||
],
|
||||
},
|
||||
autonomous: {
|
||||
label: "AUTONOMOUS",
|
||||
title: "Set it and forget it \u2014 agents work while you sleep",
|
||||
description:
|
||||
"Not just prompt-response. Full task lifecycle management: enqueue, claim, start, complete or fail. Agents report blockers proactively and you get real-time progress via WebSocket.",
|
||||
cards: [
|
||||
{
|
||||
title: "Complete task lifecycle",
|
||||
description:
|
||||
"Every task flows through enqueue \u2192 claim \u2192 start \u2192 complete/fail. No silent failures \u2014 every transition is tracked and broadcast.",
|
||||
},
|
||||
{
|
||||
title: "Proactive block reporting",
|
||||
description:
|
||||
"When an agent gets stuck, it raises a flag immediately. No more checking back hours later to find nothing happened.",
|
||||
},
|
||||
{
|
||||
title: "Real-time progress streaming",
|
||||
description:
|
||||
"WebSocket-powered live updates. Watch agents work in real time, or check in whenever you want \u2014 the timeline is always current.",
|
||||
},
|
||||
],
|
||||
},
|
||||
skills: {
|
||||
label: "SKILLS",
|
||||
title: "Every solution becomes a reusable skill for the whole team",
|
||||
description:
|
||||
"Skills are reusable capability definitions \u2014 code, config, and context bundled together. Write a skill once, and every agent on your team can use it. Your skill library compounds over time.",
|
||||
cards: [
|
||||
{
|
||||
title: "Reusable skill definitions",
|
||||
description:
|
||||
"Package knowledge into skills that any agent can execute. Deploy to staging, write migrations, review PRs \u2014 all codified.",
|
||||
},
|
||||
{
|
||||
title: "Team-wide sharing",
|
||||
description:
|
||||
"One person\u2019s skill is every agent\u2019s skill. Build once, benefit everywhere across your team.",
|
||||
},
|
||||
{
|
||||
title: "Compound growth",
|
||||
description:
|
||||
"Day 1: you teach an agent to deploy. Day 30: every agent deploys, writes tests, and does code review. Your team\u2019s capabilities grow exponentially.",
|
||||
},
|
||||
],
|
||||
},
|
||||
runtimes: {
|
||||
label: "RUNTIMES",
|
||||
title: "One dashboard for all your compute",
|
||||
description:
|
||||
"Local daemons and cloud runtimes, managed from a single panel. Real-time monitoring of online/offline status, usage charts, and activity heatmaps. Auto-detects local CLIs \u2014 plug in and go.",
|
||||
cards: [
|
||||
{
|
||||
title: "Unified runtime panel",
|
||||
description:
|
||||
"Local daemons and cloud runtimes in one view. No context switching between different management interfaces.",
|
||||
},
|
||||
{
|
||||
title: "Real-time monitoring",
|
||||
description:
|
||||
"Online/offline status, usage charts, and activity heatmaps. Know exactly what your compute is doing at any moment.",
|
||||
},
|
||||
{
|
||||
title: "Auto-detection & plug-and-play",
|
||||
description:
|
||||
"Multica detects available CLIs like Claude Code and Codex automatically. Connect a machine, and it\u2019s ready to work.",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
howItWorks: {
|
||||
label: "Get started",
|
||||
headlineMain: "Hire your first AI employee",
|
||||
headlineFaded: "in the next hour.",
|
||||
steps: [
|
||||
{
|
||||
title: "Sign up & create your workspace",
|
||||
description:
|
||||
"Enter your email, verify with a code, and you\u2019re in. Your workspace is created automatically \u2014 no setup wizard, no configuration forms.",
|
||||
},
|
||||
{
|
||||
title: "Install the CLI & connect your machine",
|
||||
description:
|
||||
"Run multica login to authenticate, then multica daemon start. The daemon auto-detects Claude Code and Codex on your machine \u2014 plug in and go.",
|
||||
},
|
||||
{
|
||||
title: "Create your first agent",
|
||||
description:
|
||||
"Give it a name, write instructions, and attach skills. Agents automatically activate on assignment, on comment, or on mention.",
|
||||
},
|
||||
{
|
||||
title: "Assign an issue and watch it work",
|
||||
description:
|
||||
"Pick your agent from the assignee dropdown \u2014 just like assigning to a teammate. The task is queued, claimed, and executed automatically. Watch progress in real time.",
|
||||
},
|
||||
],
|
||||
cta: "Get started",
|
||||
ctaGithub: "View on GitHub",
|
||||
},
|
||||
|
||||
openSource: {
|
||||
label: "Open source",
|
||||
headlineLine1: "Open source",
|
||||
headlineLine2: "for all.",
|
||||
description:
|
||||
"Multica is fully open source. Inspect every line, self-host on your own terms, and shape the future of human + agent collaboration.",
|
||||
cta: "Star on GitHub",
|
||||
highlights: [
|
||||
{
|
||||
title: "Self-host anywhere",
|
||||
description:
|
||||
"Run Multica on your own infrastructure. Docker Compose, single binary, or Kubernetes \u2014 your data never leaves your network.",
|
||||
},
|
||||
{
|
||||
title: "No vendor lock-in",
|
||||
description:
|
||||
"Bring your own LLM provider, swap agent backends, extend the API. You own the stack, top to bottom.",
|
||||
},
|
||||
{
|
||||
title: "Transparent by default",
|
||||
description:
|
||||
"Every line of code is auditable. See exactly how your agents make decisions, how tasks are routed, and where your data flows.",
|
||||
},
|
||||
{
|
||||
title: "Community-driven",
|
||||
description:
|
||||
"Built with the community, not just for it. Contribute skills, integrations, and agent backends that benefit everyone.",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
faq: {
|
||||
label: "FAQ",
|
||||
headline: "Questions & answers.",
|
||||
items: [
|
||||
{
|
||||
question: "What coding agents does Multica support?",
|
||||
answer:
|
||||
"Multica currently supports Claude Code and OpenAI Codex out of the box. The daemon auto-detects whichever CLIs you have installed. More backends are on the roadmap \u2014 and since it\u2019s open source, you can add your own.",
|
||||
},
|
||||
{
|
||||
question: "Do I need to self-host, or is there a cloud version?",
|
||||
answer:
|
||||
"Both. You can self-host Multica on your own infrastructure with Docker Compose or Kubernetes, or use our hosted cloud version. Your data, your choice.",
|
||||
},
|
||||
{
|
||||
question:
|
||||
"How is this different from just using Claude Code or Codex directly?",
|
||||
answer:
|
||||
"Coding agents are great at executing. Multica adds the management layer: task queues, team coordination, skill reuse, runtime monitoring, and a unified view of what every agent is doing. Think of it as the project manager for your agents.",
|
||||
},
|
||||
{
|
||||
question: "Can agents work on long-running tasks autonomously?",
|
||||
answer:
|
||||
"Yes. Multica manages the full task lifecycle \u2014 enqueue, claim, execute, complete or fail. Agents report blockers proactively and stream progress in real time. You can check in whenever you want or let them run overnight.",
|
||||
},
|
||||
{
|
||||
question: "Is my code safe? Where does agent execution happen?",
|
||||
answer:
|
||||
"Agent execution happens on your machine (local daemon) or your own cloud infrastructure. Code never passes through Multica servers. The platform only coordinates task state and broadcasts events.",
|
||||
},
|
||||
{
|
||||
question: "How many agents can I run?",
|
||||
answer:
|
||||
"As many as your hardware supports. Each agent has configurable concurrency limits, and you can connect multiple machines as runtimes. There are no artificial caps in the open source version.",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
footer: {
|
||||
tagline:
|
||||
"Project management for human + agent teams. Open source, self-hostable, built for the future of work.",
|
||||
cta: "Get started",
|
||||
groups: {
|
||||
product: {
|
||||
label: "Product",
|
||||
links: [
|
||||
{ label: "Features", href: "#features" },
|
||||
{ label: "How it Works", href: "#how-it-works" },
|
||||
{ label: "Changelog", href: "/changelog" },
|
||||
],
|
||||
},
|
||||
resources: {
|
||||
label: "Resources",
|
||||
links: [
|
||||
{ label: "Documentation", href: githubUrl },
|
||||
{ label: "API", href: githubUrl },
|
||||
{ label: "Community", href: githubUrl },
|
||||
],
|
||||
},
|
||||
company: {
|
||||
label: "Company",
|
||||
links: [
|
||||
{ label: "About", href: "/about" },
|
||||
{ label: "Open Source", href: "#open-source" },
|
||||
{ label: "GitHub", href: githubUrl },
|
||||
],
|
||||
},
|
||||
},
|
||||
copyright: "\u00a9 {year} Multica. All rights reserved.",
|
||||
},
|
||||
|
||||
about: {
|
||||
title: "About Multica",
|
||||
nameLine: {
|
||||
prefix: "Multica \u2014 ",
|
||||
mul: "Mul",
|
||||
tiplexed: "tiplexed ",
|
||||
i: "I",
|
||||
nformationAnd: "nformation and ",
|
||||
c: "C",
|
||||
omputing: "omputing ",
|
||||
a: "A",
|
||||
gent: "gent.",
|
||||
},
|
||||
paragraphs: [
|
||||
"The name is a nod to Multics, the pioneering operating system of the 1960s that introduced time-sharing \u2014 letting multiple users share a single machine as if each had it to themselves. Unix was born as a deliberate simplification of Multics: one user, one task, one elegant philosophy.",
|
||||
"We think the same inflection is happening again. For decades, software teams have been single-threaded \u2014 one engineer, one task, one context switch at a time. AI agents change that equation. Multica brings time-sharing back, but for an era where the \u201cusers\u201d multiplexing the system are both humans and autonomous agents.",
|
||||
"In Multica, agents are first-class teammates. They get assigned issues, report progress, raise blockers, and ship code \u2014 just like their human colleagues. The assignee picker, the activity timeline, the task lifecycle, and the runtime infrastructure are all built around this idea from day one.",
|
||||
"Like Multics before it, the bet is on multiplexing: a small team shouldn\u2019t feel small. With the right system, two engineers and a fleet of agents can move like twenty.",
|
||||
"The platform is fully open source and self-hostable. Your data stays on your infrastructure. Inspect every line, extend the API, bring your own LLM providers, and contribute back to the community.",
|
||||
],
|
||||
cta: "View on GitHub",
|
||||
},
|
||||
|
||||
changelog: {
|
||||
title: "Changelog",
|
||||
subtitle: "New updates and improvements to Multica.",
|
||||
entries: [
|
||||
{
|
||||
version: "0.1.9",
|
||||
date: "2026-04-08",
|
||||
title: "Sub-Issues, TanStack Query & Usage Tracking",
|
||||
changes: [
|
||||
"Sub-issue support — create, view, and manage child issues within any issue",
|
||||
"Full migration to TanStack Query for server state (issues, inbox, workspace, runtimes)",
|
||||
"Per-task token usage tracking across all agent providers",
|
||||
"Multiple agents can now run concurrently on the same issue",
|
||||
"Board view: Done column shows total count with infinite scroll",
|
||||
"ReadonlyContent component for lightweight Markdown display in comments",
|
||||
"Optimistic UI updates for reactions and mutations with rollback",
|
||||
"WebSocket-driven cache invalidation replaces polling and refetch-on-focus",
|
||||
"Browser session persists during CLI login flow",
|
||||
"Daemon reuses existing worktrees by updating to latest remote",
|
||||
"Fixed slow tab switching caused by dynamic root layout",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.1.8",
|
||||
date: "2026-04-07",
|
||||
title: "OAuth, OpenClaw & Issue Loading",
|
||||
changes: [
|
||||
"Google OAuth login",
|
||||
"OpenClaw runtime support for running agents on OpenClaw infrastructure",
|
||||
"Redesigned agent live card — always sticky with manual expand/collapse toggle",
|
||||
"Load all open issues without pagination limit; closed issues paginate on scroll",
|
||||
"JWT and CloudFront cookie expiration extended from 72 hours to 30 days",
|
||||
"Remember last selected workspace after re-login",
|
||||
"Daemon ensures multica CLI is on PATH in agent task environment",
|
||||
"PR template and CLI install guide for agent-driven setup",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.1.7",
|
||||
date: "2026-04-05",
|
||||
title: "Comment Pagination & CLI Polish",
|
||||
changes: [
|
||||
"Comment list pagination in both the API and CLI",
|
||||
"Inbox archive now dismisses all items for the same issue at once",
|
||||
"CLI help output overhauled to match gh CLI style with examples",
|
||||
"Attachments use UUIDv7 as S3 key and auto-link on issue/comment creation",
|
||||
"@mention assigned agents on done or cancelled issues",
|
||||
"Reply @mention inheritance skips when the reply only mentions members",
|
||||
"Worktree setup preserves existing .env.worktree variables",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.1.6",
|
||||
date: "2026-04-03",
|
||||
title: "Editor Overhaul & Agent Lifecycle",
|
||||
changes: [
|
||||
"Unified Tiptap editor with a single Markdown pipeline for editing and display",
|
||||
"Reliable Markdown paste, inline code spacing, and link styling",
|
||||
"Agent archive and restore — soft delete replaces hard delete",
|
||||
"Archived agents hidden from default agent list",
|
||||
"Skeleton loading states, error toasts, and confirmation dialogs across the app",
|
||||
"OpenCode added as a supported agent provider",
|
||||
"Reply-triggered agent tasks now inherit thread-root @mentions",
|
||||
"Granular real-time event handling for issues and inbox — no more full refetches",
|
||||
"Unified image upload flow for paste and button in the editor",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.1.5",
|
||||
date: "2026-04-02",
|
||||
title: "Mentions & Permissions",
|
||||
changes: [
|
||||
"@mention issues in comments with server-side auto-expansion",
|
||||
"@all mention to notify every workspace member",
|
||||
"Inbox auto-scrolls to the referenced comment from a notification",
|
||||
"Repositories extracted into a standalone settings tab",
|
||||
"CLI update support from the web runtime page and direct download for non-Homebrew installs",
|
||||
"CLI commands for viewing issue execution runs and run messages",
|
||||
"Agent permission model — owners and admins manage agents, members manage skills on their own agents",
|
||||
"Per-issue serial execution to prevent concurrent task collisions",
|
||||
"File upload now supports all file types",
|
||||
"README redesign with quickstart guide",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.1.4",
|
||||
date: "2026-04-01",
|
||||
title: "My Issues & i18n",
|
||||
changes: [
|
||||
"My Issues page with kanban board, list view, and scope tabs",
|
||||
"Simplified Chinese localization for the landing page",
|
||||
"About and Changelog pages for the marketing site",
|
||||
"Agent avatar upload in settings",
|
||||
"Attachment support for CLI comments and issue/comment APIs",
|
||||
"Unified avatar rendering with ActorAvatar across all pickers",
|
||||
"SEO optimization and auth flow improvements for landing pages",
|
||||
"CLI defaults to production API URLs",
|
||||
"License changed to Apache 2.0",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.1.3",
|
||||
date: "2026-03-31",
|
||||
title: "Agent Intelligence",
|
||||
changes: [
|
||||
"Trigger agents via @mention in comments",
|
||||
"Stream live agent output to issue detail page",
|
||||
"Rich text editor \u2014 mentions, link paste, emoji reactions, collapsible threads",
|
||||
"File upload with S3 + CloudFront signed URLs and attachment tracking",
|
||||
"Agent-driven repo checkout with bare clone cache for task isolation",
|
||||
"Batch operations for issue list view",
|
||||
"Daemon authentication and security hardening",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.1.2",
|
||||
date: "2026-03-28",
|
||||
title: "Collaboration",
|
||||
changes: [
|
||||
"Email verification login and browser-based CLI auth",
|
||||
"Multi-workspace daemon with hot-reload",
|
||||
"Runtime dashboard with usage charts and activity heatmaps",
|
||||
"Subscriber-driven notification model replacing hardcoded triggers",
|
||||
"Unified activity timeline with threaded comment replies",
|
||||
"Kanban board redesign with drag sorting, filters, and display settings",
|
||||
"Human-readable issue identifiers (e.g. JIA-1)",
|
||||
"Skill import from ClawHub and Skills.sh",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.1.1",
|
||||
date: "2026-03-25",
|
||||
title: "Core Platform",
|
||||
changes: [
|
||||
"Multi-workspace switching and creation",
|
||||
"Agent management UI with skills",
|
||||
"Unified agent SDK supporting Claude Code and Codex backends",
|
||||
"Comment CRUD with real-time WebSocket updates",
|
||||
"Task service layer and daemon REST protocol",
|
||||
"Event bus with workspace-scoped WebSocket isolation",
|
||||
"Inbox notifications with unread badge and archive",
|
||||
"CLI with cobra subcommands for workspace and issue management",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.1.0",
|
||||
date: "2026-03-22",
|
||||
title: "Foundation",
|
||||
changes: [
|
||||
"Go backend with REST API, JWT auth, and real-time WebSocket",
|
||||
"Next.js frontend with Linear-inspired UI",
|
||||
"Issues with board and list views and drag-and-drop kanban",
|
||||
"Agents, Inbox, and Settings pages",
|
||||
"One-click setup, migration CLI, and seed tool",
|
||||
"Comprehensive test suite \u2014 Go unit/integration, Vitest, Playwright E2E",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user