mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-26 08:59:31 +02:00
Compare commits
151 Commits
feat/assig
...
agent/lamb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
112fe8c404 | ||
|
|
5b4ee7c5e1 | ||
|
|
b2b909a90f | ||
|
|
bf5395f9ee | ||
|
|
cd92aad9e1 | ||
|
|
017f69c123 | ||
|
|
1e9266f063 | ||
|
|
1d71df8622 | ||
|
|
576f20f2c7 | ||
|
|
e01fa6bd9e | ||
|
|
f1236b2358 | ||
|
|
0b60f78e8a | ||
|
|
5cd58183b2 | ||
|
|
83ff80c3ed | ||
|
|
8fb3bd322e | ||
|
|
06b1b99638 | ||
|
|
156982dc83 | ||
|
|
b239aa383e | ||
|
|
e2e5de1b26 | ||
|
|
0faf1363ee | ||
|
|
6c92108b09 | ||
|
|
a94c6481dd | ||
|
|
b4de4c9e9f | ||
|
|
7cac8014c9 | ||
|
|
be8b099c12 | ||
|
|
458b1e19e2 | ||
|
|
acad93163b | ||
|
|
526e336081 | ||
|
|
f4ce4c249d | ||
|
|
69f8380b9c | ||
|
|
2e5af72cdc | ||
|
|
0a0a86da2c | ||
|
|
96e87f7200 | ||
|
|
9e7d1eb764 | ||
|
|
007a1ca284 | ||
|
|
c5fce56887 | ||
|
|
04747b45a2 | ||
|
|
01232fc2f9 | ||
|
|
4372c5f4fa | ||
|
|
12bf7cac34 | ||
|
|
64ed0806ff | ||
|
|
b927684e3d | ||
|
|
e9bed4eb13 | ||
|
|
297b436e65 | ||
|
|
6097f7392e | ||
|
|
a749d310dd | ||
|
|
a473110078 | ||
|
|
2f1000d815 | ||
|
|
dbc6308c20 | ||
|
|
9e8c20df3d | ||
|
|
4d31b1ecee | ||
|
|
17ea7797df | ||
|
|
418fe4b18e | ||
|
|
e044c7e84b | ||
|
|
afab4dfdef | ||
|
|
99e973ba3e | ||
|
|
6ce0ba46a9 | ||
|
|
547da4c3e5 | ||
|
|
14beaa6ce2 | ||
|
|
a3eefcf2c4 | ||
|
|
20809052f5 | ||
|
|
265d1854c9 | ||
|
|
ff206baa6f | ||
|
|
1d64ea4ba6 | ||
|
|
c8275605c9 | ||
|
|
c54f9a0bc4 | ||
|
|
30725392ac | ||
|
|
3f13605b4c | ||
|
|
93fffad82a | ||
|
|
2fd344511e | ||
|
|
9581e4d870 | ||
|
|
cb4f5071ab | ||
|
|
c76ba2f58e | ||
|
|
bec84e2013 | ||
|
|
2ea778796a | ||
|
|
43466a6402 | ||
|
|
68b101fe01 | ||
|
|
e20c507dcc | ||
|
|
95bfd7dd96 | ||
|
|
3bf7f467a2 | ||
|
|
04238bea22 | ||
|
|
c13d365015 | ||
|
|
b271e8915e | ||
|
|
47eb6cb612 | ||
|
|
1ee4e0501a | ||
|
|
544b9bc971 | ||
|
|
0c19f0d16f | ||
|
|
d74d7f2b7b | ||
|
|
0c2102b951 | ||
|
|
0c28d3cd08 | ||
|
|
7312b5650c | ||
|
|
c7e0863419 | ||
|
|
d7c83bc285 | ||
|
|
4285549381 | ||
|
|
9ed80120e0 | ||
|
|
ec586ebc25 | ||
|
|
ea8cb18f9e | ||
|
|
d011039c58 | ||
|
|
471d4a6838 | ||
|
|
bd42552854 | ||
|
|
31eeb00b59 | ||
|
|
d32c419b6d | ||
|
|
f31a322978 | ||
|
|
5bae3368d7 | ||
|
|
f100b5b707 | ||
|
|
701399536f | ||
|
|
4ca607f888 | ||
|
|
29f7959db7 | ||
|
|
bd1a7eb680 | ||
|
|
3198972d15 | ||
|
|
d78be3b621 | ||
|
|
b0ee214154 | ||
|
|
02c9480f44 | ||
|
|
3e4ae17596 | ||
|
|
c95ee27991 | ||
|
|
f9f061de4c | ||
|
|
d11824807a | ||
|
|
7c063a0e6f | ||
|
|
e477d64548 | ||
|
|
2e33084097 | ||
|
|
b3f98ef95d | ||
|
|
ff241af8d7 | ||
|
|
d9be9465c3 | ||
|
|
5def4b62e0 | ||
|
|
c72df9b127 | ||
|
|
1de88a9412 | ||
|
|
3cd26c1d82 | ||
|
|
cc9a8ad6ec | ||
|
|
41d4ac3877 | ||
|
|
a76194744a | ||
|
|
34695ad78b | ||
|
|
7008d03b02 | ||
|
|
5956280d56 | ||
|
|
21fea91d23 | ||
|
|
82bbce98fd | ||
|
|
f4016fc721 | ||
|
|
6c5879215d | ||
|
|
2610d2dc3f | ||
|
|
faee939312 | ||
|
|
ea15f94341 | ||
|
|
762bc92b2d | ||
|
|
8db9099207 | ||
|
|
904192b45c | ||
|
|
0cceeee690 | ||
|
|
f1d81cdfaa | ||
|
|
2d4b959407 | ||
|
|
54d452e20d | ||
|
|
9b62485a86 | ||
|
|
cce210ed3a | ||
|
|
356ff002dd | ||
|
|
a35f71f65d |
12
.env.example
12
.env.example
@@ -22,6 +22,8 @@ MULTICA_CODEX_WORKDIR=
|
||||
MULTICA_CODEX_TIMEOUT=20m
|
||||
|
||||
# Email (Resend)
|
||||
# For local/dev use, leave RESEND_API_KEY empty — codes print to stdout, and master code 888888 works.
|
||||
# For production, set your Resend API key and change RESEND_FROM_EMAIL to a domain verified in your Resend account.
|
||||
RESEND_API_KEY=
|
||||
RESEND_FROM_EMAIL=noreply@multica.ai
|
||||
|
||||
@@ -40,6 +42,16 @@ CLOUDFRONT_PRIVATE_KEY=
|
||||
CLOUDFRONT_DOMAIN=
|
||||
COOKIE_DOMAIN=
|
||||
|
||||
# Local file storage (fallback when S3_BUCKET is not set)
|
||||
LOCAL_UPLOAD_DIR=./data/uploads
|
||||
LOCAL_UPLOAD_BASE_URL=http://localhost:8080
|
||||
|
||||
# Security
|
||||
# Comma-separated list of allowed origins for CORS and WebSocket connections.
|
||||
# Defaults to localhost dev origins when unset.
|
||||
# Example: ALLOWED_ORIGINS=https://app.multica.ai,https://staging.multica.ai
|
||||
ALLOWED_ORIGINS=
|
||||
|
||||
# Frontend
|
||||
FRONTEND_PORT=3000
|
||||
FRONTEND_ORIGIN=http://localhost:3000
|
||||
|
||||
6
.gitattributes
vendored
Normal file
6
.gitattributes
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
# Ensure shell scripts always use LF line endings (needed for Docker on Windows)
|
||||
*.sh text eol=lf
|
||||
docker/entrypoint.sh text eol=lf
|
||||
|
||||
# Default behavior
|
||||
* text=auto
|
||||
39
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
39
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
name: "Bug Report"
|
||||
description: Report a bug — something that's broken, crashes, or behaves incorrectly.
|
||||
title: "[Bug]: "
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: What happened?
|
||||
description: Describe the bug and what you expected instead. Screenshots, error messages, or screen recordings are welcome.
|
||||
placeholder: |
|
||||
When I do X, Y happens. I expected Z instead.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: reproduction
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
description: How can we trigger this bug?
|
||||
placeholder: |
|
||||
1. Go to '...'
|
||||
2. Click on '...'
|
||||
3. See error
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: screenshots
|
||||
attributes:
|
||||
label: Screenshots (optional)
|
||||
description: If applicable, add screenshots or screen recordings to help explain the problem.
|
||||
|
||||
- type: textarea
|
||||
id: context
|
||||
attributes:
|
||||
label: Additional context (optional)
|
||||
description: Environment info, logs, or anything else that might help.
|
||||
render: shell
|
||||
1
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
1
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1 @@
|
||||
blank_issues_enabled: true
|
||||
26
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
26
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
name: "Feature Request"
|
||||
description: Suggest a new feature or improvement.
|
||||
title: "[Feature]: "
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: What do you want and why?
|
||||
description: Describe the problem you're trying to solve or the improvement you'd like to see.
|
||||
placeholder: |
|
||||
I'm trying to do X but there's no way to...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: solution
|
||||
attributes:
|
||||
label: Proposed solution (optional)
|
||||
description: If you have an idea for how this should work, describe it here.
|
||||
|
||||
- type: textarea
|
||||
id: screenshots
|
||||
attributes:
|
||||
label: Screenshots / mockups (optional)
|
||||
description: If applicable, add screenshots, mockups, or sketches to illustrate your idea.
|
||||
52
.github/PULL_REQUEST_TEMPLATE.md
vendored
52
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -1,34 +1,56 @@
|
||||
## What
|
||||
## What does this PR do?
|
||||
|
||||
<!-- What does this PR do? Keep it to 1-3 sentences. -->
|
||||
<!-- Describe the change clearly. What problem does it solve? Why is this approach the right one? -->
|
||||
|
||||
## Why
|
||||
|
||||
<!-- Why is this change needed? Link the related issue. -->
|
||||
|
||||
Closes #<!-- issue number -->
|
||||
## Related Issue
|
||||
|
||||
<!-- Link the issue this PR addresses. If no issue exists, consider creating one first. -->
|
||||
|
||||
Closes #
|
||||
|
||||
## Type of Change
|
||||
|
||||
- [ ] Bug fix
|
||||
- [ ] New feature
|
||||
- [ ] Refactor / code improvement
|
||||
- [ ] Documentation
|
||||
- [ ] Bug fix (non-breaking change that fixes an issue)
|
||||
- [ ] New feature (non-breaking change that adds functionality)
|
||||
- [ ] Refactor / code improvement (no behavior change)
|
||||
- [ ] Documentation update
|
||||
- [ ] Tests (adding or improving test coverage)
|
||||
- [ ] CI / infrastructure
|
||||
- [ ] Other (describe below)
|
||||
|
||||
## Changes Made
|
||||
|
||||
<!-- List the specific changes. Include file paths for code changes. -->
|
||||
|
||||
-
|
||||
|
||||
## How to Test
|
||||
|
||||
<!-- How can a reviewer verify this works? Steps, commands, or screenshots. -->
|
||||
<!-- Steps to verify this change works. For bugs: reproduction steps + proof that the fix works. -->
|
||||
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] I searched for [existing PRs](https://github.com/multica-ai/multica/pulls) to make sure this isn't a duplicate
|
||||
- [ ] My commit messages follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix(scope):`, `feat(scope):`, etc.)
|
||||
- [ ] `make check` passes (typecheck, unit tests, Go tests, E2E)
|
||||
- [ ] Changes follow existing code patterns and conventions
|
||||
- [ ] No unrelated changes included
|
||||
|
||||
## AI Disclosure (optional)
|
||||
## AI Disclosure
|
||||
|
||||
<!-- 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. -->
|
||||
<!-- Most PRs involve AI coding tools — that's totally fine! We're curious about your process. -->
|
||||
|
||||
**AI tool used:** <!-- e.g. Claude Code, Cursor, GitHub Copilot, Multica Agent, N/A -->
|
||||
|
||||
**Prompt / approach:**
|
||||
<!-- How did you use AI to produce this code? Share your prompt, conversation link, or describe your approach. This helps the team learn from each other's AI workflows. -->
|
||||
|
||||
|
||||
## Screenshots (optional)
|
||||
|
||||
<!-- If applicable, add screenshots showing the change in action. -->
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -41,7 +41,12 @@ apps/web/test-results/
|
||||
# feature tracking
|
||||
_features/
|
||||
|
||||
# runtime
|
||||
*.pid
|
||||
|
||||
# platform specific
|
||||
*.dmg
|
||||
*.app
|
||||
server/server
|
||||
data/
|
||||
.kilo
|
||||
|
||||
@@ -11,19 +11,28 @@ builds:
|
||||
- -s -w
|
||||
- -X main.version={{.Version}}
|
||||
- -X main.commit={{.ShortCommit}}
|
||||
- -X main.date={{.Date}}
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
goos:
|
||||
- darwin
|
||||
- linux
|
||||
- windows
|
||||
goarch:
|
||||
- amd64
|
||||
- arm64
|
||||
ignore:
|
||||
- goos: windows
|
||||
goarch: arm64
|
||||
|
||||
archives:
|
||||
- id: default
|
||||
formats:
|
||||
- tar.gz
|
||||
format_overrides:
|
||||
- goos: windows
|
||||
formats:
|
||||
- zip
|
||||
name_template: "{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}"
|
||||
|
||||
checksum:
|
||||
|
||||
283
AGENTS.md
283
AGENTS.md
@@ -2,273 +2,46 @@
|
||||
|
||||
This file provides guidance to AI agents when working with code in this repository.
|
||||
|
||||
## Project Context
|
||||
> **Single source of truth:** This file is a concise pointer document.
|
||||
> All authoritative architecture, coding rules, commands, and conventions
|
||||
> live in **CLAUDE.md** at the project root. Read that file first.
|
||||
|
||||
Multica is an AI-native task management platform — like Linear, but with AI agents as first-class citizens.
|
||||
## Quick Reference
|
||||
|
||||
- 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
|
||||
### Architecture
|
||||
|
||||
## Architecture
|
||||
Go backend + monorepo frontend (pnpm workspaces + Turborepo) with shared packages.
|
||||
|
||||
**Go backend + standalone Next.js frontend.**
|
||||
- `server/` — Go backend (Chi router, sqlc, gorilla/websocket)
|
||||
- `apps/web/` — Next.js frontend (App Router)
|
||||
- `apps/desktop/` — Electron desktop app
|
||||
- `packages/core/` — Headless business logic (Zustand stores, React Query hooks, API client)
|
||||
- `packages/ui/` — Atomic UI components (shadcn/Base UI, zero business logic)
|
||||
- `packages/views/` — Shared business pages/components
|
||||
- `packages/tsconfig/` — Shared TypeScript config
|
||||
|
||||
- `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
|
||||
### State Management (critical)
|
||||
|
||||
### Web App Structure (`apps/web/`)
|
||||
- **React Query** owns all server state (issues, members, agents, inbox, workspace list)
|
||||
- **Zustand** owns all client state (current workspace selection, view filters, drafts, modals)
|
||||
- All Zustand stores live in `packages/core/` — never in `packages/views/` or app directories
|
||||
- WS events invalidate React Query — never write directly to stores
|
||||
|
||||
The frontend uses a **feature-based architecture** with four layers:
|
||||
### Package Boundaries (hard rules)
|
||||
|
||||
```
|
||||
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
|
||||
```
|
||||
- `packages/core/` — zero react-dom, zero localStorage, zero process.env
|
||||
- `packages/ui/` — zero `@multica/core` imports
|
||||
- `packages/views/` — zero `next/*`, zero `react-router-dom`, use `NavigationAdapter` for routing
|
||||
- `apps/web/platform/` — only place for Next.js APIs
|
||||
|
||||
**`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
|
||||
### 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
|
||||
make dev # Auto-setup + start everything
|
||||
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")
|
||||
pnpm test # TS unit tests (Vitest)
|
||||
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
|
||||
make check # Full verification pipeline
|
||||
```
|
||||
|
||||
### 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
|
||||
});
|
||||
```
|
||||
See CLAUDE.md for the complete command reference.
|
||||
|
||||
@@ -30,6 +30,16 @@ This auto-detects your installation method (Homebrew or manual) and upgrades acc
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# One-command setup: configure, authenticate, and start the daemon
|
||||
multica setup
|
||||
|
||||
# For self-hosted (local) deployments:
|
||||
multica setup self-host
|
||||
```
|
||||
|
||||
Or step by step:
|
||||
|
||||
```bash
|
||||
# 1. Authenticate (opens browser for login)
|
||||
multica login
|
||||
@@ -125,6 +135,9 @@ The daemon auto-detects these AI CLIs on your PATH:
|
||||
|-----|---------|-------------|
|
||||
| [Claude Code](https://docs.anthropic.com/en/docs/claude-code) | `claude` | Anthropic's coding agent |
|
||||
| [Codex](https://github.com/openai/codex) | `codex` | OpenAI's coding agent |
|
||||
| OpenCode | `opencode` | Open-source coding agent |
|
||||
| OpenClaw | `openclaw` | Open-source coding agent |
|
||||
| Hermes | `hermes` | Nous Research coding agent |
|
||||
|
||||
You need at least one installed. The daemon registers each detected CLI as an available runtime.
|
||||
|
||||
@@ -159,34 +172,50 @@ Agent-specific overrides:
|
||||
| `MULTICA_CLAUDE_MODEL` | Override the Claude model used |
|
||||
| `MULTICA_CODEX_PATH` | Custom path to the `codex` binary |
|
||||
| `MULTICA_CODEX_MODEL` | Override the Codex model used |
|
||||
| `MULTICA_OPENCODE_PATH` | Custom path to the `opencode` binary |
|
||||
| `MULTICA_OPENCODE_MODEL` | Override the OpenCode model used |
|
||||
| `MULTICA_OPENCLAW_PATH` | Custom path to the `openclaw` binary |
|
||||
| `MULTICA_OPENCLAW_MODEL` | Override the OpenClaw model used |
|
||||
| `MULTICA_HERMES_PATH` | Custom path to the `hermes` binary |
|
||||
| `MULTICA_HERMES_MODEL` | Override the Hermes model used |
|
||||
|
||||
### Self-Hosted Server
|
||||
|
||||
When connecting to a self-hosted Multica instance, point the CLI to your server before logging in:
|
||||
When connecting to a self-hosted Multica instance, the easiest approach is:
|
||||
|
||||
```bash
|
||||
export MULTICA_APP_URL=https://app.example.com
|
||||
export MULTICA_SERVER_URL=wss://api.example.com/ws
|
||||
# One command — configures for localhost, authenticates, starts daemon
|
||||
multica setup self-host
|
||||
|
||||
# Or for on-premise with custom domains:
|
||||
multica setup self-host --server-url https://api.example.com --app-url https://app.example.com
|
||||
```
|
||||
|
||||
Or configure manually:
|
||||
|
||||
```bash
|
||||
# Set URLs individually
|
||||
multica config set server_url http://localhost:8080
|
||||
multica config set app_url http://localhost:3000
|
||||
|
||||
# For production with TLS:
|
||||
# multica config set server_url https://api.example.com
|
||||
# multica config set app_url https://app.example.com
|
||||
|
||||
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
|
||||
# Set up a staging profile
|
||||
multica setup self-host --profile staging --server-url https://api-staging.example.com --app-url https://staging.example.com
|
||||
|
||||
# Start its daemon
|
||||
multica daemon start --profile staging
|
||||
|
||||
# Default profile runs separately
|
||||
multica daemon start
|
||||
@@ -306,6 +335,24 @@ multica issue run-messages <task-id> --since 42 --output json
|
||||
|
||||
The `runs` command shows all past and current executions for an issue, including running tasks. The `run-messages` command shows the detailed message log (tool calls, thinking, text, errors) for a single run. Use `--since` for efficient polling of in-progress runs.
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
# One-command setup for Multica Cloud: configure, authenticate, and start the daemon
|
||||
multica setup
|
||||
|
||||
# For local self-hosted deployments
|
||||
multica setup self-host
|
||||
|
||||
# Custom ports
|
||||
multica setup self-host --port 9090 --frontend-port 4000
|
||||
|
||||
# On-premise with custom domains
|
||||
multica setup self-host --server-url https://api.example.com --app-url https://app.example.com
|
||||
```
|
||||
|
||||
`multica setup` configures the CLI, opens your browser for authentication, and starts the daemon — all in one step. Use `multica setup self-host` to connect to a self-hosted server instead of Multica Cloud.
|
||||
|
||||
## Configuration
|
||||
|
||||
### View Config
|
||||
@@ -319,7 +366,7 @@ 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 server_url https://api.example.com
|
||||
multica config set app_url https://app.example.com
|
||||
multica config set workspace_id <workspace-id>
|
||||
```
|
||||
|
||||
@@ -27,7 +27,9 @@ multica version
|
||||
|
||||
## Step 2: Install the Multica CLI
|
||||
|
||||
### Option A: Homebrew (preferred)
|
||||
> **Windows users:** Skip to [Option C: Windows (PowerShell)](#option-c-windows-powershell) below.
|
||||
|
||||
### Option A: Homebrew (preferred — macOS/Linux)
|
||||
|
||||
Check if Homebrew is available:
|
||||
|
||||
@@ -49,7 +51,7 @@ multica version
|
||||
|
||||
If the version prints successfully, skip to **Step 3**.
|
||||
|
||||
### Option B: Download from GitHub Releases (no Homebrew)
|
||||
### Option B: Download from GitHub Releases (macOS/Linux, no Homebrew)
|
||||
|
||||
If Homebrew is not available, download the binary directly.
|
||||
|
||||
@@ -85,6 +87,27 @@ multica version
|
||||
- On Linux, you may need `chmod +x /usr/local/bin/multica`.
|
||||
- If `sudo` is not available, install to a user-writable directory: `mv /tmp/multica ~/.local/bin/multica` and ensure `~/.local/bin` is in `$PATH`.
|
||||
|
||||
### Option C: Windows (PowerShell)
|
||||
|
||||
Run in PowerShell (no admin required):
|
||||
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
This downloads the latest Windows binary from GitHub Releases, installs it to `%USERPROFILE%\.multica\bin\`, and adds it to your user PATH.
|
||||
|
||||
Verify:
|
||||
|
||||
```powershell
|
||||
multica version
|
||||
```
|
||||
|
||||
**If this fails:**
|
||||
- Restart your terminal so the updated PATH takes effect.
|
||||
- If you use Scoop, the installer will use it automatically: `scoop bucket add multica https://github.com/multica-ai/scoop-bucket.git && scoop install multica`
|
||||
- If your execution policy blocks the script: `Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned` then re-run.
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Log in
|
||||
@@ -136,12 +159,12 @@ Wait 3 seconds, then verify:
|
||||
multica daemon status
|
||||
```
|
||||
|
||||
Expected output should show `running` status with detected agents (e.g. `claude`, `codex`).
|
||||
Expected output should show `running` status with detected agents (e.g. `claude`, `codex`, `opencode`, `openclaw`, `hermes`).
|
||||
|
||||
**If daemon fails to start:**
|
||||
- Check logs: `multica daemon logs`
|
||||
- If a port conflict occurs, the daemon may already be running under a different profile.
|
||||
- If no agents are detected, ensure at least one AI CLI (`claude` or `codex`) is installed and on the `$PATH`.
|
||||
- If no agents are detected, ensure at least one AI CLI (`claude`, `codex`, `opencode`, `openclaw`, or `hermes`) is installed and on the `$PATH`.
|
||||
|
||||
---
|
||||
|
||||
@@ -155,12 +178,12 @@ multica daemon status
|
||||
|
||||
Confirm:
|
||||
1. Status is `running`
|
||||
2. At least one agent is listed (e.g. `claude`, `codex`)
|
||||
2. At least one agent is listed (e.g. `claude`, `codex`, `opencode`, `openclaw`, or `hermes`)
|
||||
3. At least one workspace is being watched
|
||||
|
||||
If the agents list is empty, tell the user:
|
||||
|
||||
> "The Multica daemon is running but no AI agent CLIs were detected. Please install at least one: [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`."
|
||||
> "The Multica daemon is running but no AI agent CLIs were detected. Please install at least one supported CLI (`claude`, `codex`, `opencode`, `openclaw`, or `hermes`), then restart the daemon with `multica daemon stop && multica daemon start`."
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ COPY --from=builder /src/server/bin/multica .
|
||||
COPY --from=builder /src/server/bin/migrate .
|
||||
COPY server/migrations/ ./migrations/
|
||||
COPY docker/entrypoint.sh .
|
||||
RUN chmod +x entrypoint.sh
|
||||
RUN sed -i 's/\r$//' entrypoint.sh && chmod +x entrypoint.sh
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ RUN corepack enable && corepack prepare pnpm@10.28.2 --activate
|
||||
WORKDIR /app
|
||||
|
||||
# Copy workspace config and all package.json files for dependency resolution
|
||||
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json turbo.json ./
|
||||
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json turbo.json .npmrc ./
|
||||
COPY apps/web/package.json apps/web/
|
||||
COPY packages/core/package.json packages/core/
|
||||
COPY packages/ui/package.json packages/ui/
|
||||
@@ -23,22 +23,22 @@ RUN corepack enable && corepack prepare pnpm@10.28.2 --activate
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY --from=deps /app/apps/web/node_modules ./apps/web/node_modules
|
||||
COPY --from=deps /app/packages/core/node_modules ./packages/core/node_modules
|
||||
COPY --from=deps /app/packages/ui/node_modules ./packages/ui/node_modules
|
||||
COPY --from=deps /app/packages/views/node_modules ./packages/views/node_modules
|
||||
COPY --from=deps /app/packages/tsconfig/node_modules ./packages/tsconfig/node_modules
|
||||
COPY --from=deps /app/packages/eslint-config/node_modules ./packages/eslint-config/node_modules
|
||||
# Copy installed dependencies (preserves pnpm symlink structure)
|
||||
COPY --from=deps /app ./
|
||||
|
||||
# Copy source
|
||||
COPY package.json turbo.json pnpm-workspace.yaml ./
|
||||
COPY apps/web/ apps/web/
|
||||
COPY packages/ packages/
|
||||
|
||||
# Re-link after source overlay (fixes any symlinks overwritten by COPY)
|
||||
RUN pnpm install --frozen-lockfile --offline
|
||||
|
||||
# Set build-time env: tells Next.js rewrites to proxy API calls to the backend service
|
||||
ARG REMOTE_API_URL=http://backend:8080
|
||||
ARG NEXT_PUBLIC_GOOGLE_CLIENT_ID
|
||||
ENV REMOTE_API_URL=$REMOTE_API_URL
|
||||
ENV NEXT_PUBLIC_GOOGLE_CLIENT_ID=$NEXT_PUBLIC_GOOGLE_CLIENT_ID
|
||||
ENV STANDALONE=true
|
||||
|
||||
# Build the web app (standalone output for minimal runtime)
|
||||
@@ -55,11 +55,11 @@ RUN addgroup --system --gid 1001 nodejs && \
|
||||
adduser --system --uid 1001 nextjs
|
||||
|
||||
# Copy standalone output (includes traced node_modules)
|
||||
COPY --from=builder /app/apps/web/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/standalone ./
|
||||
# Copy static files (not included in standalone)
|
||||
COPY --from=builder /app/apps/web/.next/static ./apps/web/.next/static
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/static ./apps/web/.next/static
|
||||
# Copy public assets
|
||||
COPY --from=builder /app/apps/web/public ./apps/web/public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/public ./apps/web/public
|
||||
|
||||
USER nextjs
|
||||
|
||||
|
||||
52
Makefile
52
Makefile
@@ -1,4 +1,4 @@
|
||||
.PHONY: dev server daemon cli multica build test migrate-up migrate-down sqlc seed clean setup start stop check worktree-env setup-main start-main stop-main check-main setup-worktree start-worktree stop-worktree check-worktree db-up db-down
|
||||
.PHONY: dev server daemon cli multica build test migrate-up migrate-down sqlc seed clean setup start stop check worktree-env setup-main start-main stop-main check-main setup-worktree start-worktree stop-worktree check-worktree db-up db-down selfhost selfhost-stop
|
||||
|
||||
MAIN_ENV_FILE ?= .env
|
||||
WORKTREE_ENV_FILE ?= .env.worktree
|
||||
@@ -36,6 +36,53 @@ define REQUIRE_ENV
|
||||
fi
|
||||
endef
|
||||
|
||||
# ---------- Self-hosting (Docker Compose) ----------
|
||||
|
||||
# One-command self-host: create env, start Docker Compose, wait for health
|
||||
selfhost:
|
||||
@if [ ! -f .env ]; then \
|
||||
echo "==> Creating .env from .env.example..."; \
|
||||
cp .env.example .env; \
|
||||
JWT=$$(openssl rand -hex 32); \
|
||||
if [ "$$(uname)" = "Darwin" ]; then \
|
||||
sed -i '' "s/^JWT_SECRET=.*/JWT_SECRET=$$JWT/" .env; \
|
||||
else \
|
||||
sed -i "s/^JWT_SECRET=.*/JWT_SECRET=$$JWT/" .env; \
|
||||
fi; \
|
||||
echo "==> Generated random JWT_SECRET"; \
|
||||
fi
|
||||
@echo "==> Starting Multica via Docker Compose..."
|
||||
docker compose -f docker-compose.selfhost.yml up -d --build
|
||||
@echo "==> Waiting for backend to be ready..."
|
||||
@for i in $$(seq 1 30); do \
|
||||
if curl -sf http://localhost:$${PORT:-8080}/health > /dev/null 2>&1; then \
|
||||
break; \
|
||||
fi; \
|
||||
sleep 2; \
|
||||
done
|
||||
@if curl -sf http://localhost:$${PORT:-8080}/health > /dev/null 2>&1; then \
|
||||
echo ""; \
|
||||
echo "✓ Multica is running!"; \
|
||||
echo " Frontend: http://localhost:$${FRONTEND_PORT:-3000}"; \
|
||||
echo " Backend: http://localhost:$${PORT:-8080}"; \
|
||||
echo ""; \
|
||||
echo "Log in with any email + verification code: 888888"; \
|
||||
echo ""; \
|
||||
echo "Next — install the CLI and connect your machine:"; \
|
||||
echo " brew install multica-ai/tap/multica"; \
|
||||
echo " multica setup self-host"; \
|
||||
else \
|
||||
echo ""; \
|
||||
echo "Services are still starting. Check logs:"; \
|
||||
echo " docker compose -f docker-compose.selfhost.yml logs"; \
|
||||
fi
|
||||
|
||||
# Stop all Docker Compose self-host services
|
||||
selfhost-stop:
|
||||
@echo "==> Stopping Multica services..."
|
||||
docker compose -f docker-compose.selfhost.yml down
|
||||
@echo "✓ All services stopped."
|
||||
|
||||
# ---------- One-click commands ----------
|
||||
|
||||
# First-time setup: install deps, start DB, run migrations
|
||||
@@ -143,10 +190,11 @@ multica:
|
||||
|
||||
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
|
||||
COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
|
||||
DATE ?= $(shell date -u '+%Y-%m-%dT%H:%M:%SZ')
|
||||
|
||||
build:
|
||||
cd server && go build -o bin/server ./cmd/server
|
||||
cd server && go build -ldflags "-X main.version=$(VERSION) -X main.commit=$(COMMIT)" -o bin/multica ./cmd/multica
|
||||
cd server && go build -ldflags "-X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.date=$(DATE)" -o bin/multica ./cmd/multica
|
||||
cd server && go build -o bin/migrate ./cmd/migrate
|
||||
|
||||
test:
|
||||
|
||||
114
README.md
114
README.md
@@ -18,10 +18,9 @@ The open-source managed agents platform.<br/>
|
||||
Turn coding agents into real teammates — assign tasks, track progress, compound skills.
|
||||
|
||||
[](https://github.com/multica-ai/multica/actions/workflows/ci.yml)
|
||||
[](https://opensource.org/licenses/Apache-2.0)
|
||||
[](https://github.com/multica-ai/multica/stargazers)
|
||||
|
||||
[Website](https://multica.ai) · [Cloud](https://multica.ai/app) · [X](https://x.com/multica_hq) · [Self-Hosting](SELF_HOSTING.md) · [Contributing](CONTRIBUTING.md)
|
||||
[Website](https://multica.ai) · [Cloud](https://multica.ai/app) · [X](https://x.com/MulticaAI) · [Self-Hosting](SELF_HOSTING.md) · [Contributing](CONTRIBUTING.md)
|
||||
|
||||
**English | [简体中文](README.zh-CN.md)**
|
||||
|
||||
@@ -47,66 +46,48 @@ Multica manages the full agent lifecycle: from task assignment to execution moni
|
||||
- **Unified Runtimes** — one dashboard for all your compute. Local daemons and cloud runtimes, auto-detection of available CLIs, real-time monitoring.
|
||||
- **Multi-Workspace** — organize work across teams with workspace-level isolation. Each workspace has its own agents, issues, and settings.
|
||||
|
||||
---
|
||||
|
||||
## Quick Install
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash
|
||||
```
|
||||
|
||||
Installs the Multica CLI on macOS and Linux. Works with Homebrew or downloads the binary directly.
|
||||
|
||||
**Windows (PowerShell):**
|
||||
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
Then configure, authenticate, and start the daemon in one command:
|
||||
|
||||
```bash
|
||||
multica setup # Connect to Multica Cloud, log in, start daemon
|
||||
```
|
||||
|
||||
> **Self-hosting?** Add `--with-server` to deploy a full Multica server on your machine:
|
||||
>
|
||||
> ```bash
|
||||
> curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash -s -- --with-server
|
||||
> multica setup self-host
|
||||
> ```
|
||||
>
|
||||
> Requires Docker. See the [Self-Hosting Guide](SELF_HOSTING.md) for details.
|
||||
|
||||
---
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Multica Cloud
|
||||
|
||||
The fastest way to get started — no setup required: **[multica.ai](https://multica.ai)**
|
||||
|
||||
### Self-Host with Docker
|
||||
|
||||
**Prerequisites:** Docker and Docker Compose.
|
||||
### 1. Set up and start the daemon
|
||||
|
||||
```bash
|
||||
git clone https://github.com/multica-ai/multica.git
|
||||
cd multica
|
||||
cp .env.example .env
|
||||
# Edit .env — change JWT_SECRET at minimum
|
||||
docker compose -f docker-compose.selfhost.yml up -d
|
||||
multica setup # Configure, authenticate, and start the daemon
|
||||
```
|
||||
|
||||
This builds and starts PostgreSQL, the backend (with auto-migration), and the frontend. Open http://localhost:3000 when ready.
|
||||
|
||||
See the [Self-Hosting Guide](SELF_HOSTING.md) for full configuration, reverse proxy setup, and CLI/daemon instructions.
|
||||
|
||||
## CLI
|
||||
|
||||
The `multica` CLI connects your local machine to Multica — authenticate, manage workspaces, and run the agent daemon.
|
||||
|
||||
**Option A — paste this to your coding agent (Claude Code, Codex, OpenClaw, OpenCode, etc.):**
|
||||
|
||||
```
|
||||
Fetch https://github.com/multica-ai/multica/blob/main/CLI_INSTALL.md and follow the instructions to install Multica CLI, log in, and start the daemon on this machine.
|
||||
```
|
||||
|
||||
**Option B — install manually:**
|
||||
|
||||
```bash
|
||||
# Install
|
||||
brew tap multica-ai/tap
|
||||
brew install multica
|
||||
|
||||
# Authenticate and start
|
||||
multica login
|
||||
multica daemon start
|
||||
```
|
||||
|
||||
The daemon auto-detects available agent CLIs (`claude`, `codex`, `openclaw`, `opencode`) on your PATH. When an agent is assigned a task, the daemon creates an isolated environment, runs the agent, and reports results back.
|
||||
|
||||
See the [CLI and Daemon Guide](CLI_AND_DAEMON.md) for the full command reference, daemon configuration, and advanced usage.
|
||||
|
||||
## Quickstart
|
||||
|
||||
Once you have the CLI installed (or signed up for [Multica Cloud](https://multica.ai)), follow these steps to assign your first task to an agent:
|
||||
|
||||
### 1. Log in and start the daemon
|
||||
|
||||
```bash
|
||||
multica login # Authenticate with your Multica account
|
||||
multica daemon start # Start the local agent runtime
|
||||
```
|
||||
|
||||
The daemon runs in the background and keeps your machine connected to Multica. It auto-detects agent CLIs (`claude`, `codex`, `openclaw`, `opencode`) available on your PATH.
|
||||
The daemon runs in the background and auto-detects agent CLIs (`claude`, `codex`, `openclaw`, `opencode`) on your PATH.
|
||||
|
||||
### 2. Verify your runtime
|
||||
|
||||
@@ -122,7 +103,26 @@ Go to **Settings → Agents** and click **New Agent**. Pick the runtime you just
|
||||
|
||||
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. 🎉
|
||||
---
|
||||
|
||||
## CLI
|
||||
|
||||
The `multica` CLI connects your local machine to Multica — authenticate, manage workspaces, and run the agent daemon.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `multica login` | Authenticate (opens browser) |
|
||||
| `multica daemon start` | Start the local agent runtime |
|
||||
| `multica daemon status` | Check daemon status |
|
||||
| `multica setup` | One-command setup for Multica Cloud (configure + login + start daemon) |
|
||||
| `multica setup self-host` | Same, but for self-hosted deployments |
|
||||
| `multica issue list` | List issues in your workspace |
|
||||
| `multica issue create` | Create a new issue |
|
||||
| `multica update` | Update to the latest version |
|
||||
|
||||
See the [CLI and Daemon Guide](CLI_AND_DAEMON.md) for the full command reference.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
|
||||
@@ -18,10 +18,9 @@
|
||||
将编码 Agent 变成真正的队友——分配任务、跟踪进度、积累技能。
|
||||
|
||||
[](https://github.com/multica-ai/multica/actions/workflows/ci.yml)
|
||||
[](https://opensource.org/licenses/Apache-2.0)
|
||||
[](https://github.com/multica-ai/multica/stargazers)
|
||||
|
||||
[官网](https://multica.ai) · [云服务](https://multica.ai/app) · [X](https://x.com/multica_hq) · [自部署指南](SELF_HOSTING.md) · [参与贡献](CONTRIBUTING.md)
|
||||
[官网](https://multica.ai) · [云服务](https://multica.ai/app) · [X](https://x.com/MulticaAI) · [自部署指南](SELF_HOSTING.md) · [参与贡献](CONTRIBUTING.md)
|
||||
|
||||
**[English](README.md) | 简体中文**
|
||||
|
||||
@@ -47,62 +46,47 @@ Multica 管理完整的 Agent 生命周期:从任务分配到执行监控再
|
||||
- **统一运行时** — 一个控制台管理所有算力。本地 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 # 启动应用
|
||||
curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash
|
||||
```
|
||||
|
||||
完整部署文档请参阅 [自部署指南](SELF_HOSTING.md)。
|
||||
安装 Multica CLI,支持 macOS 和 Linux。有 Homebrew 用 Homebrew,没有则直接下载二进制。
|
||||
|
||||
## CLI
|
||||
**Windows (PowerShell):**
|
||||
|
||||
`multica` CLI 将你的本地机器连接到 Multica — 用于认证、管理工作区和运行 Agent daemon。
|
||||
|
||||
**方式 A — 将以下指令粘贴给你的 coding agent(Claude Code、Codex、OpenClaw、OpenCode 等):**
|
||||
|
||||
```
|
||||
Fetch https://github.com/multica-ai/multica/blob/main/CLI_INSTALL.md and follow the instructions to install Multica CLI, log in, and start the daemon on this machine.
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
**方式 B — 手动安装:**
|
||||
安装完成后,一条命令完成配置、认证和启动:
|
||||
|
||||
```bash
|
||||
# 安装
|
||||
brew tap multica-ai/tap
|
||||
brew install multica
|
||||
|
||||
# 认证并启动
|
||||
multica login
|
||||
multica daemon start
|
||||
multica setup # 连接 Multica Cloud,登录,启动 daemon
|
||||
```
|
||||
|
||||
daemon 会自动检测 PATH 中可用的 Agent CLI(`claude`、`codex`、`openclaw`、`opencode`)。当 Agent 被分配任务时,daemon 会创建隔离环境、运行 Agent、并将结果回传。
|
||||
> **自部署?** 加上 `--with-server` 在本地部署完整的 Multica 服务:
|
||||
>
|
||||
> ```bash
|
||||
> curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash -s -- --with-server
|
||||
> multica setup self-host
|
||||
> ```
|
||||
>
|
||||
> 需要 Docker。详见 [自部署指南](SELF_HOSTING.md)。
|
||||
|
||||
完整命令参考请参阅 [CLI 与 Daemon 指南](CLI_AND_DAEMON.md)。
|
||||
---
|
||||
|
||||
## 快速上手
|
||||
|
||||
安装好 CLI(或注册 [Multica 云服务](https://multica.ai))后,按以下步骤将第一个任务分配给 Agent:
|
||||
|
||||
### 1. 登录并启动 daemon
|
||||
### 1. 配置并启动 daemon
|
||||
|
||||
```bash
|
||||
multica login # 使用你的 Multica 账号认证
|
||||
multica daemon start # 启动本地 Agent 运行时
|
||||
multica setup # 配置、认证、启动 daemon(一条命令搞定)
|
||||
```
|
||||
|
||||
daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动检测 PATH 中可用的 Agent CLI(`claude`、`codex`、`openclaw`、`opencode`)。
|
||||
|
||||
434
SELF_HOSTING.md
434
SELF_HOSTING.md
@@ -1,10 +1,8 @@
|
||||
# Self-Hosting Guide
|
||||
|
||||
This guide walks you through deploying Multica on your own infrastructure.
|
||||
Deploy Multica on your own infrastructure in minutes.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
Multica has three components:
|
||||
## Architecture
|
||||
|
||||
| Component | Description | Technology |
|
||||
|-----------|-------------|------------|
|
||||
@@ -12,12 +10,155 @@ Multica has three components:
|
||||
| **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.
|
||||
Each user who runs AI agents locally also installs the **`multica` CLI** and runs the **agent daemon** on their own machine.
|
||||
|
||||
## Quick Start (Docker Compose)
|
||||
## Quick Install (Recommended)
|
||||
|
||||
Two commands to set up everything — server, CLI, and configuration:
|
||||
|
||||
```bash
|
||||
# 1. Install CLI + provision the self-host server
|
||||
curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash -s -- --with-server
|
||||
|
||||
# 2. Configure CLI, authenticate, and start the daemon
|
||||
multica setup self-host
|
||||
```
|
||||
|
||||
This clones the repository, starts all services via Docker Compose, installs the `multica` CLI, then configures it for localhost.
|
||||
|
||||
Open http://localhost:3000, log in with any email + verification code **`888888`**.
|
||||
|
||||
> **Prerequisites:** Docker and Docker Compose must be installed. The script checks for this and provides install links if missing.
|
||||
|
||||
---
|
||||
|
||||
## Step-by-Step Setup (Alternative)
|
||||
|
||||
If you prefer to run each step manually:
|
||||
|
||||
### Step 1 — Start the Server
|
||||
|
||||
**Prerequisites:** Docker and Docker Compose.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/multica-ai/multica.git
|
||||
cd multica
|
||||
make selfhost
|
||||
```
|
||||
|
||||
`make selfhost` automatically creates `.env` from the example, generates a random `JWT_SECRET`, and starts all services via Docker Compose.
|
||||
|
||||
Once ready:
|
||||
|
||||
- **Frontend:** http://localhost:3000
|
||||
- **Backend API:** http://localhost:8080
|
||||
|
||||
> **Note:** If you prefer to run the Docker Compose steps manually, see [Manual Docker Compose Setup](#manual-docker-compose-setup) below.
|
||||
|
||||
### Step 2 — Log In
|
||||
|
||||
Open http://localhost:3000 in your browser. Enter any email address and use verification code **`888888`** to log in.
|
||||
|
||||
> This master code works in all non-production environments (i.e. when `APP_ENV` is not set to `production`). For production, configure an email provider — see [Advanced Configuration](SELF_HOSTING_ADVANCED.md#email-required-for-authentication).
|
||||
|
||||
### Step 3 — Install CLI & Start Daemon
|
||||
|
||||
The daemon runs on your local machine (not inside Docker). It detects installed AI agent CLIs, registers them with the server, and executes tasks when agents are assigned work.
|
||||
|
||||
Each team member who wants to run AI agents locally needs to:
|
||||
|
||||
### a) Install the CLI and an AI agent
|
||||
|
||||
```bash
|
||||
brew tap multica-ai/tap
|
||||
brew install multica
|
||||
```
|
||||
|
||||
You also need at least one AI agent CLI installed:
|
||||
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code) (`claude` on PATH)
|
||||
- [Codex](https://github.com/openai/codex) (`codex` on PATH)
|
||||
- [OpenClaw](https://github.com/openclaw/openclaw) (`openclaw` on PATH)
|
||||
- [OpenCode](https://github.com/anomalyco/opencode) (`opencode` on PATH)
|
||||
- [Hermes](https://github.com/NousResearch/hermes) (`hermes` on PATH)
|
||||
|
||||
### b) One-command setup
|
||||
|
||||
```bash
|
||||
multica setup self-host
|
||||
```
|
||||
|
||||
This automatically:
|
||||
1. Configures the CLI to connect to `localhost` (ports 8080/3000)
|
||||
2. Opens your browser for authentication
|
||||
3. Discovers your workspaces
|
||||
4. Starts the daemon in the background
|
||||
|
||||
For on-premise deployments with custom domains:
|
||||
|
||||
```bash
|
||||
multica setup self-host --server-url https://api.example.com --app-url https://app.example.com
|
||||
```
|
||||
|
||||
To verify the daemon is running:
|
||||
|
||||
```bash
|
||||
multica daemon status
|
||||
```
|
||||
|
||||
> **Alternative:** If you prefer manual steps, see [Manual CLI Configuration](#manual-cli-configuration) below.
|
||||
|
||||
### Step 4 — Verify & Start Using
|
||||
|
||||
1. Open your workspace in the web app at http://localhost:3000
|
||||
2. Navigate to **Settings → Runtimes** — you should see your machine listed
|
||||
3. Go to **Settings → Agents** and create a new agent
|
||||
4. Create an issue and assign it to your agent — it will pick up the task automatically
|
||||
|
||||
## Stopping Services
|
||||
|
||||
If you installed via the install script:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash -s -- --stop
|
||||
```
|
||||
|
||||
If you cloned the repo manually:
|
||||
|
||||
```bash
|
||||
# Stop the Docker Compose services (backend, frontend, database)
|
||||
make selfhost-stop
|
||||
|
||||
# Stop the local daemon
|
||||
multica daemon stop
|
||||
```
|
||||
|
||||
## Switching to Multica Cloud
|
||||
|
||||
If you've been self-hosting and want to switch your CLI to [Multica Cloud](https://multica.ai):
|
||||
|
||||
```bash
|
||||
multica setup
|
||||
```
|
||||
|
||||
This reconfigures the CLI for multica.ai, re-authenticates, and restarts the daemon. You will be prompted before overwriting the existing configuration.
|
||||
|
||||
> Your local Docker services are unaffected. Stop them separately if you no longer need them.
|
||||
|
||||
## Rebuilding After Updates
|
||||
|
||||
```bash
|
||||
git pull
|
||||
make selfhost
|
||||
```
|
||||
|
||||
Migrations run automatically on backend startup.
|
||||
|
||||
---
|
||||
|
||||
## Manual Docker Compose Setup
|
||||
|
||||
If you prefer running Docker Compose steps manually instead of `make selfhost`:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/multica-ai/multica.git
|
||||
cd multica
|
||||
@@ -36,276 +177,31 @@ Then start everything:
|
||||
docker compose -f docker-compose.selfhost.yml up -d
|
||||
```
|
||||
|
||||
That's it. This builds and starts PostgreSQL, the backend (with auto-migration), and the frontend:
|
||||
## Manual CLI Configuration
|
||||
|
||||
- **Frontend:** http://localhost:3000
|
||||
- **Backend API:** http://localhost:8080
|
||||
|
||||
The backend automatically runs database migrations on startup — no manual migration step needed.
|
||||
|
||||
### Rebuilding After Updates
|
||||
If you prefer configuring the CLI step by step instead of `multica setup`:
|
||||
|
||||
```bash
|
||||
git pull
|
||||
docker compose -f docker-compose.selfhost.yml up -d --build
|
||||
# Point CLI to your local server
|
||||
multica config set server_url http://localhost:8080
|
||||
multica config set app_url http://localhost:3000
|
||||
|
||||
# Login (opens browser)
|
||||
multica login
|
||||
|
||||
# Start the daemon
|
||||
multica daemon start
|
||||
```
|
||||
|
||||
Migrations run automatically on each backend startup.
|
||||
|
||||
## Configuration
|
||||
|
||||
All configuration is done via environment variables. Copy `.env.example` as a starting point.
|
||||
|
||||
### Required Variables
|
||||
|
||||
| Variable | Description | Example |
|
||||
|----------|-------------|---------|
|
||||
| `DATABASE_URL` | PostgreSQL connection string | `postgres://multica:multica@localhost:5432/multica?sslmode=disable` |
|
||||
| `JWT_SECRET` | **Must change from default.** Secret key for signing JWT tokens. Use a long random string. | `openssl rand -hex 32` |
|
||||
| `FRONTEND_ORIGIN` | URL where the frontend is served (used for CORS) | `https://app.example.com` |
|
||||
|
||||
### Email (Required for Authentication)
|
||||
|
||||
Multica uses email-based magic link authentication via [Resend](https://resend.com).
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `RESEND_API_KEY` | Your Resend API key |
|
||||
| `RESEND_FROM_EMAIL` | Sender email address (default: `noreply@multica.ai`) |
|
||||
|
||||
### Google OAuth (Optional)
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `GOOGLE_CLIENT_ID` | Google OAuth client ID |
|
||||
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret |
|
||||
| `GOOGLE_REDIRECT_URI` | OAuth callback URL (e.g. `https://app.example.com/auth/callback`) |
|
||||
|
||||
### File Storage (Optional)
|
||||
|
||||
For file uploads and attachments, configure S3 and CloudFront:
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `S3_BUCKET` | S3 bucket name |
|
||||
| `S3_REGION` | AWS region (default: `us-west-2`) |
|
||||
| `CLOUDFRONT_DOMAIN` | CloudFront distribution domain |
|
||||
| `CLOUDFRONT_KEY_PAIR_ID` | CloudFront key pair ID for signed URLs |
|
||||
| `CLOUDFRONT_PRIVATE_KEY` | CloudFront private key (PEM format) |
|
||||
| `COOKIE_DOMAIN` | Domain for CloudFront auth cookies |
|
||||
|
||||
### Server
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `PORT` | `8080` | Backend server port |
|
||||
| `FRONTEND_PORT` | `3000` | Frontend port |
|
||||
| `CORS_ALLOWED_ORIGINS` | Value of `FRONTEND_ORIGIN` | Comma-separated list of allowed origins |
|
||||
| `LOG_LEVEL` | `info` | Log level: `debug`, `info`, `warn`, `error` |
|
||||
|
||||
### CLI / Daemon
|
||||
|
||||
These are configured on each user's machine, not on the server:
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `MULTICA_SERVER_URL` | `ws://localhost:8080/ws` | WebSocket URL for daemon → server connection |
|
||||
| `MULTICA_APP_URL` | `http://localhost:3000` | Frontend URL for CLI login flow |
|
||||
| `MULTICA_DAEMON_POLL_INTERVAL` | `3s` | How often the daemon polls for tasks |
|
||||
| `MULTICA_DAEMON_HEARTBEAT_INTERVAL` | `15s` | Heartbeat frequency |
|
||||
|
||||
## Database Setup
|
||||
|
||||
Multica requires PostgreSQL 17 with the pgvector extension.
|
||||
|
||||
### Using Docker Compose (Recommended)
|
||||
|
||||
The `docker-compose.selfhost.yml` includes PostgreSQL. No separate setup needed.
|
||||
|
||||
### Using Your Own PostgreSQL
|
||||
|
||||
If you prefer to use an existing PostgreSQL instance, ensure the pgvector extension is available:
|
||||
|
||||
```sql
|
||||
CREATE EXTENSION IF NOT EXISTS vector;
|
||||
```
|
||||
|
||||
Set `DATABASE_URL` in your `.env` and remove the `postgres` service from the compose file.
|
||||
|
||||
### Running Migrations Manually
|
||||
|
||||
The Docker Compose setup runs migrations automatically. If you need to run them manually:
|
||||
For production deployments with TLS:
|
||||
|
||||
```bash
|
||||
# Using the built binary
|
||||
./server/bin/migrate up
|
||||
|
||||
# Or from source
|
||||
cd server && go run ./cmd/migrate up
|
||||
multica config set app_url https://app.example.com
|
||||
multica config set server_url https://api.example.com
|
||||
multica login
|
||||
multica daemon start
|
||||
```
|
||||
|
||||
## Manual Setup (Without Docker Compose)
|
||||
## Advanced Configuration
|
||||
|
||||
If you prefer to build and run services manually:
|
||||
|
||||
**Prerequisites:** Go 1.26+, Node.js 20+, pnpm 10.28+, PostgreSQL 17 with pgvector.
|
||||
|
||||
```bash
|
||||
# Start your PostgreSQL (or use: docker compose up -d postgres)
|
||||
|
||||
# Build the backend
|
||||
make build
|
||||
|
||||
# Run database migrations
|
||||
DATABASE_URL="your-database-url" ./server/bin/migrate up
|
||||
|
||||
# Start the backend server
|
||||
DATABASE_URL="your-database-url" PORT=8080 JWT_SECRET="your-secret" ./server/bin/server
|
||||
```
|
||||
|
||||
For the frontend:
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm build
|
||||
|
||||
# Start the frontend (production mode)
|
||||
cd apps/web
|
||||
REMOTE_API_URL=http://localhost:8080 pnpm start
|
||||
```
|
||||
|
||||
## Reverse Proxy
|
||||
|
||||
In production, put a reverse proxy in front of both the backend and frontend to handle TLS and routing.
|
||||
|
||||
### Caddy (Recommended)
|
||||
|
||||
```
|
||||
app.example.com {
|
||||
reverse_proxy localhost:3000
|
||||
}
|
||||
|
||||
api.example.com {
|
||||
reverse_proxy localhost:8080
|
||||
}
|
||||
```
|
||||
|
||||
### Nginx
|
||||
|
||||
```nginx
|
||||
# Frontend
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name app.example.com;
|
||||
|
||||
ssl_certificate /path/to/cert.pem;
|
||||
ssl_certificate_key /path/to/key.pem;
|
||||
|
||||
location / {
|
||||
proxy_pass http://localhost:3000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
|
||||
# Backend API
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name api.example.com;
|
||||
|
||||
ssl_certificate /path/to/cert.pem;
|
||||
ssl_certificate_key /path/to/key.pem;
|
||||
|
||||
location / {
|
||||
proxy_pass http://localhost:8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# WebSocket support
|
||||
location /ws {
|
||||
proxy_pass http://localhost:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_read_timeout 86400;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When using separate domains for frontend and backend, set these environment variables accordingly:
|
||||
|
||||
```bash
|
||||
# Backend
|
||||
FRONTEND_ORIGIN=https://app.example.com
|
||||
CORS_ALLOWED_ORIGINS=https://app.example.com
|
||||
|
||||
# Frontend (set before building the frontend image)
|
||||
REMOTE_API_URL=https://api.example.com
|
||||
NEXT_PUBLIC_API_URL=https://api.example.com
|
||||
NEXT_PUBLIC_WS_URL=wss://api.example.com/ws
|
||||
```
|
||||
|
||||
## Health Check
|
||||
|
||||
The backend exposes a health check endpoint:
|
||||
|
||||
```
|
||||
GET /health
|
||||
→ {"status":"ok"}
|
||||
```
|
||||
|
||||
Use this for load balancer health checks or monitoring.
|
||||
|
||||
## 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
|
||||
|
||||
```bash
|
||||
git pull
|
||||
docker compose -f docker-compose.selfhost.yml up -d --build
|
||||
```
|
||||
|
||||
Migrations run automatically on backend startup. They are idempotent — running them multiple times has no effect.
|
||||
For environment variables, manual setup (without Docker), reverse proxy configuration, database setup, and more, see the [Advanced Configuration Guide](SELF_HOSTING_ADVANCED.md).
|
||||
|
||||
239
SELF_HOSTING_ADVANCED.md
Normal file
239
SELF_HOSTING_ADVANCED.md
Normal file
@@ -0,0 +1,239 @@
|
||||
# Self-Hosting — Advanced Configuration
|
||||
|
||||
This document covers advanced configuration for self-hosted Multica deployments. For the quick start guide, see [SELF_HOSTING.md](SELF_HOSTING.md).
|
||||
|
||||
## Configuration
|
||||
|
||||
All configuration is done via environment variables. Copy `.env.example` as a starting point.
|
||||
|
||||
### Required Variables
|
||||
|
||||
| Variable | Description | Example |
|
||||
|----------|-------------|---------|
|
||||
| `DATABASE_URL` | PostgreSQL connection string | `postgres://multica:multica@localhost:5432/multica?sslmode=disable` |
|
||||
| `JWT_SECRET` | **Must change from default.** Secret key for signing JWT tokens. Use a long random string. | `openssl rand -hex 32` |
|
||||
| `FRONTEND_ORIGIN` | URL where the frontend is served (used for CORS) | `https://app.example.com` |
|
||||
|
||||
### Email (Required for Authentication)
|
||||
|
||||
Multica uses email-based magic link authentication via [Resend](https://resend.com).
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `RESEND_API_KEY` | Your Resend API key |
|
||||
| `RESEND_FROM_EMAIL` | Sender email address (default: `noreply@multica.ai`) |
|
||||
|
||||
> **Note:** For local/development deployments without email configured, you can use the master verification code `888888` to log in.
|
||||
|
||||
### Google OAuth (Optional)
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `GOOGLE_CLIENT_ID` | Google OAuth client ID |
|
||||
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret |
|
||||
| `GOOGLE_REDIRECT_URI` | OAuth callback URL (e.g. `https://app.example.com/auth/callback`) |
|
||||
|
||||
### File Storage (Optional)
|
||||
|
||||
For file uploads and attachments, configure S3 and CloudFront:
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `S3_BUCKET` | S3 bucket name |
|
||||
| `S3_REGION` | AWS region (default: `us-west-2`) |
|
||||
| `CLOUDFRONT_DOMAIN` | CloudFront distribution domain |
|
||||
| `CLOUDFRONT_KEY_PAIR_ID` | CloudFront key pair ID for signed URLs |
|
||||
| `CLOUDFRONT_PRIVATE_KEY` | CloudFront private key (PEM format) |
|
||||
| `COOKIE_DOMAIN` | Domain for CloudFront auth cookies |
|
||||
|
||||
### Server
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `PORT` | `8080` | Backend server port |
|
||||
| `FRONTEND_PORT` | `3000` | Frontend port |
|
||||
| `CORS_ALLOWED_ORIGINS` | Value of `FRONTEND_ORIGIN` | Comma-separated list of allowed origins |
|
||||
| `LOG_LEVEL` | `info` | Log level: `debug`, `info`, `warn`, `error` |
|
||||
|
||||
### CLI / Daemon
|
||||
|
||||
These are configured on each user's machine, not on the server:
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `MULTICA_SERVER_URL` | `ws://localhost:8080/ws` | WebSocket URL for daemon → server connection |
|
||||
| `MULTICA_APP_URL` | `http://localhost:3000` | Frontend URL for CLI login flow |
|
||||
| `MULTICA_DAEMON_POLL_INTERVAL` | `3s` | How often the daemon polls for tasks |
|
||||
| `MULTICA_DAEMON_HEARTBEAT_INTERVAL` | `15s` | Heartbeat frequency |
|
||||
|
||||
Agent-specific overrides:
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `MULTICA_CLAUDE_PATH` | Custom path to the `claude` binary |
|
||||
| `MULTICA_CLAUDE_MODEL` | Override the Claude model used |
|
||||
| `MULTICA_CODEX_PATH` | Custom path to the `codex` binary |
|
||||
| `MULTICA_CODEX_MODEL` | Override the Codex model used |
|
||||
| `MULTICA_OPENCODE_PATH` | Custom path to the `opencode` binary |
|
||||
| `MULTICA_OPENCODE_MODEL` | Override the OpenCode model used |
|
||||
| `MULTICA_OPENCLAW_PATH` | Custom path to the `openclaw` binary |
|
||||
| `MULTICA_OPENCLAW_MODEL` | Override the OpenClaw model used |
|
||||
| `MULTICA_HERMES_PATH` | Custom path to the `hermes` binary |
|
||||
| `MULTICA_HERMES_MODEL` | Override the Hermes model used |
|
||||
|
||||
## Database Setup
|
||||
|
||||
Multica requires PostgreSQL 17 with the pgvector extension.
|
||||
|
||||
### Using Docker Compose (Recommended)
|
||||
|
||||
The `docker-compose.selfhost.yml` includes PostgreSQL. No separate setup needed.
|
||||
|
||||
### Using Your Own PostgreSQL
|
||||
|
||||
If you prefer to use an existing PostgreSQL instance, ensure the pgvector extension is available:
|
||||
|
||||
```sql
|
||||
CREATE EXTENSION IF NOT EXISTS vector;
|
||||
```
|
||||
|
||||
Set `DATABASE_URL` in your `.env` and remove the `postgres` service from the compose file.
|
||||
|
||||
### Running Migrations Manually
|
||||
|
||||
The Docker Compose setup runs migrations automatically. If you need to run them manually:
|
||||
|
||||
```bash
|
||||
# Using the built binary
|
||||
./server/bin/migrate up
|
||||
|
||||
# Or from source
|
||||
cd server && go run ./cmd/migrate up
|
||||
```
|
||||
|
||||
## Manual Setup (Without Docker Compose)
|
||||
|
||||
If you prefer to build and run services manually:
|
||||
|
||||
**Prerequisites:** Go 1.26+, Node.js 20+, pnpm 10.28+, PostgreSQL 17 with pgvector.
|
||||
|
||||
```bash
|
||||
# Start your PostgreSQL (or use: docker compose up -d postgres)
|
||||
|
||||
# Build the backend
|
||||
make build
|
||||
|
||||
# Run database migrations
|
||||
DATABASE_URL="your-database-url" ./server/bin/migrate up
|
||||
|
||||
# Start the backend server
|
||||
DATABASE_URL="your-database-url" PORT=8080 JWT_SECRET="your-secret" ./server/bin/server
|
||||
```
|
||||
|
||||
For the frontend:
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm build
|
||||
|
||||
# Start the frontend (production mode)
|
||||
cd apps/web
|
||||
REMOTE_API_URL=http://localhost:8080 pnpm start
|
||||
```
|
||||
|
||||
## Reverse Proxy
|
||||
|
||||
In production, put a reverse proxy in front of both the backend and frontend to handle TLS and routing.
|
||||
|
||||
### Caddy (Recommended)
|
||||
|
||||
```
|
||||
app.example.com {
|
||||
reverse_proxy localhost:3000
|
||||
}
|
||||
|
||||
api.example.com {
|
||||
reverse_proxy localhost:8080
|
||||
}
|
||||
```
|
||||
|
||||
### Nginx
|
||||
|
||||
```nginx
|
||||
# Frontend
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name app.example.com;
|
||||
|
||||
ssl_certificate /path/to/cert.pem;
|
||||
ssl_certificate_key /path/to/key.pem;
|
||||
|
||||
location / {
|
||||
proxy_pass http://localhost:3000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
|
||||
# Backend API
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name api.example.com;
|
||||
|
||||
ssl_certificate /path/to/cert.pem;
|
||||
ssl_certificate_key /path/to/key.pem;
|
||||
|
||||
location / {
|
||||
proxy_pass http://localhost:8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# WebSocket support
|
||||
location /ws {
|
||||
proxy_pass http://localhost:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_read_timeout 86400;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When using separate domains for frontend and backend, set these environment variables accordingly:
|
||||
|
||||
```bash
|
||||
# Backend
|
||||
FRONTEND_ORIGIN=https://app.example.com
|
||||
CORS_ALLOWED_ORIGINS=https://app.example.com
|
||||
|
||||
# Frontend (set before building the frontend image)
|
||||
REMOTE_API_URL=https://api.example.com
|
||||
NEXT_PUBLIC_API_URL=https://api.example.com
|
||||
NEXT_PUBLIC_WS_URL=wss://api.example.com/ws
|
||||
```
|
||||
|
||||
## Health Check
|
||||
|
||||
The backend exposes a health check endpoint:
|
||||
|
||||
```
|
||||
GET /health
|
||||
→ {"status":"ok"}
|
||||
```
|
||||
|
||||
Use this for load balancer health checks or monitoring.
|
||||
|
||||
## Upgrading
|
||||
|
||||
```bash
|
||||
git pull
|
||||
docker compose -f docker-compose.selfhost.yml up -d --build
|
||||
```
|
||||
|
||||
Migrations run automatically on backend startup. They are idempotent — running them multiple times has no effect.
|
||||
76
SELF_HOSTING_AI.md
Normal file
76
SELF_HOSTING_AI.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# Self-Hosting Setup (for AI Agents)
|
||||
|
||||
This document is designed for AI agents to execute. Follow these steps exactly to deploy a local Multica instance and connect to it.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Docker and Docker Compose installed
|
||||
- Homebrew installed (for CLI)
|
||||
- At least one AI agent CLI on PATH: `claude` or `codex`
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
# Install CLI + provision self-host server
|
||||
curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash -s -- --with-server
|
||||
|
||||
# Configure CLI for localhost, authenticate, and start daemon
|
||||
multica setup self-host
|
||||
```
|
||||
|
||||
Wait for the server output `✓ Multica server is running and CLI is ready!` before running `multica setup self-host`.
|
||||
|
||||
**Expected result:**
|
||||
- Frontend at http://localhost:3000
|
||||
- Backend at http://localhost:8080
|
||||
- `multica` CLI installed and configured for localhost
|
||||
|
||||
## Alternative: Manual Setup
|
||||
|
||||
```bash
|
||||
git clone https://github.com/multica-ai/multica.git
|
||||
cd multica
|
||||
make selfhost
|
||||
brew install multica-ai/tap/multica
|
||||
multica setup self-host
|
||||
```
|
||||
|
||||
The `multica setup self-host` command will:
|
||||
1. Configure CLI to connect to localhost:8080 / localhost:3000
|
||||
2. Open a browser for login — use verification code `888888` with any email
|
||||
3. Discover workspaces automatically
|
||||
4. Start the daemon in the background
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
multica daemon status
|
||||
```
|
||||
|
||||
Should show `running` with detected agents.
|
||||
|
||||
## Stopping
|
||||
|
||||
```bash
|
||||
# Stop the daemon
|
||||
multica daemon stop
|
||||
|
||||
# Stop all Docker services
|
||||
cd multica
|
||||
make selfhost-stop
|
||||
```
|
||||
|
||||
## Custom Ports
|
||||
|
||||
If the default ports (8080/3000) are in use:
|
||||
|
||||
1. Edit `.env` and change `PORT` and `FRONTEND_PORT`
|
||||
2. Run `make selfhost`
|
||||
3. Run `multica setup self-host --port <PORT> --frontend-port <FRONTEND_PORT>`
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Backend not ready:** `docker compose -f docker-compose.selfhost.yml logs backend`
|
||||
- **Frontend not ready:** `docker compose -f docker-compose.selfhost.yml logs frontend`
|
||||
- **Daemon issues:** `multica daemon logs`
|
||||
- **Health check:** `curl http://localhost:8080/health`
|
||||
@@ -8,6 +8,10 @@ files:
|
||||
- "!electron.vite.config.*"
|
||||
- "!{.eslintignore,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}"
|
||||
- "!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}"
|
||||
protocols:
|
||||
- name: Multica
|
||||
schemes:
|
||||
- multica
|
||||
asarUnpack:
|
||||
- resources/**
|
||||
mac:
|
||||
|
||||
@@ -1,26 +1,41 @@
|
||||
import { resolve } from "path";
|
||||
import { defineConfig, externalizeDepsPlugin } from "electron-vite";
|
||||
import { loadEnv } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
export default defineConfig({
|
||||
main: {
|
||||
plugins: [externalizeDepsPlugin()],
|
||||
},
|
||||
preload: {
|
||||
plugins: [externalizeDepsPlugin()],
|
||||
},
|
||||
renderer: {
|
||||
server: {
|
||||
port: 5173,
|
||||
strictPort: true,
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), "");
|
||||
const remoteApi = env.VITE_REMOTE_API;
|
||||
const remoteWs = remoteApi?.replace(/^https/, "wss").replace(/^http/, "ws");
|
||||
|
||||
return {
|
||||
main: {
|
||||
plugins: [externalizeDepsPlugin()],
|
||||
},
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": resolve("src/renderer/src"),
|
||||
preload: {
|
||||
plugins: [externalizeDepsPlugin()],
|
||||
},
|
||||
renderer: {
|
||||
server: {
|
||||
port: 5173,
|
||||
strictPort: true,
|
||||
...(remoteApi && {
|
||||
proxy: {
|
||||
"/api": { target: remoteApi, changeOrigin: true },
|
||||
"/auth": { target: remoteApi, changeOrigin: true },
|
||||
"/uploads": { target: remoteApi, changeOrigin: true },
|
||||
"/ws": { target: remoteWs, changeOrigin: true, ws: true },
|
||||
},
|
||||
}),
|
||||
},
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": resolve("src/renderer/src"),
|
||||
},
|
||||
dedupe: ["react", "react-dom"],
|
||||
},
|
||||
dedupe: ["react", "react-dom"],
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"main": "./out/main/index.js",
|
||||
"scripts": {
|
||||
"dev": "electron-vite dev",
|
||||
"dev:remote": "electron-vite dev --mode remote",
|
||||
"build": "electron-vite build",
|
||||
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
|
||||
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
|
||||
|
||||
@@ -1,9 +1,32 @@
|
||||
import { app, shell, BrowserWindow } from "electron";
|
||||
import { app, shell, BrowserWindow, ipcMain } from "electron";
|
||||
import { join } from "path";
|
||||
import { electronApp, optimizer, is } from "@electron-toolkit/utils";
|
||||
|
||||
const PROTOCOL = "multica";
|
||||
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
|
||||
// --- Deep link helpers ---------------------------------------------------
|
||||
|
||||
function handleDeepLink(url: string): void {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.protocol !== `${PROTOCOL}:`) return;
|
||||
|
||||
// multica://auth/callback?token=<jwt>
|
||||
if (parsed.hostname === "auth" && parsed.pathname === "/callback") {
|
||||
const token = parsed.searchParams.get("token");
|
||||
if (token && mainWindow) {
|
||||
mainWindow.webContents.send("auth:token", token);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed URLs
|
||||
}
|
||||
}
|
||||
|
||||
// --- Window creation -----------------------------------------------------
|
||||
|
||||
function createWindow(): void {
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1280,
|
||||
@@ -21,6 +44,16 @@ function createWindow(): void {
|
||||
},
|
||||
});
|
||||
|
||||
// Strip Origin header from WebSocket upgrade requests so the server's
|
||||
// origin whitelist doesn't reject connections from localhost dev origins.
|
||||
mainWindow.webContents.session.webRequest.onBeforeSendHeaders(
|
||||
{ urls: ["wss://*/*", "ws://*/*"] },
|
||||
(details, callback) => {
|
||||
delete details.requestHeaders["Origin"];
|
||||
callback({ requestHeaders: details.requestHeaders });
|
||||
},
|
||||
);
|
||||
|
||||
mainWindow.on("ready-to-show", () => {
|
||||
mainWindow?.show();
|
||||
});
|
||||
@@ -37,19 +70,72 @@ function createWindow(): void {
|
||||
}
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
electronApp.setAppUserModelId("ai.multica.desktop");
|
||||
// --- Protocol registration -----------------------------------------------
|
||||
|
||||
app.on("browser-window-created", (_, window) => {
|
||||
optimizer.watchWindowShortcuts(window);
|
||||
if (process.defaultApp) {
|
||||
// In dev, register with the path to the electron binary + app path
|
||||
app.setAsDefaultProtocolClient(PROTOCOL, process.execPath, [
|
||||
app.getAppPath(),
|
||||
]);
|
||||
} else {
|
||||
app.setAsDefaultProtocolClient(PROTOCOL);
|
||||
}
|
||||
|
||||
// --- Single instance lock ------------------------------------------------
|
||||
|
||||
const gotTheLock = app.requestSingleInstanceLock();
|
||||
|
||||
if (!gotTheLock) {
|
||||
app.quit();
|
||||
} else {
|
||||
// Windows/Linux: second instance passes deep link via argv
|
||||
app.on("second-instance", (_event, argv) => {
|
||||
if (mainWindow) {
|
||||
if (mainWindow.isMinimized()) mainWindow.restore();
|
||||
mainWindow.focus();
|
||||
}
|
||||
|
||||
// On Windows the deep link URL is the last argv entry
|
||||
const deepLinkUrl = argv.find((arg) => arg.startsWith(`${PROTOCOL}://`));
|
||||
if (deepLinkUrl) handleDeepLink(deepLinkUrl);
|
||||
});
|
||||
|
||||
createWindow();
|
||||
app.whenReady().then(() => {
|
||||
electronApp.setAppUserModelId("ai.multica.desktop");
|
||||
|
||||
app.on("activate", () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) createWindow();
|
||||
app.on("browser-window-created", (_, window) => {
|
||||
optimizer.watchWindowShortcuts(window);
|
||||
});
|
||||
|
||||
// IPC: open URL in default browser (used by renderer for Google login)
|
||||
ipcMain.handle("shell:openExternal", (_event, url: string) => {
|
||||
return shell.openExternal(url);
|
||||
});
|
||||
|
||||
createWindow();
|
||||
|
||||
// macOS: deep link arrives via open-url event
|
||||
app.on("open-url", (_event, url) => {
|
||||
if (mainWindow) {
|
||||
if (mainWindow.isMinimized()) mainWindow.restore();
|
||||
mainWindow.focus();
|
||||
}
|
||||
handleDeepLink(url);
|
||||
});
|
||||
|
||||
app.on("activate", () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) createWindow();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Check argv for deep link on cold start (Windows/Linux)
|
||||
const deepLinkArg = process.argv.find((arg) =>
|
||||
arg.startsWith(`${PROTOCOL}://`),
|
||||
);
|
||||
if (deepLinkArg) {
|
||||
app.whenReady().then(() => handleDeepLink(deepLinkArg));
|
||||
}
|
||||
}
|
||||
|
||||
app.on("window-all-closed", () => {
|
||||
if (process.platform !== "darwin") app.quit();
|
||||
|
||||
8
apps/desktop/src/preload/index.d.ts
vendored
8
apps/desktop/src/preload/index.d.ts
vendored
@@ -1,8 +1,16 @@
|
||||
import { ElectronAPI } from "@electron-toolkit/preload";
|
||||
|
||||
interface DesktopAPI {
|
||||
/** Listen for auth token delivered via deep link. Returns an unsubscribe function. */
|
||||
onAuthToken: (callback: (token: string) => void) => () => void;
|
||||
/** Open a URL in the default browser. */
|
||||
openExternal: (url: string) => Promise<void>;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
electron: ElectronAPI;
|
||||
desktopAPI: DesktopAPI;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,26 @@
|
||||
import { contextBridge } from "electron";
|
||||
import { contextBridge, ipcRenderer } from "electron";
|
||||
import { electronAPI } from "@electron-toolkit/preload";
|
||||
|
||||
const desktopAPI = {
|
||||
/** Listen for auth token delivered via deep link */
|
||||
onAuthToken: (callback: (token: string) => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, token: string) =>
|
||||
callback(token);
|
||||
ipcRenderer.on("auth:token", handler);
|
||||
return () => {
|
||||
ipcRenderer.removeListener("auth:token", handler);
|
||||
};
|
||||
},
|
||||
/** Open a URL in the default browser */
|
||||
openExternal: (url: string) => ipcRenderer.invoke("shell:openExternal", url),
|
||||
};
|
||||
|
||||
if (process.contextIsolated) {
|
||||
contextBridge.exposeInMainWorld("electron", electronAPI);
|
||||
contextBridge.exposeInMainWorld("desktopAPI", desktopAPI);
|
||||
} else {
|
||||
// @ts-expect-error - fallback for non-isolated context
|
||||
window.electron = electronAPI;
|
||||
// @ts-expect-error - fallback for non-isolated context
|
||||
window.desktopAPI = desktopAPI;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { useEffect } from "react";
|
||||
import { CoreProvider } from "@multica/core/platform";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { useWorkspaceStore } from "@multica/core/workspace";
|
||||
import { api } from "@multica/core/api";
|
||||
import { ThemeProvider } from "@multica/ui/components/common/theme-provider";
|
||||
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
|
||||
import { Toaster } from "sonner";
|
||||
@@ -10,6 +13,20 @@ function AppContent() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const isLoading = useAuthStore((s) => s.isLoading);
|
||||
|
||||
// Listen for auth token delivered via deep link (multica://auth/callback?token=...)
|
||||
useEffect(() => {
|
||||
return window.desktopAPI.onAuthToken(async (token) => {
|
||||
try {
|
||||
await useAuthStore.getState().loginWithToken(token);
|
||||
const wsList = await api.listWorkspaces();
|
||||
const lastWsId = localStorage.getItem("multica_workspace_id");
|
||||
useWorkspaceStore.getState().hydrateWorkspace(wsList, lastWsId);
|
||||
} catch {
|
||||
// Token invalid or expired — user stays on login page
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
@@ -22,12 +39,14 @@ function AppContent() {
|
||||
return <DesktopShell />;
|
||||
}
|
||||
|
||||
const remoteProxy = Boolean(import.meta.env.VITE_REMOTE_API);
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<CoreProvider
|
||||
apiBaseUrl={import.meta.env.VITE_API_URL || "http://localhost:8080"}
|
||||
wsUrl={import.meta.env.VITE_WS_URL || "ws://localhost:8080/ws"}
|
||||
apiBaseUrl={remoteProxy ? "" : (import.meta.env.VITE_API_URL || "http://localhost:8080")}
|
||||
wsUrl={remoteProxy ? "ws://localhost:5173/ws" : (import.meta.env.VITE_WS_URL || "ws://localhost:8080/ws")}
|
||||
>
|
||||
<AppContent />
|
||||
</CoreProvider>
|
||||
|
||||
@@ -85,17 +85,17 @@ export function DesktopShell() {
|
||||
>
|
||||
<TabBar />
|
||||
</header>
|
||||
{/* Content area with inset styling */}
|
||||
<div className="flex flex-1 min-h-0 flex-col overflow-hidden mr-2 mb-2 ml-0.5 rounded-xl shadow-sm bg-background">
|
||||
{/* Content area with inset styling — relative so ChatWindow/ChatFab are constrained here */}
|
||||
<div className="relative flex flex-1 min-h-0 flex-col overflow-hidden mr-2 mb-2 ml-0.5 rounded-xl shadow-sm bg-background">
|
||||
<TabContent />
|
||||
<ChatWindow />
|
||||
<ChatFab />
|
||||
</div>
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
</div>
|
||||
<ModalRegistry />
|
||||
<SearchCommand />
|
||||
<ChatWindow />
|
||||
<ChatFab />
|
||||
</DashboardGuard>
|
||||
</DesktopNavigationProvider>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
import { LoginPage } from "@multica/views/auth";
|
||||
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
|
||||
|
||||
const WEB_URL = import.meta.env.VITE_WEB_URL || "http://localhost:3000";
|
||||
|
||||
export function DesktopLoginPage() {
|
||||
const lastWorkspaceId = localStorage.getItem("multica_workspace_id");
|
||||
|
||||
const handleGoogleLogin = () => {
|
||||
// Open web login page in the default browser with platform=desktop flag.
|
||||
// The web callback will redirect back via multica:// deep link with the token.
|
||||
window.desktopAPI.openExternal(
|
||||
`${WEB_URL}/login?platform=desktop`,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col">
|
||||
{/* Traffic light inset */}
|
||||
@@ -11,9 +23,11 @@ export function DesktopLoginPage() {
|
||||
/>
|
||||
<LoginPage
|
||||
logo={<MulticaIcon bordered size="lg" />}
|
||||
lastWorkspaceId={lastWorkspaceId}
|
||||
onSuccess={() => {
|
||||
// Auth store update triggers AppContent re-render → shows DesktopShell
|
||||
}}
|
||||
onGoogleLogin={handleGoogleLogin}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { create } from "zustand";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
import { arrayMove } from "@dnd-kit/sortable";
|
||||
import { createPersistStorage, defaultStorage } from "@multica/core/platform";
|
||||
import { createSafeId } from "@multica/core/utils";
|
||||
import type { DataRouter } from "react-router-dom";
|
||||
import { createTabRouter } from "../routes";
|
||||
|
||||
@@ -69,7 +70,7 @@ export function resolveRouteIcon(pathname: string): string {
|
||||
const DEFAULT_PATH = "/issues";
|
||||
|
||||
function createId(): string {
|
||||
return crypto.randomUUID();
|
||||
return createSafeId();
|
||||
}
|
||||
|
||||
function makeTab(path: string, title: string, icon: string): Tab {
|
||||
|
||||
@@ -57,16 +57,13 @@ This auto-detects your installation method (Homebrew or manual) and upgrades acc
|
||||
## 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
|
||||
# One command: configure, authenticate, and start the daemon
|
||||
multica setup
|
||||
```
|
||||
|
||||
`multica login` automatically discovers all workspaces you belong to and adds them to the daemon watch list.
|
||||
This configures the CLI for Multica Cloud, opens your browser for login, discovers your workspaces, and starts the agent daemon.
|
||||
|
||||
For self-hosted servers, use `multica setup self-host` instead. See [Self-Hosting](/docs/getting-started/self-hosting) for details.
|
||||
|
||||
## Verify
|
||||
|
||||
@@ -76,12 +73,15 @@ multica daemon status
|
||||
|
||||
Confirm:
|
||||
1. Status is `running`
|
||||
2. At least one agent is listed (e.g. `claude`, `codex`)
|
||||
2. At least one agent is listed (e.g. `claude`, `codex`, `opencode`, `openclaw`, or `hermes`)
|
||||
3. At least one workspace is being watched
|
||||
|
||||
If the agents list is empty, install at least one AI agent CLI:
|
||||
If the agents list is empty, install at least one supported AI agent CLI:
|
||||
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code) (`claude`)
|
||||
- [Codex](https://github.com/openai/codex) (`codex`)
|
||||
- OpenCode (`opencode`)
|
||||
- OpenClaw (`openclaw`)
|
||||
- Hermes (`hermes`)
|
||||
|
||||
Then restart the daemon:
|
||||
|
||||
|
||||
@@ -88,6 +88,9 @@ The daemon auto-detects these AI CLIs on your PATH:
|
||||
|-----|---------|-------------|
|
||||
| [Claude Code](https://docs.anthropic.com/en/docs/claude-code) | `claude` | Anthropic's coding agent |
|
||||
| [Codex](https://github.com/openai/codex) | `codex` | OpenAI's coding agent |
|
||||
| OpenCode | `opencode` | Open-source coding agent |
|
||||
| OpenClaw | `openclaw` | Open-source coding agent |
|
||||
| Hermes | `hermes` | Nous Research coding agent |
|
||||
|
||||
You need at least one installed. The daemon registers each detected CLI as an available runtime.
|
||||
|
||||
@@ -122,6 +125,12 @@ Agent-specific overrides:
|
||||
| `MULTICA_CLAUDE_MODEL` | Override the Claude model used |
|
||||
| `MULTICA_CODEX_PATH` | Custom path to the `codex` binary |
|
||||
| `MULTICA_CODEX_MODEL` | Override the Codex model used |
|
||||
| `MULTICA_OPENCODE_PATH` | Custom path to the `opencode` binary |
|
||||
| `MULTICA_OPENCODE_MODEL` | Override the OpenCode model used |
|
||||
| `MULTICA_OPENCLAW_PATH` | Custom path to the `openclaw` binary |
|
||||
| `MULTICA_OPENCLAW_MODEL` | Override the OpenClaw model used |
|
||||
| `MULTICA_HERMES_PATH` | Custom path to the `hermes` binary |
|
||||
| `MULTICA_HERMES_MODEL` | Override the Hermes model used |
|
||||
|
||||
### Self-Hosted Server
|
||||
|
||||
@@ -147,9 +156,11 @@ multica config set server_url wss://api.example.com/ws
|
||||
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
|
||||
# Set up a staging profile
|
||||
multica setup self-host --profile staging --server-url https://api-staging.example.com --app-url https://staging.example.com
|
||||
|
||||
# Start its daemon
|
||||
multica daemon start --profile staging
|
||||
|
||||
# Default profile runs separately
|
||||
multica daemon start
|
||||
|
||||
@@ -20,13 +20,11 @@ Fetch https://github.com/multica-ai/multica/blob/main/CLI_INSTALL.md and follow
|
||||
Or install manually:
|
||||
|
||||
```bash
|
||||
# Install
|
||||
brew tap multica-ai/tap
|
||||
brew install multica
|
||||
# Install the CLI
|
||||
curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash
|
||||
|
||||
# Authenticate and start
|
||||
multica login
|
||||
multica daemon start
|
||||
# Configure, authenticate, and start the daemon
|
||||
multica setup
|
||||
```
|
||||
|
||||
The daemon auto-detects available agent CLIs (`claude`, `codex`, `openclaw`, `opencode`) on your PATH. When an agent is assigned a task, the daemon creates an isolated environment, runs the agent, and reports results back.
|
||||
|
||||
@@ -13,50 +13,147 @@ Multica has three components:
|
||||
| **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.
|
||||
Each user who wants to run AI agents locally also installs the **`multica` CLI** and runs the **agent daemon** on their own machine.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Docker and Docker Compose (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
|
||||
- Docker and Docker Compose
|
||||
|
||||
## Quick Start (Docker Compose)
|
||||
## Quick Install
|
||||
|
||||
Two commands to set up everything:
|
||||
|
||||
```bash
|
||||
# Install CLI + provision self-host server
|
||||
curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash -s -- --with-server
|
||||
|
||||
# Configure CLI, authenticate, and start the daemon
|
||||
multica setup self-host
|
||||
```
|
||||
|
||||
This clones the repo, starts all services, installs the CLI, and configures it for localhost. Then open http://localhost:3000 — log in with any email + code **`888888`**.
|
||||
|
||||
<Callout>
|
||||
For a step-by-step setup, see below.
|
||||
</Callout>
|
||||
|
||||
## Step-by-Step Setup
|
||||
|
||||
### Step 1 — Start the Server
|
||||
|
||||
```bash
|
||||
git clone https://github.com/multica-ai/multica.git
|
||||
cd multica
|
||||
cp .env.example .env
|
||||
make selfhost
|
||||
```
|
||||
|
||||
Edit `.env` with your production values (see [Configuration](#configuration) below), then:
|
||||
`make selfhost` automatically creates `.env`, generates a random `JWT_SECRET`, and starts all services via Docker Compose.
|
||||
|
||||
Once ready:
|
||||
|
||||
- **Frontend:** http://localhost:3000
|
||||
- **Backend API:** http://localhost:8080
|
||||
|
||||
<Callout>
|
||||
If you prefer running the Docker Compose steps manually: `cp .env.example .env`, edit `JWT_SECRET`, then `docker compose -f docker-compose.selfhost.yml up -d`.
|
||||
</Callout>
|
||||
|
||||
### Step 2 — Log In
|
||||
|
||||
Open http://localhost:3000. Enter any email address and use verification code **`888888`** to log in.
|
||||
|
||||
<Callout>
|
||||
This master code works in all non-production environments (when `APP_ENV` is not set to `production`). For production, configure an email provider — see [Configuration](#configuration) below.
|
||||
</Callout>
|
||||
|
||||
### Step 3 — Install CLI & Start Daemon
|
||||
|
||||
The daemon runs on your local machine (not inside Docker). It detects installed AI agent CLIs, registers them with the server, and executes tasks.
|
||||
|
||||
### a) Install the CLI and an AI agent
|
||||
|
||||
```bash
|
||||
# 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
|
||||
brew tap multica-ai/tap
|
||||
brew install multica
|
||||
```
|
||||
|
||||
For the frontend:
|
||||
You also need at least one AI agent CLI:
|
||||
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code) (`claude` on PATH)
|
||||
- [Codex](https://github.com/openai/codex) (`codex` on PATH)
|
||||
- OpenCode (`opencode` on PATH)
|
||||
- OpenClaw (`openclaw` on PATH)
|
||||
- Hermes (`hermes` on PATH)
|
||||
|
||||
### b) One-command setup
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm build
|
||||
|
||||
# Start the frontend (production mode)
|
||||
cd apps/web
|
||||
REMOTE_API_URL=http://localhost:8080 pnpm start
|
||||
multica setup self-host
|
||||
```
|
||||
|
||||
This automatically:
|
||||
1. Configures the CLI to connect to `localhost`
|
||||
2. Opens your browser for authentication
|
||||
3. Discovers your workspaces
|
||||
4. Starts the daemon in the background
|
||||
|
||||
For on-premise deployments with custom domains:
|
||||
|
||||
```bash
|
||||
multica setup self-host --server-url https://api.example.com --app-url https://app.example.com
|
||||
```
|
||||
|
||||
Verify the daemon is running:
|
||||
|
||||
```bash
|
||||
multica daemon status
|
||||
```
|
||||
|
||||
<Callout>
|
||||
Alternatively, configure step by step: `multica config set server_url http://localhost:8080 && multica config set app_url http://localhost:3000 && multica login && multica daemon start`
|
||||
</Callout>
|
||||
|
||||
### Step 4 — Verify & Start Using
|
||||
|
||||
1. Open your workspace at http://localhost:3000
|
||||
2. Navigate to **Settings → Runtimes** — you should see your machine listed
|
||||
3. Go to **Settings → Agents** and create a new agent
|
||||
4. Create an issue and assign it to your agent
|
||||
|
||||
## Stopping Services
|
||||
|
||||
```bash
|
||||
# Stop Docker Compose services
|
||||
make selfhost-stop
|
||||
|
||||
# Stop the local daemon
|
||||
multica daemon stop
|
||||
```
|
||||
|
||||
## Switching to Multica Cloud
|
||||
|
||||
If you've been self-hosting and want to switch your CLI to [Multica Cloud](https://multica.ai):
|
||||
|
||||
```bash
|
||||
multica setup
|
||||
```
|
||||
|
||||
This reconfigures the CLI for multica.ai, re-authenticates, and restarts the daemon. You will be prompted before overwriting the existing configuration.
|
||||
|
||||
<Callout>
|
||||
Your local Docker services are unaffected. Stop them separately if you no longer need them.
|
||||
</Callout>
|
||||
|
||||
## Rebuilding After Updates
|
||||
|
||||
```bash
|
||||
git pull
|
||||
make selfhost
|
||||
```
|
||||
|
||||
Migrations run automatically on backend startup.
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
All configuration is done via environment variables. Copy `.env.example` as a starting point.
|
||||
@@ -119,6 +216,21 @@ These are configured on each user's machine, not on the server:
|
||||
| `MULTICA_DAEMON_POLL_INTERVAL` | `3s` | How often the daemon polls for tasks |
|
||||
| `MULTICA_DAEMON_HEARTBEAT_INTERVAL` | `15s` | Heartbeat frequency |
|
||||
|
||||
Agent-specific overrides:
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `MULTICA_CLAUDE_PATH` | Custom path to the `claude` binary |
|
||||
| `MULTICA_CLAUDE_MODEL` | Override the Claude model used |
|
||||
| `MULTICA_CODEX_PATH` | Custom path to the `codex` binary |
|
||||
| `MULTICA_CODEX_MODEL` | Override the Codex model used |
|
||||
| `MULTICA_OPENCODE_PATH` | Custom path to the `opencode` binary |
|
||||
| `MULTICA_OPENCODE_MODEL` | Override the OpenCode model used |
|
||||
| `MULTICA_OPENCLAW_PATH` | Custom path to the `openclaw` binary |
|
||||
| `MULTICA_OPENCLAW_MODEL` | Override the OpenClaw model used |
|
||||
| `MULTICA_HERMES_PATH` | Custom path to the `hermes` binary |
|
||||
| `MULTICA_HERMES_MODEL` | Override the Hermes model used |
|
||||
|
||||
## Database Setup
|
||||
|
||||
Multica requires PostgreSQL 17 with the pgvector extension.
|
||||
@@ -151,6 +263,36 @@ Migrations must be run before starting the server:
|
||||
cd server && go run ./cmd/migrate up
|
||||
```
|
||||
|
||||
## Manual Setup (Without Docker Compose)
|
||||
|
||||
If you prefer to build and run services manually:
|
||||
|
||||
**Prerequisites:** Go 1.26+, Node.js 20+, pnpm 10.28+, PostgreSQL 17 with pgvector.
|
||||
|
||||
```bash
|
||||
# Start your PostgreSQL (or use: docker compose up -d postgres)
|
||||
|
||||
# Build the backend
|
||||
make build
|
||||
|
||||
# Run database migrations
|
||||
DATABASE_URL="your-database-url" ./server/bin/migrate up
|
||||
|
||||
# Start the backend server
|
||||
DATABASE_URL="your-database-url" PORT=8080 JWT_SECRET="your-secret" ./server/bin/server
|
||||
```
|
||||
|
||||
For the frontend:
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm build
|
||||
|
||||
# Start the frontend (production mode)
|
||||
cd apps/web
|
||||
REMOTE_API_URL=http://localhost:8080 pnpm start
|
||||
```
|
||||
|
||||
## Reverse Proxy
|
||||
|
||||
In production, put a reverse proxy in front of both the backend and frontend to handle TLS and routing.
|
||||
@@ -239,39 +381,6 @@ GET /health
|
||||
|
||||
Use this for load balancer health checks or monitoring.
|
||||
|
||||
## Setting Up the Agent Daemon
|
||||
|
||||
Each team member who wants to run AI agents locally needs to:
|
||||
|
||||
1. **Install the CLI**
|
||||
|
||||
```bash
|
||||
brew tap multica-ai/tap
|
||||
brew install multica-cli
|
||||
```
|
||||
|
||||
2. **Install an AI agent CLI** — at least one of:
|
||||
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code) (`claude` on PATH)
|
||||
- [Codex](https://github.com/openai/codex) (`codex` on PATH)
|
||||
|
||||
3. **Authenticate and start**
|
||||
|
||||
```bash
|
||||
# Point CLI to your server
|
||||
export MULTICA_APP_URL=https://app.example.com
|
||||
export MULTICA_SERVER_URL=wss://api.example.com/ws
|
||||
|
||||
# Login (opens browser)
|
||||
multica login
|
||||
|
||||
# Start the daemon
|
||||
multica daemon start
|
||||
```
|
||||
|
||||
> **Note:** Use `https://` and `wss://` for production deployments behind a TLS-terminating reverse proxy. For local or development deployments without TLS, use `http://` and `ws://` instead.
|
||||
|
||||
The daemon auto-detects installed agent CLIs and registers itself with the server.
|
||||
|
||||
## Upgrading
|
||||
|
||||
1. Pull the latest code or image
|
||||
|
||||
@@ -5,14 +5,13 @@ description: Assign your first task to an agent in under 5 minutes.
|
||||
|
||||
Once you have the CLI installed (or signed up for [Multica Cloud](https://multica.ai)), follow these steps to assign your first task to an agent.
|
||||
|
||||
## 1. Log in and start the daemon
|
||||
## 1. Set up and start the daemon
|
||||
|
||||
```bash
|
||||
multica login # Authenticate with your Multica account
|
||||
multica daemon start # Start the local agent runtime
|
||||
multica setup # Configure, authenticate, and start the daemon
|
||||
```
|
||||
|
||||
The daemon runs in the background and keeps your machine connected to Multica. It auto-detects agent CLIs (`claude`, `codex`, `openclaw`, `opencode`) available on your PATH.
|
||||
This configures the CLI, opens your browser for login, discovers your workspaces, and starts the agent daemon in the background. It auto-detects agent CLIs (`claude`, `codex`, `openclaw`, `opencode`) available on your PATH.
|
||||
|
||||
## 2. Verify your runtime
|
||||
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
function createWrapper() {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const { mockSendCode, mockVerifyCode, mockHydrateWorkspace } = vi.hoisted(
|
||||
() => ({
|
||||
@@ -66,7 +75,7 @@ describe("LoginPage", () => {
|
||||
});
|
||||
|
||||
it("renders login form with email input and continue button", () => {
|
||||
render(<LoginPage />);
|
||||
render(<LoginPage />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByText("Sign in to Multica")).toBeInTheDocument();
|
||||
expect(screen.getByText("Enter your email to get a login code")).toBeInTheDocument();
|
||||
@@ -78,7 +87,7 @@ describe("LoginPage", () => {
|
||||
|
||||
it("does not call sendCode when email is empty", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LoginPage />);
|
||||
render(<LoginPage />, { wrapper: createWrapper() });
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Continue" }));
|
||||
expect(mockSendCode).not.toHaveBeenCalled();
|
||||
@@ -87,7 +96,7 @@ describe("LoginPage", () => {
|
||||
it("calls sendCode with email on submit", async () => {
|
||||
mockSendCode.mockResolvedValueOnce(undefined);
|
||||
const user = userEvent.setup();
|
||||
render(<LoginPage />);
|
||||
render(<LoginPage />, { wrapper: createWrapper() });
|
||||
|
||||
await user.type(screen.getByLabelText("Email"), "test@multica.ai");
|
||||
await user.click(screen.getByRole("button", { name: "Continue" }));
|
||||
@@ -100,7 +109,7 @@ describe("LoginPage", () => {
|
||||
it("shows 'Sending code...' while submitting", async () => {
|
||||
mockSendCode.mockReturnValueOnce(new Promise(() => {}));
|
||||
const user = userEvent.setup();
|
||||
render(<LoginPage />);
|
||||
render(<LoginPage />, { wrapper: createWrapper() });
|
||||
|
||||
await user.type(screen.getByLabelText("Email"), "test@multica.ai");
|
||||
await user.click(screen.getByRole("button", { name: "Continue" }));
|
||||
@@ -113,7 +122,7 @@ describe("LoginPage", () => {
|
||||
it("shows verification code step after sending code", async () => {
|
||||
mockSendCode.mockResolvedValueOnce(undefined);
|
||||
const user = userEvent.setup();
|
||||
render(<LoginPage />);
|
||||
render(<LoginPage />, { wrapper: createWrapper() });
|
||||
|
||||
await user.type(screen.getByLabelText("Email"), "test@multica.ai");
|
||||
await user.click(screen.getByRole("button", { name: "Continue" }));
|
||||
@@ -126,7 +135,7 @@ describe("LoginPage", () => {
|
||||
it("shows error when sendCode fails", async () => {
|
||||
mockSendCode.mockRejectedValueOnce(new Error("Network error"));
|
||||
const user = userEvent.setup();
|
||||
render(<LoginPage />);
|
||||
render(<LoginPage />, { wrapper: createWrapper() });
|
||||
|
||||
await user.type(screen.getByLabelText("Email"), "test@multica.ai");
|
||||
await user.click(screen.getByRole("button", { name: "Continue" }));
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { Suspense, useEffect } from "react";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { useWorkspaceStore } from "@multica/core/workspace";
|
||||
import { setLoggedInCookie } from "@/features/auth/auth-cookie";
|
||||
import { LoginPage, validateCliCallback } from "@multica/views/auth";
|
||||
|
||||
@@ -16,6 +17,7 @@ function LoginPageContent() {
|
||||
|
||||
const cliCallbackRaw = searchParams.get("cli_callback");
|
||||
const cliState = searchParams.get("cli_state") || "";
|
||||
const platform = searchParams.get("platform");
|
||||
const nextUrl = searchParams.get("next") || "/issues";
|
||||
|
||||
// Already authenticated — redirect to dashboard (skip if CLI callback)
|
||||
@@ -30,14 +32,20 @@ function LoginPageContent() {
|
||||
? localStorage.getItem("multica_workspace_id")
|
||||
: null;
|
||||
|
||||
const handleSuccess = () => {
|
||||
const ws = useWorkspaceStore.getState().workspace;
|
||||
router.push(ws ? nextUrl : "/onboarding");
|
||||
};
|
||||
|
||||
return (
|
||||
<LoginPage
|
||||
onSuccess={() => router.push(nextUrl)}
|
||||
onSuccess={handleSuccess}
|
||||
google={
|
||||
googleClientId
|
||||
? {
|
||||
clientId: googleClientId,
|
||||
redirectUri: `${window.location.origin}/auth/callback`,
|
||||
state: platform === "desktop" ? "platform:desktop" : undefined,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
|
||||
23
apps/web/app/(auth)/onboarding/page.tsx
Normal file
23
apps/web/app/(auth)/onboarding/page.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { OnboardingWizard } from "@multica/views/onboarding";
|
||||
|
||||
export default function OnboardingPage() {
|
||||
const router = useRouter();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const isLoading = useAuthStore((s) => s.isLoading);
|
||||
|
||||
// Redirect to login if not authenticated
|
||||
useEffect(() => {
|
||||
if (!isLoading && !user) router.replace("/login");
|
||||
}, [isLoading, user, router]);
|
||||
|
||||
if (isLoading || !user) return null;
|
||||
|
||||
return (
|
||||
<OnboardingWizard onComplete={() => router.push("/issues")} />
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
loadingIndicator={<MulticaIcon className="size-6" />}
|
||||
searchSlot={<SearchTrigger />}
|
||||
extra={<><SearchCommand /><ChatWindow /><ChatFab /></>}
|
||||
onboardingPath="/onboarding"
|
||||
>
|
||||
{children}
|
||||
</DashboardLayout>
|
||||
|
||||
@@ -4,11 +4,11 @@ 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.",
|
||||
"Learn about Multica — multiplexed information and computing agent. An open-source project management platform for human + agent teams.",
|
||||
openGraph: {
|
||||
title: "About Multica",
|
||||
description:
|
||||
"The story behind Multica and why we're building AI-native task management.",
|
||||
"The story behind Multica and why we're building project management for human + agent teams.",
|
||||
url: "/about",
|
||||
},
|
||||
alternates: {
|
||||
|
||||
@@ -6,7 +6,7 @@ export const metadata: Metadata = {
|
||||
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",
|
||||
title: "Multica — Project Management for Human + Agent Teams",
|
||||
description:
|
||||
"Manage your human + agent workforce in one place.",
|
||||
url: "/homepage",
|
||||
|
||||
@@ -30,7 +30,7 @@ const jsonLd = {
|
||||
applicationCategory: "ProjectManagement",
|
||||
operatingSystem: "Web",
|
||||
description:
|
||||
"AI-native task management platform that turns coding agents into real teammates.",
|
||||
"Open-source project management platform that turns coding agents into real teammates.",
|
||||
offers: {
|
||||
"@type": "Offer",
|
||||
price: "0",
|
||||
|
||||
@@ -3,12 +3,12 @@ import { MulticaLanding } from "@/features/landing/components/multica-landing";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
absolute: "Multica — AI-Native Task Management",
|
||||
absolute: "Multica — Project Management for Human + Agent Teams",
|
||||
},
|
||||
description:
|
||||
"Open-source platform that turns coding agents into real teammates. Assign tasks, track progress, compound skills.",
|
||||
openGraph: {
|
||||
title: "Multica — AI-Native Task Management",
|
||||
title: "Multica — Project Management for Human + Agent Teams",
|
||||
description:
|
||||
"Manage your human + agent workforce in one place.",
|
||||
url: "/",
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
import { Suspense, useEffect, useState } from "react";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { useWorkspaceStore } from "@multica/core/workspace";
|
||||
import { workspaceKeys } from "@multica/core/workspace/queries";
|
||||
import { api } from "@multica/core/api";
|
||||
import {
|
||||
Card,
|
||||
@@ -12,14 +14,17 @@ import {
|
||||
CardDescription,
|
||||
CardContent,
|
||||
} from "@multica/ui/components/ui/card";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
function CallbackContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const qc = useQueryClient();
|
||||
const loginWithGoogle = useAuthStore((s) => s.loginWithGoogle);
|
||||
const hydrateWorkspace = useWorkspaceStore((s) => s.hydrateWorkspace);
|
||||
const [error, setError] = useState("");
|
||||
const [desktopToken, setDesktopToken] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const code = searchParams.get("code");
|
||||
@@ -34,19 +39,63 @@ function CallbackContent() {
|
||||
return;
|
||||
}
|
||||
|
||||
const state = searchParams.get("state");
|
||||
const isDesktop = state === "platform:desktop";
|
||||
|
||||
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 (isDesktop) {
|
||||
// Desktop flow: exchange code for token, then redirect via deep link
|
||||
api
|
||||
.googleLogin(code, redirectUri)
|
||||
.then(({ token }) => {
|
||||
setDesktopToken(token);
|
||||
window.location.href = `multica://auth/callback?token=${encodeURIComponent(token)}`;
|
||||
})
|
||||
.catch((err) => {
|
||||
setError(err instanceof Error ? err.message : "Login failed");
|
||||
});
|
||||
} else {
|
||||
// Normal web flow
|
||||
loginWithGoogle(code, redirectUri)
|
||||
.then(async () => {
|
||||
const wsList = await api.listWorkspaces();
|
||||
qc.setQueryData(workspaceKeys.list(), wsList);
|
||||
const lastWsId = localStorage.getItem("multica_workspace_id");
|
||||
const ws = await hydrateWorkspace(wsList, lastWsId);
|
||||
router.push(ws ? "/issues" : "/onboarding");
|
||||
})
|
||||
.catch((err) => {
|
||||
setError(err instanceof Error ? err.message : "Login failed");
|
||||
});
|
||||
}
|
||||
}, [searchParams, loginWithGoogle, hydrateWorkspace, router, qc]);
|
||||
|
||||
if (desktopToken) {
|
||||
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">Opening Multica</CardTitle>
|
||||
<CardDescription>
|
||||
You should see a prompt to open the Multica desktop app. If
|
||||
nothing happens, click the button below.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex justify-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
window.location.href = `multica://auth/callback?token=${encodeURIComponent(desktopToken)}`;
|
||||
}}
|
||||
>
|
||||
Open Multica Desktop
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
|
||||
@@ -22,7 +22,7 @@ export const viewport: Viewport = {
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL("https://www.multica.ai"),
|
||||
title: {
|
||||
default: "Multica — AI-Native Task Management",
|
||||
default: "Multica — Project Management for Human + Agent Teams",
|
||||
template: "%s | Multica",
|
||||
},
|
||||
description:
|
||||
|
||||
@@ -7,11 +7,28 @@ import {
|
||||
clearLoggedInCookie,
|
||||
} from "@/features/auth/auth-cookie";
|
||||
|
||||
// Legacy token in localStorage → keep this session in token mode so users who
|
||||
// logged in before the cookie-auth migration stay authed. They migrate to
|
||||
// cookie mode on their next logout/login cycle (logout clears multica_token).
|
||||
// Sunset: once telemetry shows <1% of sessions still carry multica_token,
|
||||
// delete this branch and hard-code `cookieAuth` — the localStorage token is
|
||||
// XSS-exposed and is the exact thing the cookie migration exists to remove.
|
||||
function hasLegacyToken(): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
try {
|
||||
return Boolean(window.localStorage.getItem("multica_token"));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function WebProviders({ children }: { children: React.ReactNode }) {
|
||||
const cookieAuth = !hasLegacyToken();
|
||||
return (
|
||||
<CoreProvider
|
||||
apiBaseUrl={process.env.NEXT_PUBLIC_API_URL}
|
||||
wsUrl={process.env.NEXT_PUBLIC_WS_URL}
|
||||
cookieAuth={cookieAuth}
|
||||
onLogin={setLoggedInCookie}
|
||||
onLogout={clearLoggedInCookie}
|
||||
>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
@@ -52,6 +53,8 @@ export function LandingHero() {
|
||||
GitHub
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<InstallCommand />
|
||||
</div>
|
||||
|
||||
<div className="mt-10 flex items-center justify-center gap-8">
|
||||
@@ -87,6 +90,64 @@ export function LandingHero() {
|
||||
);
|
||||
}
|
||||
|
||||
const INSTALL_COMMAND =
|
||||
"curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash";
|
||||
|
||||
function InstallCommand() {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(INSTALL_COMMAND);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="mx-auto mt-6 max-w-fit">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className="group flex items-center gap-3 rounded-lg border border-white/10 bg-white/5 px-4 py-2.5 font-mono text-[13px] text-white/70 backdrop-blur-sm transition-colors hover:border-white/20 hover:bg-white/8 hover:text-white/90"
|
||||
>
|
||||
<span className="text-white/40">$</span>
|
||||
<span className="select-all">{INSTALL_COMMAND}</span>
|
||||
<span className="ml-1 flex size-5 shrink-0 items-center justify-center text-white/40 transition-colors group-hover:text-white/70">
|
||||
{copied ? (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="size-3.5 text-green-400"
|
||||
>
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="size-3.5"
|
||||
>
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LandingBackdrop() {
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-0">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
|
||||
export const githubUrl = "https://github.com/multica-ai/multica";
|
||||
export const twitterUrl = "https://x.com/multica_hq";
|
||||
export const twitterUrl = "https://x.com/MulticaAI";
|
||||
|
||||
export function GitHubMark({ className }: { className?: string }) {
|
||||
return (
|
||||
|
||||
@@ -126,7 +126,7 @@ export const en: LandingDict = {
|
||||
{
|
||||
title: "Install the CLI & connect your machine",
|
||||
description:
|
||||
"Run multica login to authenticate, then multica daemon start. The daemon auto-detects Claude Code, Codex, OpenClaw, and OpenCode on your machine \u2014 plug in and go.",
|
||||
"Run multica setup to configure, authenticate, and start the daemon. It auto-detects Claude Code, Codex, OpenClaw, and OpenCode on your machine \u2014 plug in and go.",
|
||||
},
|
||||
{
|
||||
title: "Create your first agent",
|
||||
@@ -230,7 +230,7 @@ export const en: LandingDict = {
|
||||
links: [
|
||||
{ label: "Documentation", href: githubUrl },
|
||||
{ label: "API", href: githubUrl },
|
||||
{ label: "X (Twitter)", href: "https://x.com/multica_hq" },
|
||||
{ label: "X (Twitter)", href: "https://x.com/MulticaAI" },
|
||||
],
|
||||
},
|
||||
company: {
|
||||
@@ -277,6 +277,87 @@ export const en: LandingDict = {
|
||||
fixes: "Bug Fixes",
|
||||
},
|
||||
entries: [
|
||||
{
|
||||
version: "0.1.28",
|
||||
date: "2026-04-13",
|
||||
title: "Windows Support, Auth & Onboarding",
|
||||
changes: [],
|
||||
features: [
|
||||
"Windows support — CLI installation, daemon, and release builds",
|
||||
"Auth migrated to HttpOnly Cookie with WebSocket Origin whitelist",
|
||||
"Full-screen onboarding wizard for new workspaces",
|
||||
"Resizable Master Agent chat window with session history improvements",
|
||||
"Token usage log scanning for OpenCode, OpenClaw, and Hermes runtimes",
|
||||
],
|
||||
fixes: [
|
||||
"WebSocket first-message authentication security fix",
|
||||
"Content-Security-Policy response header",
|
||||
"Sub-issue progress computed from database instead of paginated client cache",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.1.27",
|
||||
date: "2026-04-12",
|
||||
title: "One-Click Setup, Self-Hosting & Stability",
|
||||
changes: [],
|
||||
features: [
|
||||
"One-click install & setup — `curl | bash` installs CLI, `--with-server` bootstraps full self-hosting, `multica setup` configures your environment",
|
||||
"Self-hosted storage — local file fallback when S3 is unavailable, plus custom S3 endpoint support (MinIO)",
|
||||
"Inline property editing (priority, status, lead) on project list page",
|
||||
],
|
||||
improvements: [
|
||||
"Stale agent tasks auto-swept; agent live card shows immediately without waiting for first message",
|
||||
"Comment attachments uploaded via CLI now visible in the UI",
|
||||
"Pinned items scoped per user with fixed sidebar pin action",
|
||||
],
|
||||
fixes: [
|
||||
"Workspace ownership checks on daemon API routes and attachment uploads",
|
||||
"Markdown sanitizer preserves code blocks from HTML entity escaping",
|
||||
"Next.js upgraded to ^16.2.3 for CVE-2026-23869",
|
||||
"OpenClaw backend rewritten to match actual CLI interface",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.1.24",
|
||||
date: "2026-04-11",
|
||||
title: "Security & Notifications",
|
||||
changes: [],
|
||||
features: [
|
||||
"Parent issue subscribers notified on sub-issue changes",
|
||||
"CLI `--project` filter for issue list",
|
||||
],
|
||||
improvements: [
|
||||
"Meta-skill workflow defers to agent Skills instead of hardcoded logic",
|
||||
],
|
||||
fixes: [
|
||||
"Workspace ownership checks on all daemon API routes",
|
||||
"Workspace ownership validation for attachment uploads and queries",
|
||||
"Reply mentions no longer inherit parent thread's agent mentions",
|
||||
"Agent comment creation missing workspace ID",
|
||||
"Self-hosting Docker build failures (file permissions, CRLF, missing deps)",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.1.23",
|
||||
date: "2026-04-11",
|
||||
title: "Pinning, Cmd+K & Projects",
|
||||
changes: [],
|
||||
features: [
|
||||
"Pin issues and projects to sidebar with drag-and-drop reordering",
|
||||
"Cmd+K command palette — recent issues, page navigation, and project search",
|
||||
"Project detail sidebar with properties panel (replaces overview tab)",
|
||||
"Project filter in Issues tab",
|
||||
"Project completion progress in project list",
|
||||
"Auto-fill project when creating issue via 'C' shortcut on project page",
|
||||
"Assignee dropdown sorted by user's assignment frequency",
|
||||
],
|
||||
fixes: [
|
||||
"Markdown XSS — sanitize HTML rendering in comments with rehype-sanitize and server-side bluemonday",
|
||||
"Project kanban issue counts incorrect",
|
||||
"Self-hosting Docker build missing tsconfig dependencies",
|
||||
"Cmd+K requiring double ESC to close",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.1.22",
|
||||
date: "2026-04-10",
|
||||
|
||||
@@ -126,7 +126,7 @@ export const zh: LandingDict = {
|
||||
{
|
||||
title: "\u5b89\u88c5 CLI \u5e76\u8fde\u63a5\u4f60\u7684\u673a\u5668",
|
||||
description:
|
||||
"\u8fd0\u884c multica login \u8fdb\u884c\u8ba4\u8bc1\uff0c\u7136\u540e multica daemon start\u3002\u5b88\u62a4\u8fdb\u7a0b\u81ea\u52a8\u68c0\u6d4b\u4f60\u673a\u5668\u4e0a\u7684 Claude Code\u3001Codex\u3001OpenClaw \u548c OpenCode\u2014\u2014\u63d2\u4e0a\u5c31\u7528\u3002",
|
||||
"运行 multica setup 一键完成配置、认证和启动。守护进程自动检测你机器上的 Claude Code、Codex、OpenClaw 和 OpenCode——插上就用。",
|
||||
},
|
||||
{
|
||||
title: "\u521b\u5efa\u4f60\u7684\u7b2c\u4e00\u4e2a Agent",
|
||||
@@ -230,7 +230,7 @@ export const zh: LandingDict = {
|
||||
links: [
|
||||
{ label: "\u6587\u6863", href: githubUrl },
|
||||
{ label: "API", href: githubUrl },
|
||||
{ label: "X (Twitter)", href: "https://x.com/multica_hq" },
|
||||
{ label: "X (Twitter)", href: "https://x.com/MulticaAI" },
|
||||
],
|
||||
},
|
||||
company: {
|
||||
@@ -277,6 +277,87 @@ export const zh: LandingDict = {
|
||||
fixes: "问题修复",
|
||||
},
|
||||
entries: [
|
||||
{
|
||||
version: "0.1.28",
|
||||
date: "2026-04-13",
|
||||
title: "Windows 支持、认证与引导",
|
||||
changes: [],
|
||||
features: [
|
||||
"Windows 支持——CLI 安装、Daemon 运行和发布构建",
|
||||
"认证迁移至 HttpOnly Cookie,WebSocket 新增 Origin 白名单",
|
||||
"新工作区全屏引导向导",
|
||||
"Master Agent 聊天窗口可调整大小,会话历史体验优化",
|
||||
"OpenCode、OpenClaw 和 Hermes 运行时 Token 用量日志扫描",
|
||||
],
|
||||
fixes: [
|
||||
"WebSocket 首条消息认证安全修复",
|
||||
"新增 Content-Security-Policy 响应头",
|
||||
"子 Issue 进度改为从数据库计算而非分页客户端缓存",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.1.27",
|
||||
date: "2026-04-12",
|
||||
title: "一键安装、自部署与稳定性",
|
||||
changes: [],
|
||||
features: [
|
||||
"一键安装与配置——`curl | bash` 安装 CLI,`--with-server` 完整自部署,`multica setup` 配置连接环境",
|
||||
"自部署存储——无 S3 时本地文件存储回退,支持自定义 S3 端点(MinIO)",
|
||||
"项目列表页支持行内编辑属性(优先级、状态、负责人)",
|
||||
],
|
||||
improvements: [
|
||||
"过期 Agent 任务自动清扫;执行卡片立即显示,无需等待首条消息",
|
||||
"通过 CLI 上传的评论附件现在可在 UI 中显示",
|
||||
"置顶项按用户隔离,修复侧边栏置顶操作",
|
||||
],
|
||||
fixes: [
|
||||
"Daemon API 路由和附件上传新增工作区所有权校验",
|
||||
"Markdown 清洗器保留代码块不被 HTML 实体转义",
|
||||
"Next.js 升级至 ^16.2.3 修复 CVE-2026-23869",
|
||||
"OpenClaw 后端重写以匹配实际 CLI 接口",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.1.24",
|
||||
date: "2026-04-11",
|
||||
title: "安全加固与通知",
|
||||
changes: [],
|
||||
features: [
|
||||
"子 Issue 变更时通知父 Issue 的订阅者",
|
||||
"CLI `--project` 筛选 Issue 列表",
|
||||
],
|
||||
improvements: [
|
||||
"Meta-skill 工作流改为委托 Agent Skills 而非硬编码逻辑",
|
||||
],
|
||||
fixes: [
|
||||
"Daemon API 路由新增工作区所有权校验",
|
||||
"附件上传和查询新增工作区所有权验证",
|
||||
"回复评论不再继承父级线程的 Agent 提及",
|
||||
"Agent 创建评论缺少 workspace ID",
|
||||
"自部署 Docker 构建问题修复(文件权限、CRLF 换行、缺失依赖)",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.1.23",
|
||||
date: "2026-04-11",
|
||||
title: "置顶、Cmd+K 与项目增强",
|
||||
changes: [],
|
||||
features: [
|
||||
"Issue 和项目置顶到侧边栏,支持拖拽排序",
|
||||
"Cmd+K 命令面板——最近访问的 Issue、页面导航、项目搜索",
|
||||
"项目详情侧边栏属性面板(替代原概览标签页)",
|
||||
"Issues 列表新增项目筛选",
|
||||
"项目列表显示完成进度",
|
||||
"在项目页按 'C' 创建 Issue 时自动填充项目",
|
||||
"指派人下拉按用户分配频率排序",
|
||||
],
|
||||
fixes: [
|
||||
"Markdown XSS 漏洞——评论渲染增加 rehype-sanitize 和服务端 bluemonday 清洗",
|
||||
"项目看板 Issue 计数不正确",
|
||||
"自部署 Docker 构建缺少 tsconfig 依赖",
|
||||
"Cmd+K 需要按两次 ESC 才能关闭",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.1.22",
|
||||
date: "2026-04-10",
|
||||
|
||||
@@ -12,15 +12,15 @@
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@multica/core": "workspace:*",
|
||||
"@multica/ui": "workspace:*",
|
||||
"@multica/views": "workspace:*",
|
||||
"@base-ui/react": "^1.3.0",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@emoji-mart/data": "^1.2.1",
|
||||
"@floating-ui/dom": "^1.7.6",
|
||||
"@multica/core": "workspace:*",
|
||||
"@multica/ui": "workspace:*",
|
||||
"@multica/views": "workspace:*",
|
||||
"@tanstack/react-query": "^5.96.2",
|
||||
"@tanstack/react-query-devtools": "^5.96.2",
|
||||
"@tiptap/extension-code-block-lowlight": "^3.22.1",
|
||||
@@ -43,13 +43,14 @@
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"dotenv": "^17.4.1",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"emoji-mart": "^5.6.0",
|
||||
"input-otp": "^1.4.2",
|
||||
"linkify-it": "^5.0.0",
|
||||
"lowlight": "^3.3.0",
|
||||
"lucide-react": "catalog:",
|
||||
"next": "^16.1.6",
|
||||
"next": "^16.2.3",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "catalog:",
|
||||
"react-day-picker": "^9.14.0",
|
||||
|
||||
@@ -75,14 +75,9 @@ export const mockAuthValue: Record<string, any> = {
|
||||
isLoading: false,
|
||||
login: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
workspaces: [mockWorkspace],
|
||||
switchWorkspace: vi.fn(),
|
||||
createWorkspace: vi.fn(),
|
||||
updateWorkspace: vi.fn(),
|
||||
updateCurrentUser: vi.fn(),
|
||||
leaveWorkspace: vi.fn(),
|
||||
deleteWorkspace: vi.fn(),
|
||||
refreshWorkspaces: vi.fn(),
|
||||
getMemberName: (userId: string) => {
|
||||
const m = mockMembers.find((m) => m.user_id === userId);
|
||||
return m?.name ?? "Unknown";
|
||||
|
||||
@@ -61,6 +61,7 @@ services:
|
||||
dockerfile: Dockerfile.web
|
||||
args:
|
||||
REMOTE_API_URL: http://backend:8080
|
||||
NEXT_PUBLIC_GOOGLE_CLIENT_ID: ${NEXT_PUBLIC_GOOGLE_CLIENT_ID:-}
|
||||
depends_on:
|
||||
- backend
|
||||
ports:
|
||||
|
||||
13
e2e/env.ts
Normal file
13
e2e/env.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { existsSync } from "fs";
|
||||
import { resolve } from "path";
|
||||
import { config } from "dotenv";
|
||||
|
||||
const envCandidates = [".env.worktree", ".env"];
|
||||
|
||||
for (const filename of envCandidates) {
|
||||
const path = resolve(process.cwd(), filename);
|
||||
if (existsSync(path)) {
|
||||
config({ path });
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
* Uses raw fetch so E2E tests have zero build-time coupling to the web app.
|
||||
*/
|
||||
|
||||
import "./env";
|
||||
import pg from "pg";
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? `http://localhost:${process.env.PORT ?? "8080"}`;
|
||||
@@ -21,39 +22,43 @@ export class TestApiClient {
|
||||
private createdIssueIds: string[] = [];
|
||||
|
||||
async login(email: string, name: string) {
|
||||
// Step 1: Send verification code
|
||||
const sendRes = await fetch(`${API_BASE}/auth/send-code`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
if (!sendRes.ok) {
|
||||
// Rate limited — code already sent recently, read it from DB
|
||||
if (sendRes.status !== 429) {
|
||||
throw new Error(`send-code failed: ${sendRes.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Read code from database
|
||||
const client = new pg.Client(DATABASE_URL);
|
||||
await client.connect();
|
||||
try {
|
||||
// Keep each E2E login isolated so previous test runs do not trip the
|
||||
// per-email send-code rate limit.
|
||||
await client.query("DELETE FROM verification_code WHERE email = $1", [email]);
|
||||
|
||||
// Step 1: Send verification code
|
||||
const sendRes = await fetch(`${API_BASE}/auth/send-code`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
if (!sendRes.ok) {
|
||||
throw new Error(`send-code failed: ${sendRes.status}`);
|
||||
}
|
||||
|
||||
// Step 2: Read code from database
|
||||
const result = await client.query(
|
||||
"SELECT code FROM verification_code WHERE email = $1 AND used = FALSE AND expires_at > now() ORDER BY created_at DESC LIMIT 1",
|
||||
[email]
|
||||
[email],
|
||||
);
|
||||
if (result.rows.length === 0) {
|
||||
throw new Error(`No verification code found for ${email}`);
|
||||
}
|
||||
const code = result.rows[0].code;
|
||||
|
||||
// Step 3: Verify code to get JWT
|
||||
const verifyRes = await fetch(`${API_BASE}/auth/verify-code`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, code }),
|
||||
body: JSON.stringify({ email, code: result.rows[0].code }),
|
||||
});
|
||||
if (!verifyRes.ok) {
|
||||
throw new Error(`verify-code failed: ${verifyRes.status}`);
|
||||
}
|
||||
const data = await verifyRes.json();
|
||||
|
||||
this.token = data.token;
|
||||
|
||||
// Update user name if needed
|
||||
@@ -64,6 +69,8 @@ export class TestApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
await client.query("DELETE FROM verification_code WHERE email = $1", [email]);
|
||||
|
||||
return data;
|
||||
} finally {
|
||||
await client.end();
|
||||
|
||||
@@ -11,11 +11,14 @@ test.describe("Issues", () => {
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
await api.cleanup();
|
||||
if (api) {
|
||||
await api.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("issues page loads with board view", async ({ page }) => {
|
||||
await expect(page.locator("text=All Issues")).toBeVisible();
|
||||
await api.createIssue("E2E Board View " + Date.now());
|
||||
await page.reload();
|
||||
|
||||
// Board columns should be visible
|
||||
await expect(page.locator("text=Backlog")).toBeVisible();
|
||||
@@ -23,29 +26,36 @@ test.describe("Issues", () => {
|
||||
await expect(page.locator("text=In Progress")).toBeVisible();
|
||||
});
|
||||
|
||||
test("can switch between board and list view", async ({ page }) => {
|
||||
await expect(page.locator("text=All Issues")).toBeVisible();
|
||||
test("can switch from board to list view", async ({ page }) => {
|
||||
const title = "E2E List Switch " + Date.now();
|
||||
await api.createIssue(title);
|
||||
await page.reload();
|
||||
await expect(page.locator("text=Backlog")).toBeVisible();
|
||||
|
||||
// Switch to list view
|
||||
await page.click("text=List");
|
||||
await expect(page.locator("text=All Issues")).toBeVisible();
|
||||
|
||||
// Switch back to board view
|
||||
await page.click("text=Board");
|
||||
await expect(page.locator("text=Backlog")).toBeVisible();
|
||||
await expect(page.getByText(title)).toBeVisible();
|
||||
});
|
||||
|
||||
test("can create a new issue", async ({ page }) => {
|
||||
await page.click("text=New Issue");
|
||||
const newIssueButton = page.getByRole("button", { name: "New Issue" });
|
||||
await expect(newIssueButton).toBeVisible();
|
||||
await newIssueButton.click();
|
||||
|
||||
const title = "E2E Created " + Date.now();
|
||||
await page.fill('input[placeholder="Issue title..."]', title);
|
||||
await page.click("text=Create");
|
||||
const titleInput = page.getByRole("textbox", { name: "Issue title" });
|
||||
await expect(titleInput).toBeVisible();
|
||||
await titleInput.fill(title);
|
||||
await page.getByRole("button", { name: "Create Issue" }).click();
|
||||
|
||||
// New issue should appear on the page
|
||||
await expect(page.locator(`text=${title}`).first()).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
await expect(page.getByText("Issue created")).toBeVisible({ timeout: 10000 });
|
||||
await expect(
|
||||
page.getByRole("region", { name: /Notifications/ }).getByText(title),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "View issue" }).click();
|
||||
await page.waitForURL(/\/issues\/[\w-]+/);
|
||||
await expect(page.locator("text=Properties")).toBeVisible();
|
||||
});
|
||||
|
||||
test("can navigate to issue detail page", async ({ page }) => {
|
||||
@@ -54,7 +64,6 @@ test.describe("Issues", () => {
|
||||
|
||||
// Reload to see the new issue
|
||||
await page.reload();
|
||||
await expect(page.locator("text=All Issues")).toBeVisible();
|
||||
|
||||
// Navigate to the issue detail
|
||||
const issueLink = page.locator(`a[href="/issues/${issue.id}"]`);
|
||||
@@ -71,18 +80,15 @@ test.describe("Issues", () => {
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("can cancel issue creation", async ({ page }) => {
|
||||
await page.click("text=New Issue");
|
||||
test("can dismiss issue creation", async ({ page }) => {
|
||||
await page.getByRole("button", { name: "New Issue" }).click();
|
||||
|
||||
await expect(
|
||||
page.locator('input[placeholder="Issue title..."]'),
|
||||
).toBeVisible();
|
||||
const titleInput = page.getByRole("textbox", { name: "Issue title" });
|
||||
await expect(titleInput).toBeVisible();
|
||||
|
||||
await page.click("text=Cancel");
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await expect(
|
||||
page.locator('input[placeholder="Issue title..."]'),
|
||||
).not.toBeVisible();
|
||||
await expect(page.locator("text=New Issue")).toBeVisible();
|
||||
await expect(titleInput).not.toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "New Issue" })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"scripts": {
|
||||
"dev:web": "turbo dev --filter=@multica/web",
|
||||
"dev:desktop": "turbo dev --filter=@multica/desktop",
|
||||
"dev:desktop:remote": "pnpm --filter @multica/desktop dev:remote",
|
||||
"build": "turbo build",
|
||||
"typecheck": "turbo typecheck",
|
||||
"test": "turbo test",
|
||||
|
||||
35
packages/core/api/client.test.ts
Normal file
35
packages/core/api/client.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { ApiClient, ApiError } from "./client";
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("ApiClient", () => {
|
||||
it("preserves HTTP status on failed requests", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ error: "workspace slug already exists" }), {
|
||||
status: 409,
|
||||
statusText: "Conflict",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const client = new ApiClient("https://api.example.test");
|
||||
|
||||
try {
|
||||
await client.createWorkspace({ name: "Test", slug: "test" });
|
||||
throw new Error("expected createWorkspace to fail");
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(ApiError);
|
||||
expect(error).toMatchObject({
|
||||
message: "workspace slug already exists",
|
||||
status: 409,
|
||||
statusText: "Conflict",
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
UpdateIssueRequest,
|
||||
ListIssuesResponse,
|
||||
SearchIssuesResponse,
|
||||
SearchProjectsResponse,
|
||||
UpdateMeRequest,
|
||||
CreateMemberRequest,
|
||||
UpdateMemberRequest,
|
||||
@@ -45,8 +46,13 @@ import type {
|
||||
CreateProjectRequest,
|
||||
UpdateProjectRequest,
|
||||
ListProjectsResponse,
|
||||
PinnedItem,
|
||||
CreatePinRequest,
|
||||
PinnedItemType,
|
||||
ReorderPinsRequest,
|
||||
} from "../types";
|
||||
import { type Logger, noopLogger } from "../logger";
|
||||
import { createRequestId } from "../utils";
|
||||
|
||||
export interface ApiClientOptions {
|
||||
logger?: Logger;
|
||||
@@ -58,6 +64,18 @@ export interface LoginResponse {
|
||||
user: User;
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
readonly status: number;
|
||||
readonly statusText: string;
|
||||
|
||||
constructor(message: string, status: number, statusText: string) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
this.status = status;
|
||||
this.statusText = statusText;
|
||||
}
|
||||
}
|
||||
|
||||
export class ApiClient {
|
||||
private baseUrl: string;
|
||||
private token: string | null = null;
|
||||
@@ -71,6 +89,10 @@ export class ApiClient {
|
||||
this.logger = options?.logger ?? noopLogger;
|
||||
}
|
||||
|
||||
getBaseUrl(): string {
|
||||
return this.baseUrl;
|
||||
}
|
||||
|
||||
setToken(token: string | null) {
|
||||
this.token = token;
|
||||
}
|
||||
@@ -79,10 +101,20 @@ export class ApiClient {
|
||||
this.workspaceId = id;
|
||||
}
|
||||
|
||||
private readCsrfToken(): string | null {
|
||||
if (typeof document === "undefined") return null;
|
||||
const match = document.cookie
|
||||
.split("; ")
|
||||
.find((c) => c.startsWith("multica_csrf="));
|
||||
return match ? match.split("=")[1] ?? null : null;
|
||||
}
|
||||
|
||||
private authHeaders(): Record<string, string> {
|
||||
const headers: Record<string, string> = {};
|
||||
if (this.token) headers["Authorization"] = `Bearer ${this.token}`;
|
||||
if (this.workspaceId) headers["X-Workspace-ID"] = this.workspaceId;
|
||||
const csrf = this.readCsrfToken();
|
||||
if (csrf) headers["X-CSRF-Token"] = csrf;
|
||||
return headers;
|
||||
}
|
||||
|
||||
@@ -103,7 +135,7 @@ export class ApiClient {
|
||||
}
|
||||
|
||||
private async fetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const rid = crypto.randomUUID().slice(0, 8);
|
||||
const rid = createRequestId();
|
||||
const start = Date.now();
|
||||
const method = init?.method ?? "GET";
|
||||
|
||||
@@ -127,7 +159,7 @@ export class ApiClient {
|
||||
const message = await this.parseErrorMessage(res, `API error: ${res.status} ${res.statusText}`);
|
||||
const logLevel = res.status === 404 ? "warn" : "error";
|
||||
this.logger[logLevel](`← ${res.status} ${path}`, { rid, duration: `${Date.now() - start}ms`, error: message });
|
||||
throw new Error(message);
|
||||
throw new ApiError(message, res.status, res.statusText);
|
||||
}
|
||||
|
||||
this.logger.info(`← ${res.status} ${path}`, { rid, duration: `${Date.now() - start}ms` });
|
||||
@@ -162,6 +194,10 @@ export class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async logout(): Promise<void> {
|
||||
await this.fetch("/auth/logout", { method: "POST" });
|
||||
}
|
||||
|
||||
async getMe(): Promise<User> {
|
||||
return this.fetch("/api/me");
|
||||
}
|
||||
@@ -197,6 +233,14 @@ export class ApiClient {
|
||||
return this.fetch(`/api/issues/search?${search}`, params.signal ? { signal: params.signal } : undefined);
|
||||
}
|
||||
|
||||
async searchProjects(params: { q: string; limit?: number; offset?: number; include_closed?: boolean; signal?: AbortSignal }): Promise<SearchProjectsResponse> {
|
||||
const search = new URLSearchParams({ q: params.q });
|
||||
if (params.limit !== undefined) search.set("limit", String(params.limit));
|
||||
if (params.offset !== undefined) search.set("offset", String(params.offset));
|
||||
if (params.include_closed) search.set("include_closed", "true");
|
||||
return this.fetch(`/api/projects/search?${search}`, params.signal ? { signal: params.signal } : undefined);
|
||||
}
|
||||
|
||||
async getIssue(id: string): Promise<Issue> {
|
||||
return this.fetch(`/api/issues/${id}`);
|
||||
}
|
||||
@@ -221,6 +265,10 @@ export class ApiClient {
|
||||
return this.fetch(`/api/issues/${id}/children`);
|
||||
}
|
||||
|
||||
async getChildIssueProgress(): Promise<{ progress: { parent_issue_id: string; total: number; done: number }[] }> {
|
||||
return this.fetch("/api/issues/child-progress");
|
||||
}
|
||||
|
||||
async deleteIssue(id: string): Promise<void> {
|
||||
await this.fetch(`/api/issues/${id}`, { method: "DELETE" });
|
||||
}
|
||||
@@ -419,7 +467,7 @@ export class ApiClient {
|
||||
}
|
||||
|
||||
async listTaskMessages(taskId: string): Promise<TaskMessagePayload[]> {
|
||||
return this.fetch(`/api/daemon/tasks/${taskId}/messages`);
|
||||
return this.fetch(`/api/tasks/${taskId}/messages`);
|
||||
}
|
||||
|
||||
async listTasksByIssue(issueId: string): Promise<AgentTask[]> {
|
||||
@@ -597,7 +645,7 @@ export class ApiClient {
|
||||
if (opts?.issueId) formData.append("issue_id", opts.issueId);
|
||||
if (opts?.commentId) formData.append("comment_id", opts.commentId);
|
||||
|
||||
const rid = crypto.randomUUID().slice(0, 8);
|
||||
const rid = createRequestId();
|
||||
const start = Date.now();
|
||||
this.logger.info("→ POST /api/upload-file", { rid });
|
||||
|
||||
@@ -693,4 +741,27 @@ export class ApiClient {
|
||||
async deleteProject(id: string): Promise<void> {
|
||||
await this.fetch(`/api/projects/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
// Pins
|
||||
async listPins(): Promise<PinnedItem[]> {
|
||||
return this.fetch("/api/pins");
|
||||
}
|
||||
|
||||
async createPin(data: CreatePinRequest): Promise<PinnedItem> {
|
||||
return this.fetch("/api/pins", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async deletePin(itemType: PinnedItemType, itemId: string): Promise<void> {
|
||||
await this.fetch(`/api/pins/${itemType}/${itemId}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
async reorderPins(data: ReorderPinsRequest): Promise<void> {
|
||||
await this.fetch("/api/pins/reorder", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export { ApiClient } from "./client";
|
||||
export { ApiClient, ApiError } from "./client";
|
||||
export type { ApiClientOptions } from "./client";
|
||||
export { WSClient } from "./ws-client";
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ export class WSClient {
|
||||
private baseUrl: string;
|
||||
private token: string | null = null;
|
||||
private workspaceId: string | null = null;
|
||||
private cookieAuth = false;
|
||||
private handlers = new Map<WSEventType, Set<EventHandler>>();
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private hasConnectedBefore = false;
|
||||
@@ -15,40 +16,45 @@ export class WSClient {
|
||||
private anyHandlers = new Set<(msg: WSMessage) => void>();
|
||||
private logger: Logger;
|
||||
|
||||
constructor(url: string, options?: { logger?: Logger }) {
|
||||
constructor(url: string, options?: { logger?: Logger; cookieAuth?: boolean }) {
|
||||
this.baseUrl = url;
|
||||
this.logger = options?.logger ?? noopLogger;
|
||||
this.cookieAuth = options?.cookieAuth ?? false;
|
||||
}
|
||||
|
||||
setAuth(token: string, workspaceId: string) {
|
||||
setAuth(token: string | null, workspaceId: string) {
|
||||
this.token = token;
|
||||
this.workspaceId = workspaceId;
|
||||
}
|
||||
|
||||
connect() {
|
||||
const url = new URL(this.baseUrl);
|
||||
if (this.token) url.searchParams.set("token", this.token);
|
||||
// Token is never sent as a URL query parameter — it would be logged by
|
||||
// proxies, CDNs, and browser history. In cookie mode the HttpOnly cookie
|
||||
// is sent automatically with the upgrade request. In token mode the token
|
||||
// is delivered as the first WebSocket message after the connection opens.
|
||||
if (this.workspaceId)
|
||||
url.searchParams.set("workspace_id", this.workspaceId);
|
||||
|
||||
this.ws = new WebSocket(url.toString());
|
||||
|
||||
this.ws.onopen = () => {
|
||||
this.logger.info("connected");
|
||||
if (this.hasConnectedBefore) {
|
||||
for (const cb of this.onReconnectCallbacks) {
|
||||
try {
|
||||
cb();
|
||||
} catch {
|
||||
// ignore reconnect callback errors
|
||||
}
|
||||
}
|
||||
if (!this.cookieAuth && this.token) {
|
||||
this.ws!.send(
|
||||
JSON.stringify({ type: "auth", payload: { token: this.token } }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.hasConnectedBefore = true;
|
||||
|
||||
this.onAuthenticated();
|
||||
};
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
const msg = JSON.parse(event.data as string) as WSMessage;
|
||||
if ((msg as any).type === "auth_ack") {
|
||||
this.onAuthenticated();
|
||||
return;
|
||||
}
|
||||
this.logger.debug("received", msg.type);
|
||||
const eventHandlers = this.handlers.get(msg.type);
|
||||
if (eventHandlers) {
|
||||
@@ -72,6 +78,20 @@ export class WSClient {
|
||||
};
|
||||
}
|
||||
|
||||
private onAuthenticated() {
|
||||
this.logger.info("connected");
|
||||
if (this.hasConnectedBefore) {
|
||||
for (const cb of this.onReconnectCallbacks) {
|
||||
try {
|
||||
cb();
|
||||
} catch {
|
||||
// ignore reconnect callback errors
|
||||
}
|
||||
}
|
||||
}
|
||||
this.hasConnectedBefore = true;
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
|
||||
@@ -7,6 +7,8 @@ export interface AuthStoreOptions {
|
||||
storage: StorageAdapter;
|
||||
onLogin?: () => void;
|
||||
onLogout?: () => void;
|
||||
/** When true, rely on HttpOnly cookies instead of localStorage for auth tokens. */
|
||||
cookieAuth?: boolean;
|
||||
}
|
||||
|
||||
export interface AuthState {
|
||||
@@ -17,18 +19,32 @@ export interface AuthState {
|
||||
sendCode: (email: string) => Promise<void>;
|
||||
verifyCode: (email: string, code: string) => Promise<User>;
|
||||
loginWithGoogle: (code: string, redirectUri: string) => Promise<User>;
|
||||
loginWithToken: (token: string) => Promise<User>;
|
||||
logout: () => void;
|
||||
setUser: (user: User) => void;
|
||||
}
|
||||
|
||||
export function createAuthStore(options: AuthStoreOptions) {
|
||||
const { api, storage, onLogin, onLogout } = options;
|
||||
const { api, storage, onLogin, onLogout, cookieAuth } = options;
|
||||
|
||||
return create<AuthState>((set) => ({
|
||||
user: null,
|
||||
isLoading: true,
|
||||
|
||||
initialize: async () => {
|
||||
if (cookieAuth) {
|
||||
// In cookie mode, the HttpOnly cookie is sent automatically.
|
||||
// Try to fetch the current user — if the cookie exists the server will accept it.
|
||||
try {
|
||||
const user = await api.getMe();
|
||||
set({ user, isLoading: false });
|
||||
} catch {
|
||||
set({ user: null, isLoading: false });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Token mode: read from localStorage (Electron / legacy).
|
||||
const token = storage.getItem("multica_token");
|
||||
if (!token) {
|
||||
set({ isLoading: false });
|
||||
@@ -54,8 +70,11 @@ export function createAuthStore(options: AuthStoreOptions) {
|
||||
|
||||
verifyCode: async (email: string, code: string) => {
|
||||
const { token, user } = await api.verifyCode(email, code);
|
||||
storage.setItem("multica_token", token);
|
||||
api.setToken(token);
|
||||
if (!cookieAuth) {
|
||||
// Token mode: persist for Electron / legacy.
|
||||
storage.setItem("multica_token", token);
|
||||
api.setToken(token);
|
||||
}
|
||||
onLogin?.();
|
||||
set({ user });
|
||||
return user;
|
||||
@@ -63,16 +82,30 @@ export function createAuthStore(options: AuthStoreOptions) {
|
||||
|
||||
loginWithGoogle: async (code: string, redirectUri: string) => {
|
||||
const { token, user } = await api.googleLogin(code, redirectUri);
|
||||
storage.setItem("multica_token", token);
|
||||
api.setToken(token);
|
||||
if (!cookieAuth) {
|
||||
storage.setItem("multica_token", token);
|
||||
api.setToken(token);
|
||||
}
|
||||
onLogin?.();
|
||||
set({ user });
|
||||
return user;
|
||||
},
|
||||
|
||||
loginWithToken: async (token: string) => {
|
||||
storage.setItem("multica_token", token);
|
||||
api.setToken(token);
|
||||
const user = await api.getMe();
|
||||
onLogin?.();
|
||||
set({ user, isLoading: false });
|
||||
return user;
|
||||
},
|
||||
|
||||
logout: () => {
|
||||
if (cookieAuth) {
|
||||
// Clear server-side HttpOnly cookie.
|
||||
api.logout().catch(() => {});
|
||||
}
|
||||
storage.removeItem("multica_token");
|
||||
storage.removeItem("multica_workspace_id");
|
||||
api.setToken(null);
|
||||
api.setWorkspaceId(null);
|
||||
onLogout?.();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export { createChatStore } from "./store";
|
||||
export { createChatStore, CHAT_MIN_W, CHAT_MIN_H, CHAT_DEFAULT_W, CHAT_DEFAULT_H } from "./store";
|
||||
export type { ChatStoreOptions, ChatState, ChatTimelineItem } from "./store";
|
||||
|
||||
import type { createChatStore as CreateChatStoreFn } from "./store";
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "../api";
|
||||
import { useWorkspaceId } from "../hooks";
|
||||
import { chatKeys } from "./queries";
|
||||
import type { ChatSession } from "../types";
|
||||
|
||||
export function useCreateChatSession() {
|
||||
const qc = useQueryClient();
|
||||
@@ -23,6 +24,29 @@ export function useArchiveChatSession() {
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (sessionId: string) => api.archiveChatSession(sessionId),
|
||||
onMutate: async (sessionId) => {
|
||||
await qc.cancelQueries({ queryKey: chatKeys.sessions(wsId) });
|
||||
await qc.cancelQueries({ queryKey: chatKeys.allSessions(wsId) });
|
||||
|
||||
const prevSessions = qc.getQueryData<ChatSession[]>(chatKeys.sessions(wsId));
|
||||
const prevAll = qc.getQueryData<ChatSession[]>(chatKeys.allSessions(wsId));
|
||||
|
||||
// Optimistic: remove from active, mark as archived in allSessions
|
||||
qc.setQueryData<ChatSession[]>(chatKeys.sessions(wsId), (old) =>
|
||||
old ? old.filter((s) => s.id !== sessionId) : old,
|
||||
);
|
||||
qc.setQueryData<ChatSession[]>(chatKeys.allSessions(wsId), (old) =>
|
||||
old?.map((s) =>
|
||||
s.id === sessionId ? { ...s, status: "archived" as const } : s,
|
||||
),
|
||||
);
|
||||
|
||||
return { prevSessions, prevAll };
|
||||
},
|
||||
onError: (_err, _id, ctx) => {
|
||||
if (ctx?.prevSessions) qc.setQueryData(chatKeys.sessions(wsId), ctx.prevSessions);
|
||||
if (ctx?.prevAll) qc.setQueryData(chatKeys.allSessions(wsId), ctx.prevAll);
|
||||
},
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({ queryKey: chatKeys.sessions(wsId) });
|
||||
qc.invalidateQueries({ queryKey: chatKeys.allSessions(wsId) });
|
||||
|
||||
@@ -4,6 +4,15 @@ import { getCurrentWorkspaceId, registerForWorkspaceRehydration } from "../platf
|
||||
|
||||
const AGENT_STORAGE_KEY = "multica:chat:selectedAgentId";
|
||||
const SESSION_STORAGE_KEY = "multica:chat:activeSessionId";
|
||||
const DRAFT_KEY = "multica:chat:draft";
|
||||
const CHAT_WIDTH_KEY = "multica:chat:width";
|
||||
const CHAT_HEIGHT_KEY = "multica:chat:height";
|
||||
const CHAT_EXPANDED_KEY = "multica:chat:expanded";
|
||||
|
||||
export const CHAT_MIN_W = 360;
|
||||
export const CHAT_MIN_H = 480;
|
||||
export const CHAT_DEFAULT_W = 420;
|
||||
export const CHAT_DEFAULT_H = 600;
|
||||
|
||||
export interface ChatTimelineItem {
|
||||
seq: number;
|
||||
@@ -16,21 +25,29 @@ export interface ChatTimelineItem {
|
||||
|
||||
export interface ChatState {
|
||||
isOpen: boolean;
|
||||
isFullscreen: boolean;
|
||||
activeSessionId: string | null;
|
||||
pendingTaskId: string | null;
|
||||
selectedAgentId: string | null;
|
||||
showHistory: boolean;
|
||||
timelineItems: ChatTimelineItem[];
|
||||
inputDraft: string;
|
||||
/** Raw user-chosen size — no clamp applied. UI layer clamps at render time. */
|
||||
chatWidth: number;
|
||||
chatHeight: number;
|
||||
isExpanded: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
toggle: () => void;
|
||||
toggleFullscreen: () => void;
|
||||
setActiveSession: (id: string | null) => void;
|
||||
setPendingTask: (taskId: string | null) => void;
|
||||
setSelectedAgentId: (id: string) => void;
|
||||
setShowHistory: (show: boolean) => void;
|
||||
addTimelineItem: (item: ChatTimelineItem) => void;
|
||||
clearTimeline: () => void;
|
||||
setInputDraft: (draft: string) => void;
|
||||
clearInputDraft: () => void;
|
||||
/** Persist raw size and auto-exit expanded mode. */
|
||||
setChatSize: (width: number, height: number) => void;
|
||||
setExpanded: (expanded: boolean) => void;
|
||||
}
|
||||
|
||||
export interface ChatStoreOptions {
|
||||
@@ -47,20 +64,17 @@ export function createChatStore(options: ChatStoreOptions) {
|
||||
|
||||
const store = create<ChatState>((set) => ({
|
||||
isOpen: false,
|
||||
isFullscreen: false,
|
||||
activeSessionId: storage.getItem(wsKey(SESSION_STORAGE_KEY)),
|
||||
pendingTaskId: null,
|
||||
selectedAgentId: storage.getItem(wsKey(AGENT_STORAGE_KEY)),
|
||||
showHistory: false,
|
||||
timelineItems: [],
|
||||
setOpen: (open) =>
|
||||
set({ isOpen: open, ...(open ? {} : { isFullscreen: false }) }),
|
||||
toggle: () =>
|
||||
set((s) => ({
|
||||
isOpen: !s.isOpen,
|
||||
...(s.isOpen ? { isFullscreen: false } : {}),
|
||||
})),
|
||||
toggleFullscreen: () => set((s) => ({ isFullscreen: !s.isFullscreen })),
|
||||
inputDraft: storage.getItem(wsKey(DRAFT_KEY)) ?? "",
|
||||
chatWidth: Number(storage.getItem(CHAT_WIDTH_KEY)) || CHAT_DEFAULT_W,
|
||||
chatHeight: Number(storage.getItem(CHAT_HEIGHT_KEY)) || CHAT_DEFAULT_H,
|
||||
isExpanded: storage.getItem(wsKey(CHAT_EXPANDED_KEY)) === "true",
|
||||
setOpen: (open) => set({ isOpen: open }),
|
||||
toggle: () => set((s) => ({ isOpen: !s.isOpen })),
|
||||
setActiveSession: (id) => {
|
||||
if (id) {
|
||||
storage.setItem(wsKey(SESSION_STORAGE_KEY), id);
|
||||
@@ -75,6 +89,18 @@ export function createChatStore(options: ChatStoreOptions) {
|
||||
set({ selectedAgentId: id });
|
||||
},
|
||||
setShowHistory: (show) => set({ showHistory: show }),
|
||||
setInputDraft: (draft) => {
|
||||
if (draft) {
|
||||
storage.setItem(wsKey(DRAFT_KEY), draft);
|
||||
} else {
|
||||
storage.removeItem(wsKey(DRAFT_KEY));
|
||||
}
|
||||
set({ inputDraft: draft });
|
||||
},
|
||||
clearInputDraft: () => {
|
||||
storage.removeItem(wsKey(DRAFT_KEY));
|
||||
set({ inputDraft: "" });
|
||||
},
|
||||
addTimelineItem: (item) =>
|
||||
set((s) => {
|
||||
if (s.timelineItems.some((t) => t.seq === item.seq)) return s;
|
||||
@@ -85,12 +111,28 @@ export function createChatStore(options: ChatStoreOptions) {
|
||||
};
|
||||
}),
|
||||
clearTimeline: () => set({ timelineItems: [] }),
|
||||
setChatSize: (w, h) => {
|
||||
storage.setItem(CHAT_WIDTH_KEY, String(w));
|
||||
storage.setItem(CHAT_HEIGHT_KEY, String(h));
|
||||
// Dragging = user chose a manual size → exit expanded mode
|
||||
storage.removeItem(wsKey(CHAT_EXPANDED_KEY));
|
||||
set({ chatWidth: w, chatHeight: h, isExpanded: false });
|
||||
},
|
||||
setExpanded: (expanded) => {
|
||||
if (expanded) {
|
||||
storage.setItem(wsKey(CHAT_EXPANDED_KEY), "true");
|
||||
} else {
|
||||
storage.removeItem(wsKey(CHAT_EXPANDED_KEY));
|
||||
}
|
||||
set({ isExpanded: expanded });
|
||||
},
|
||||
}));
|
||||
|
||||
registerForWorkspaceRehydration(() => {
|
||||
store.setState({
|
||||
activeSessionId: storage.getItem(wsKey(SESSION_STORAGE_KEY)),
|
||||
selectedAgentId: storage.getItem(wsKey(AGENT_STORAGE_KEY)),
|
||||
inputDraft: storage.getItem(wsKey(DRAFT_KEY)) ?? "",
|
||||
timelineItems: [],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -97,6 +97,7 @@ export function useCreateIssue() {
|
||||
// Invalidate parent's children query so sub-issues list updates immediately
|
||||
if (newIssue.parent_issue_id) {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.children(wsId, newIssue.parent_issue_id) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.childProgress(wsId) });
|
||||
}
|
||||
},
|
||||
onSettled: () => {
|
||||
@@ -171,6 +172,7 @@ export function useUpdateIssue() {
|
||||
qc.invalidateQueries({
|
||||
queryKey: issueKeys.children(wsId, ctx.parentId),
|
||||
});
|
||||
qc.invalidateQueries({ queryKey: issueKeys.childProgress(wsId) });
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -205,6 +207,7 @@ export function useDeleteIssue() {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
|
||||
if (ctx?.parentIssueId) {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.children(wsId, ctx.parentIssueId) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.childProgress(wsId) });
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -278,10 +281,11 @@ export function useBatchDeleteIssues() {
|
||||
},
|
||||
onSettled: (_data, _err, _ids, ctx) => {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
|
||||
if (ctx?.parentIssueIds) {
|
||||
if (ctx?.parentIssueIds && ctx.parentIssueIds.size > 0) {
|
||||
for (const parentId of ctx.parentIssueIds) {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.children(wsId, parentId) });
|
||||
}
|
||||
qc.invalidateQueries({ queryKey: issueKeys.childProgress(wsId) });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -14,6 +14,8 @@ export const issueKeys = {
|
||||
[...issueKeys.all(wsId), "detail", id] as const,
|
||||
children: (wsId: string, id: string) =>
|
||||
[...issueKeys.all(wsId), "children", id] as const,
|
||||
childProgress: (wsId: string) =>
|
||||
[...issueKeys.all(wsId), "child-progress"] as const,
|
||||
timeline: (issueId: string) => ["issues", "timeline", issueId] as const,
|
||||
reactions: (issueId: string) => ["issues", "reactions", issueId] as const,
|
||||
subscribers: (issueId: string) =>
|
||||
@@ -89,6 +91,20 @@ export function issueDetailOptions(wsId: string, id: string) {
|
||||
});
|
||||
}
|
||||
|
||||
export function childIssueProgressOptions(wsId: string) {
|
||||
return queryOptions({
|
||||
queryKey: issueKeys.childProgress(wsId),
|
||||
queryFn: () => api.getChildIssueProgress(),
|
||||
select: (data) => {
|
||||
const map = new Map<string, { done: number; total: number }>();
|
||||
for (const entry of data.progress) {
|
||||
map.set(entry.parent_issue_id, { done: entry.done, total: entry.total });
|
||||
}
|
||||
return map;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function childIssuesOptions(wsId: string, id: string) {
|
||||
return queryOptions({
|
||||
queryKey: issueKeys.children(wsId, id),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export { useIssueSelectionStore } from "./selection-store";
|
||||
export { useIssueDraftStore } from "./draft-store";
|
||||
export { useRecentIssuesStore, type RecentIssueEntry } from "./recent-issues-store";
|
||||
export {
|
||||
ViewStoreProvider,
|
||||
useViewStore,
|
||||
|
||||
52
packages/core/issues/stores/recent-issues-store.ts
Normal file
52
packages/core/issues/stores/recent-issues-store.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import { create } from "zustand";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
import type { IssueStatus } from "../../types";
|
||||
import {
|
||||
createWorkspaceAwareStorage,
|
||||
registerForWorkspaceRehydration,
|
||||
} from "../../platform/workspace-storage";
|
||||
import { defaultStorage } from "../../platform/storage";
|
||||
|
||||
const MAX_RECENT_ISSUES = 20;
|
||||
|
||||
export interface RecentIssueEntry {
|
||||
id: string;
|
||||
identifier: string;
|
||||
title: string;
|
||||
status: IssueStatus;
|
||||
visitedAt: number;
|
||||
}
|
||||
|
||||
interface RecentIssuesState {
|
||||
items: RecentIssueEntry[];
|
||||
recordVisit: (entry: Omit<RecentIssueEntry, "visitedAt">) => void;
|
||||
}
|
||||
|
||||
export const useRecentIssuesStore = create<RecentIssuesState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
items: [],
|
||||
recordVisit: (entry) =>
|
||||
set((state) => {
|
||||
const filtered = state.items.filter((i) => i.id !== entry.id);
|
||||
const updated: RecentIssueEntry = { ...entry, visitedAt: Date.now() };
|
||||
return {
|
||||
items: [updated, ...filtered].slice(0, MAX_RECENT_ISSUES),
|
||||
};
|
||||
}),
|
||||
}),
|
||||
{
|
||||
name: "multica_recent_issues",
|
||||
storage: createJSONStorage(() =>
|
||||
createWorkspaceAwareStorage(defaultStorage),
|
||||
),
|
||||
partialize: (state) => ({ items: state.items }),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
registerForWorkspaceRehydration(() =>
|
||||
useRecentIssuesStore.persist.rehydrate(),
|
||||
);
|
||||
@@ -46,6 +46,8 @@ export interface IssueViewState {
|
||||
assigneeFilters: ActorFilterValue[];
|
||||
includeNoAssignee: boolean;
|
||||
creatorFilters: ActorFilterValue[];
|
||||
projectFilters: string[];
|
||||
includeNoProject: boolean;
|
||||
sortBy: SortField;
|
||||
sortDirection: SortDirection;
|
||||
cardProperties: CardProperties;
|
||||
@@ -56,6 +58,8 @@ export interface IssueViewState {
|
||||
toggleAssigneeFilter: (value: ActorFilterValue) => void;
|
||||
toggleNoAssignee: () => void;
|
||||
toggleCreatorFilter: (value: ActorFilterValue) => void;
|
||||
toggleProjectFilter: (projectId: string) => void;
|
||||
toggleNoProject: () => void;
|
||||
hideStatus: (status: IssueStatus) => void;
|
||||
showStatus: (status: IssueStatus) => void;
|
||||
clearFilters: () => void;
|
||||
@@ -72,6 +76,8 @@ export const viewStoreSlice = (set: StoreApi<IssueViewState>["setState"]): Issue
|
||||
assigneeFilters: [],
|
||||
includeNoAssignee: false,
|
||||
creatorFilters: [],
|
||||
projectFilters: [],
|
||||
includeNoProject: false,
|
||||
sortBy: "position",
|
||||
sortDirection: "asc",
|
||||
cardProperties: {
|
||||
@@ -123,6 +129,14 @@ export const viewStoreSlice = (set: StoreApi<IssueViewState>["setState"]): Issue
|
||||
: [...state.creatorFilters, value],
|
||||
};
|
||||
}),
|
||||
toggleProjectFilter: (projectId) =>
|
||||
set((state) => ({
|
||||
projectFilters: state.projectFilters.includes(projectId)
|
||||
? state.projectFilters.filter((id) => id !== projectId)
|
||||
: [...state.projectFilters, projectId],
|
||||
})),
|
||||
toggleNoProject: () =>
|
||||
set((state) => ({ includeNoProject: !state.includeNoProject })),
|
||||
hideStatus: (status) =>
|
||||
set((state) => {
|
||||
// If no filter active, activate filter with all EXCEPT this one
|
||||
@@ -146,6 +160,8 @@ export const viewStoreSlice = (set: StoreApi<IssueViewState>["setState"]): Issue
|
||||
assigneeFilters: [],
|
||||
includeNoAssignee: false,
|
||||
creatorFilters: [],
|
||||
projectFilters: [],
|
||||
includeNoProject: false,
|
||||
}),
|
||||
setSortBy: (field) => set({ sortBy: field }),
|
||||
setSortDirection: (dir) => set({ sortDirection: dir }),
|
||||
@@ -174,6 +190,8 @@ export const viewStorePersistOptions = (name: string) => ({
|
||||
assigneeFilters: state.assigneeFilters,
|
||||
includeNoAssignee: state.includeNoAssignee,
|
||||
creatorFilters: state.creatorFilters,
|
||||
projectFilters: state.projectFilters,
|
||||
includeNoProject: state.includeNoProject,
|
||||
sortBy: state.sortBy,
|
||||
sortDirection: state.sortDirection,
|
||||
cardProperties: state.cardProperties,
|
||||
|
||||
@@ -20,6 +20,7 @@ export function onIssueCreated(
|
||||
qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) });
|
||||
if (issue.parent_issue_id) {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.children(wsId, issue.parent_issue_id) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.childProgress(wsId) });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +67,9 @@ export function onIssueUpdated(
|
||||
qc.setQueryData<Issue[]>(issueKeys.children(wsId, parentId), (old) =>
|
||||
old?.map((c) => (c.id === issue.id ? { ...c, ...issue } : c)),
|
||||
);
|
||||
if (issue.status !== undefined || issue.parent_issue_id !== undefined) {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.childProgress(wsId) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,5 +100,6 @@ export function onIssueDeleted(
|
||||
qc.removeQueries({ queryKey: issueKeys.children(wsId, issueId) });
|
||||
if (deleted?.parent_issue_id) {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.children(wsId, deleted.parent_issue_id) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.childProgress(wsId) });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,9 @@
|
||||
"./projects/queries": "./projects/queries.ts",
|
||||
"./projects/mutations": "./projects/mutations.ts",
|
||||
"./projects/config": "./projects/config.ts",
|
||||
"./pins": "./pins/index.ts",
|
||||
"./pins/queries": "./pins/queries.ts",
|
||||
"./pins/mutations": "./pins/mutations.ts",
|
||||
"./realtime": "./realtime/index.ts",
|
||||
"./navigation": "./navigation/index.ts",
|
||||
"./modals": "./modals/index.ts",
|
||||
|
||||
2
packages/core/pins/index.ts
Normal file
2
packages/core/pins/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { pinKeys, pinListOptions } from "./queries";
|
||||
export { useCreatePin, useDeletePin, useReorderPins } from "./mutations";
|
||||
69
packages/core/pins/mutations.ts
Normal file
69
packages/core/pins/mutations.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "../api";
|
||||
import { useAuthStore } from "../auth";
|
||||
import { pinKeys } from "./queries";
|
||||
import { useWorkspaceId } from "../hooks";
|
||||
import type { PinnedItem, PinnedItemType } from "../types";
|
||||
|
||||
export function useCreatePin() {
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
const userId = useAuthStore((s) => s.user?.id ?? "");
|
||||
return useMutation({
|
||||
mutationFn: (data: { item_type: PinnedItemType; item_id: string }) =>
|
||||
api.createPin(data),
|
||||
onSuccess: (newPin) => {
|
||||
qc.setQueryData<PinnedItem[]>(pinKeys.list(wsId, userId), (old) =>
|
||||
old ? [...old, newPin] : [newPin],
|
||||
);
|
||||
},
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({ queryKey: pinKeys.list(wsId, userId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeletePin() {
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
const userId = useAuthStore((s) => s.user?.id ?? "");
|
||||
return useMutation({
|
||||
mutationFn: ({ itemType, itemId }: { itemType: PinnedItemType; itemId: string }) =>
|
||||
api.deletePin(itemType, itemId),
|
||||
onMutate: async ({ itemType, itemId }) => {
|
||||
await qc.cancelQueries({ queryKey: pinKeys.list(wsId, userId) });
|
||||
const prev = qc.getQueryData<PinnedItem[]>(pinKeys.list(wsId, userId));
|
||||
qc.setQueryData<PinnedItem[]>(pinKeys.list(wsId, userId), (old) =>
|
||||
old ? old.filter((p) => !(p.item_type === itemType && p.item_id === itemId)) : old,
|
||||
);
|
||||
return { prev };
|
||||
},
|
||||
onError: (_err, _vars, ctx) => {
|
||||
if (ctx?.prev) qc.setQueryData(pinKeys.list(wsId, userId), ctx.prev);
|
||||
},
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({ queryKey: pinKeys.list(wsId, userId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useReorderPins() {
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
const userId = useAuthStore((s) => s.user?.id ?? "");
|
||||
return useMutation({
|
||||
mutationFn: (reorderedPins: PinnedItem[]) => {
|
||||
const items = reorderedPins.map((p, i) => ({ id: p.id, position: i + 1 }));
|
||||
return api.reorderPins({ items });
|
||||
},
|
||||
onMutate: async (reorderedPins) => {
|
||||
await qc.cancelQueries({ queryKey: pinKeys.list(wsId, userId) });
|
||||
const prev = qc.getQueryData<PinnedItem[]>(pinKeys.list(wsId, userId));
|
||||
qc.setQueryData<PinnedItem[]>(pinKeys.list(wsId, userId), reorderedPins);
|
||||
return { prev };
|
||||
},
|
||||
onError: (_err, _vars, ctx) => {
|
||||
if (ctx?.prev) qc.setQueryData(pinKeys.list(wsId, userId), ctx.prev);
|
||||
},
|
||||
});
|
||||
}
|
||||
14
packages/core/pins/queries.ts
Normal file
14
packages/core/pins/queries.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { queryOptions } from "@tanstack/react-query";
|
||||
import { api } from "../api";
|
||||
|
||||
export const pinKeys = {
|
||||
all: (wsId: string, userId: string) => ["pins", wsId, userId] as const,
|
||||
list: (wsId: string, userId: string) => [...pinKeys.all(wsId, userId), "list"] as const,
|
||||
};
|
||||
|
||||
export function pinListOptions(wsId: string, userId: string) {
|
||||
return queryOptions({
|
||||
queryKey: pinKeys.list(wsId, userId),
|
||||
queryFn: () => api.listPins(),
|
||||
});
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, type ReactNode } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { getApi } from "../api";
|
||||
import { useAuthStore } from "../auth";
|
||||
import { useWorkspaceStore } from "../workspace";
|
||||
import { workspaceKeys } from "../workspace/queries";
|
||||
import { createLogger } from "../logger";
|
||||
import { defaultStorage } from "./storage";
|
||||
import type { StorageAdapter } from "../types/storage";
|
||||
@@ -15,13 +17,39 @@ export function AuthInitializer({
|
||||
onLogin,
|
||||
onLogout,
|
||||
storage = defaultStorage,
|
||||
cookieAuth,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
onLogin?: () => void;
|
||||
onLogout?: () => void;
|
||||
storage?: StorageAdapter;
|
||||
cookieAuth?: boolean;
|
||||
}) {
|
||||
const qc = useQueryClient();
|
||||
|
||||
useEffect(() => {
|
||||
const api = getApi();
|
||||
const wsId = storage.getItem("multica_workspace_id");
|
||||
|
||||
if (cookieAuth) {
|
||||
// Cookie mode: the HttpOnly cookie is sent automatically by the browser.
|
||||
// Call the API to check if the session is still valid.
|
||||
Promise.all([api.getMe(), api.listWorkspaces()])
|
||||
.then(([user, wsList]) => {
|
||||
onLogin?.();
|
||||
useAuthStore.setState({ user, isLoading: false });
|
||||
qc.setQueryData(workspaceKeys.list(), wsList);
|
||||
useWorkspaceStore.getState().hydrateWorkspace(wsList, wsId);
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.error("cookie auth init failed", err);
|
||||
onLogout?.();
|
||||
useAuthStore.setState({ user: null, isLoading: false });
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Token mode: read from localStorage (Electron / legacy).
|
||||
const token = storage.getItem("multica_token");
|
||||
if (!token) {
|
||||
onLogout?.();
|
||||
@@ -29,14 +57,14 @@ export function AuthInitializer({
|
||||
return;
|
||||
}
|
||||
|
||||
const api = getApi();
|
||||
api.setToken(token);
|
||||
const wsId = storage.getItem("multica_workspace_id");
|
||||
|
||||
Promise.all([api.getMe(), api.listWorkspaces()])
|
||||
.then(([user, wsList]) => {
|
||||
onLogin?.();
|
||||
useAuthStore.setState({ user, isLoading: false });
|
||||
// Seed React Query cache so components don't need a second fetch
|
||||
qc.setQueryData(workspaceKeys.list(), wsList);
|
||||
useWorkspaceStore.getState().hydrateWorkspace(wsList, wsId);
|
||||
})
|
||||
.catch((err) => {
|
||||
|
||||
@@ -25,6 +25,7 @@ function initCore(
|
||||
storage: StorageAdapter,
|
||||
onLogin?: () => void,
|
||||
onLogout?: () => void,
|
||||
cookieAuth?: boolean,
|
||||
) {
|
||||
if (initialized) return;
|
||||
|
||||
@@ -37,13 +38,15 @@ function initCore(
|
||||
});
|
||||
setApiInstance(api);
|
||||
|
||||
// Hydrate token from storage
|
||||
const token = storage.getItem("multica_token");
|
||||
if (token) api.setToken(token);
|
||||
// In token mode, hydrate token from storage.
|
||||
if (!cookieAuth) {
|
||||
const token = storage.getItem("multica_token");
|
||||
if (token) api.setToken(token);
|
||||
}
|
||||
const wsId = storage.getItem("multica_workspace_id");
|
||||
if (wsId) api.setWorkspaceId(wsId);
|
||||
|
||||
authStore = createAuthStore({ api, storage, onLogin, onLogout });
|
||||
authStore = createAuthStore({ api, storage, onLogin, onLogout, cookieAuth });
|
||||
registerAuthStore(authStore);
|
||||
|
||||
workspaceStore = createWorkspaceStore(api, { storage });
|
||||
@@ -60,22 +63,24 @@ export function CoreProvider({
|
||||
apiBaseUrl = "",
|
||||
wsUrl = "ws://localhost:8080/ws",
|
||||
storage = defaultStorage,
|
||||
cookieAuth,
|
||||
onLogin,
|
||||
onLogout,
|
||||
}: CoreProviderProps) {
|
||||
// Initialize singletons on first render only. Dependencies are read-once:
|
||||
// apiBaseUrl, storage, and callbacks are set at app boot and never change at runtime.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
useMemo(() => initCore(apiBaseUrl, storage, onLogin, onLogout), []);
|
||||
useMemo(() => initCore(apiBaseUrl, storage, onLogin, onLogout, cookieAuth), []);
|
||||
|
||||
return (
|
||||
<QueryProvider>
|
||||
<AuthInitializer onLogin={onLogin} onLogout={onLogout} storage={storage}>
|
||||
<AuthInitializer onLogin={onLogin} onLogout={onLogout} storage={storage} cookieAuth={cookieAuth}>
|
||||
<WSProvider
|
||||
wsUrl={wsUrl}
|
||||
authStore={authStore}
|
||||
workspaceStore={workspaceStore}
|
||||
storage={storage}
|
||||
cookieAuth={cookieAuth}
|
||||
>
|
||||
{children}
|
||||
</WSProvider>
|
||||
|
||||
@@ -8,6 +8,8 @@ export interface CoreProviderProps {
|
||||
wsUrl?: string;
|
||||
/** Storage adapter. Default: SSR-safe localStorage wrapper. */
|
||||
storage?: StorageAdapter;
|
||||
/** Use HttpOnly cookies for auth instead of localStorage tokens. Default: false. */
|
||||
cookieAuth?: boolean;
|
||||
/** Called after successful login (e.g. set cookie for Next.js middleware). */
|
||||
onLogin?: () => void;
|
||||
/** Called after logout (e.g. clear cookie). */
|
||||
|
||||
@@ -2,16 +2,14 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||
import { createQueryClient } from "./query-client";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export function QueryProvider({ children, showDevtools = true }: { children: ReactNode; showDevtools?: boolean }) {
|
||||
export function QueryProvider({ children }: { children: ReactNode }) {
|
||||
const [queryClient] = useState(createQueryClient);
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
{showDevtools && <ReactQueryDevtools initialIsOpen={false} />}
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -35,6 +35,8 @@ export interface WSProviderProps {
|
||||
workspaceStore: UseBoundStore<StoreApi<WorkspaceStore>>;
|
||||
/** Platform-specific storage adapter for reading auth tokens */
|
||||
storage: StorageAdapter;
|
||||
/** When true, use HttpOnly cookies instead of token query param for WS auth. */
|
||||
cookieAuth?: boolean;
|
||||
/** Optional callback for showing toast messages (platform-specific, e.g. sonner) */
|
||||
onToast?: (message: string, type?: "info" | "error") => void;
|
||||
}
|
||||
@@ -45,6 +47,7 @@ export function WSProvider({
|
||||
authStore,
|
||||
workspaceStore,
|
||||
storage,
|
||||
cookieAuth,
|
||||
onToast,
|
||||
}: WSProviderProps) {
|
||||
const user = authStore((s) => s.user);
|
||||
@@ -54,10 +57,15 @@ export function WSProvider({
|
||||
useEffect(() => {
|
||||
if (!user || !workspace) return;
|
||||
|
||||
const token = storage.getItem("multica_token");
|
||||
if (!token) return;
|
||||
// In token mode we need a token from storage; in cookie mode the HttpOnly
|
||||
// cookie is sent automatically with the WS upgrade request.
|
||||
const token = cookieAuth ? null : storage.getItem("multica_token");
|
||||
if (!cookieAuth && !token) return;
|
||||
|
||||
const ws = new WSClient(wsUrl, { logger: createLogger("ws") });
|
||||
const ws = new WSClient(wsUrl, {
|
||||
logger: createLogger("ws"),
|
||||
cookieAuth,
|
||||
});
|
||||
ws.setAuth(token, workspace.id);
|
||||
setWsClient(ws);
|
||||
ws.connect();
|
||||
@@ -66,7 +74,7 @@ export function WSProvider({
|
||||
ws.disconnect();
|
||||
setWsClient(null);
|
||||
};
|
||||
}, [user, workspace, wsUrl, storage]);
|
||||
}, [user, workspace, wsUrl, storage, cookieAuth]);
|
||||
|
||||
const stores: RealtimeSyncStores = { authStore, workspaceStore };
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import { clearWorkspaceStorage } from "../platform/storage-cleanup";
|
||||
import { defaultStorage } from "../platform/storage";
|
||||
import { issueKeys } from "../issues/queries";
|
||||
import { projectKeys } from "../projects/queries";
|
||||
import { pinKeys } from "../pins/queries";
|
||||
import { runtimeKeys } from "../runtimes/queries";
|
||||
import {
|
||||
onIssueCreated,
|
||||
@@ -19,7 +20,7 @@ import {
|
||||
} from "../issues/ws-updaters";
|
||||
import { onInboxNew, onInboxInvalidate, onInboxIssueStatusChanged } from "../inbox/ws-updaters";
|
||||
import { inboxKeys } from "../inbox/queries";
|
||||
import { workspaceKeys } from "../workspace/queries";
|
||||
import { workspaceKeys, workspaceListOptions } from "../workspace/queries";
|
||||
import type {
|
||||
MemberAddedPayload,
|
||||
WorkspaceDeletedPayload,
|
||||
@@ -99,6 +100,11 @@ export function useRealtimeSync(
|
||||
const wsId = workspaceStore.getState().workspace?.id;
|
||||
if (wsId) qc.invalidateQueries({ queryKey: projectKeys.all(wsId) });
|
||||
},
|
||||
pin: () => {
|
||||
const wsId = workspaceStore.getState().workspace?.id;
|
||||
const userId = authStore.getState().user?.id;
|
||||
if (wsId && userId) qc.invalidateQueries({ queryKey: pinKeys.all(wsId, userId) });
|
||||
},
|
||||
daemon: () => {
|
||||
const wsId = workspaceStore.getState().workspace?.id;
|
||||
if (wsId) qc.invalidateQueries({ queryKey: runtimeKeys.all(wsId) });
|
||||
@@ -245,7 +251,9 @@ export function useRealtimeSync(
|
||||
if (currentWs?.id === workspace_id) {
|
||||
logger.warn("current workspace deleted, switching");
|
||||
onToast?.("This workspace was deleted", "info");
|
||||
workspaceStore.getState().refreshWorkspaces();
|
||||
qc.fetchQuery({ ...workspaceListOptions(), staleTime: 0 }).then((wsList) => {
|
||||
workspaceStore.getState().hydrateWorkspace(wsList);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -257,7 +265,9 @@ export function useRealtimeSync(
|
||||
if (wsId) clearWorkspaceStorage(defaultStorage, wsId);
|
||||
logger.warn("removed from workspace, switching");
|
||||
onToast?.("You were removed from this workspace", "info");
|
||||
workspaceStore.getState().refreshWorkspaces();
|
||||
qc.fetchQuery({ ...workspaceListOptions(), staleTime: 0 }).then((wsList) => {
|
||||
workspaceStore.getState().hydrateWorkspace(wsList);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -265,7 +275,7 @@ export function useRealtimeSync(
|
||||
const { member, workspace_name } = p as MemberAddedPayload;
|
||||
const myUserId = authStore.getState().user?.id;
|
||||
if (member.user_id === myUserId) {
|
||||
workspaceStore.getState().refreshWorkspaces();
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.list() });
|
||||
onToast?.(
|
||||
`You were invited to ${workspace_name ?? "a workspace"}`,
|
||||
"info",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Issue, IssueStatus, IssuePriority, IssueAssigneeType } from "./issue";
|
||||
import type { MemberRole } from "./workspace";
|
||||
import type { Project } from "./project";
|
||||
|
||||
// Issue API
|
||||
export interface CreateIssueRequest {
|
||||
@@ -57,6 +58,16 @@ export interface SearchIssuesResponse {
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface SearchProjectResult extends Project {
|
||||
match_source: "title" | "description";
|
||||
matched_snippet?: string;
|
||||
}
|
||||
|
||||
export interface SearchProjectsResponse {
|
||||
projects: SearchProjectResult[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface UpdateMeRequest {
|
||||
name?: string;
|
||||
avatar_url?: string;
|
||||
|
||||
@@ -50,7 +50,9 @@ export type WSEventType =
|
||||
| "chat:done"
|
||||
| "project:created"
|
||||
| "project:updated"
|
||||
| "project:deleted";
|
||||
| "project:deleted"
|
||||
| "pin:created"
|
||||
| "pin:deleted";
|
||||
|
||||
export interface WSMessage<T = unknown> {
|
||||
type: WSEventType;
|
||||
|
||||
@@ -33,3 +33,4 @@ export type { Attachment } from "./attachment";
|
||||
export type { ChatSession, ChatMessage, SendChatMessageResponse } from "./chat";
|
||||
export type { StorageAdapter } from "./storage";
|
||||
export type { Project, ProjectStatus, ProjectPriority, CreateProjectRequest, UpdateProjectRequest, ListProjectsResponse } from "./project";
|
||||
export type { PinnedItem, PinnedItemType, CreatePinRequest, ReorderPinsRequest } from "./pin";
|
||||
|
||||
24
packages/core/types/pin.ts
Normal file
24
packages/core/types/pin.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
export type PinnedItemType = "issue" | "project";
|
||||
|
||||
export interface PinnedItem {
|
||||
id: string;
|
||||
workspace_id: string;
|
||||
user_id: string;
|
||||
item_type: PinnedItemType;
|
||||
item_id: string;
|
||||
position: number;
|
||||
created_at: string;
|
||||
title: string;
|
||||
identifier?: string;
|
||||
icon?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export interface CreatePinRequest {
|
||||
item_type: PinnedItemType;
|
||||
item_id: string;
|
||||
}
|
||||
|
||||
export interface ReorderPinsRequest {
|
||||
items: { id: string; position: number }[];
|
||||
}
|
||||
@@ -14,6 +14,8 @@ export interface Project {
|
||||
lead_id: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
issue_count: number;
|
||||
done_count: number;
|
||||
}
|
||||
|
||||
export interface CreateProjectRequest {
|
||||
|
||||
33
packages/core/utils.test.ts
Normal file
33
packages/core/utils.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createRequestId, createSafeId, generateUUID } from "./utils";
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("utils id helpers", () => {
|
||||
it("generateUUID returns a valid UUID v4", () => {
|
||||
const id = generateUUID();
|
||||
expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i);
|
||||
});
|
||||
|
||||
it("createSafeId falls back when crypto.randomUUID is unavailable", () => {
|
||||
vi.stubGlobal("crypto", {
|
||||
getRandomValues: (arr: Uint8Array) => {
|
||||
for (let i = 0; i < arr.length; i++) arr[i] = i;
|
||||
return arr;
|
||||
},
|
||||
});
|
||||
|
||||
const id = createSafeId();
|
||||
expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i);
|
||||
});
|
||||
|
||||
it("createRequestId defaults to length 8 and respects custom length", () => {
|
||||
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue("12345678-1234-4abc-8def-1234567890ab");
|
||||
|
||||
expect(createRequestId()).toBe("12345678");
|
||||
expect(createRequestId(12)).toBe("123456781234");
|
||||
});
|
||||
});
|
||||
@@ -8,3 +8,43 @@ export function timeAgo(dateStr: string): string {
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d ago`;
|
||||
}
|
||||
|
||||
export function generateUUID(): string {
|
||||
const cryptoObj = globalThis.crypto;
|
||||
|
||||
if (!cryptoObj?.getRandomValues) {
|
||||
throw new Error("Secure UUID generation requires crypto.getRandomValues");
|
||||
}
|
||||
|
||||
const bytes = new Uint8Array(16);
|
||||
cryptoObj.getRandomValues(bytes);
|
||||
|
||||
bytes[6] = ((bytes[6] ?? 0) & 0x0f) | 0x40; // version 4
|
||||
bytes[8] = ((bytes[8] ?? 0) & 0x3f) | 0x80; // variant 1
|
||||
|
||||
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
||||
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate an id that prefers crypto.randomUUID but falls back in non-secure contexts.
|
||||
*/
|
||||
export function createSafeId(): string {
|
||||
const cryptoObj = globalThis.crypto;
|
||||
|
||||
if (cryptoObj?.randomUUID) {
|
||||
try {
|
||||
return cryptoObj.randomUUID();
|
||||
} catch {
|
||||
// Fall through to fallback.
|
||||
}
|
||||
}
|
||||
|
||||
return generateUUID();
|
||||
}
|
||||
|
||||
/** Request id helper used for logs/tracing headers. */
|
||||
export function createRequestId(length = 8): string {
|
||||
return createSafeId().replace(/-/g, "").slice(0, length);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import type { Workspace } from "../types";
|
||||
import { api } from "../api";
|
||||
import { workspaceKeys } from "./queries";
|
||||
import { workspaceKeys, workspaceListOptions } from "./queries";
|
||||
import { useWorkspaceStore } from "./index";
|
||||
|
||||
export function useCreateWorkspace() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: { name: string; slug: string; description?: string }) =>
|
||||
api.createWorkspace(data),
|
||||
onSuccess: (newWs) => {
|
||||
// Add to cache before switching so sidebar list is consistent on first render
|
||||
qc.setQueryData(workspaceKeys.list(), (old: Workspace[] = []) => [...old, newWs]);
|
||||
useWorkspaceStore.getState().switchWorkspace(newWs);
|
||||
},
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.list() });
|
||||
},
|
||||
@@ -17,6 +24,14 @@ export function useLeaveWorkspace() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (workspaceId: string) => api.leaveWorkspace(workspaceId),
|
||||
onSuccess: async (_, workspaceId) => {
|
||||
const currentWsId = useWorkspaceStore.getState().workspace?.id;
|
||||
if (currentWsId === workspaceId) {
|
||||
// staleTime: 0 forces a real network fetch — cache still has the left workspace
|
||||
const wsList = await qc.fetchQuery({ ...workspaceListOptions(), staleTime: 0 });
|
||||
useWorkspaceStore.getState().hydrateWorkspace(wsList);
|
||||
}
|
||||
},
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.list() });
|
||||
},
|
||||
@@ -27,6 +42,14 @@ export function useDeleteWorkspace() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (workspaceId: string) => api.deleteWorkspace(workspaceId),
|
||||
onSuccess: async (_, workspaceId) => {
|
||||
const currentWsId = useWorkspaceStore.getState().workspace?.id;
|
||||
if (currentWsId === workspaceId) {
|
||||
// staleTime: 0 forces a real network fetch — cache still has the deleted workspace
|
||||
const wsList = await qc.fetchQuery({ ...workspaceListOptions(), staleTime: 0 });
|
||||
useWorkspaceStore.getState().hydrateWorkspace(wsList);
|
||||
}
|
||||
},
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.list() });
|
||||
},
|
||||
|
||||
@@ -8,29 +8,25 @@ const logger = createLogger("workspace-store");
|
||||
|
||||
interface WorkspaceStoreOptions {
|
||||
storage?: StorageAdapter;
|
||||
onError?: (message: string) => void;
|
||||
}
|
||||
|
||||
interface WorkspaceState {
|
||||
workspace: Workspace | null;
|
||||
workspaces: Workspace[];
|
||||
}
|
||||
|
||||
interface WorkspaceActions {
|
||||
/**
|
||||
* Pick a workspace from a list and set it as current.
|
||||
* The list itself is NOT stored here — it lives in React Query.
|
||||
*/
|
||||
hydrateWorkspace: (
|
||||
wsList: Workspace[],
|
||||
preferredWorkspaceId?: string | null,
|
||||
) => Workspace | null;
|
||||
switchWorkspace: (workspaceId: string) => void;
|
||||
refreshWorkspaces: () => Promise<Workspace[]>;
|
||||
/** Switch to a workspace. Caller provides the full object (from React Query). */
|
||||
switchWorkspace: (ws: Workspace) => void;
|
||||
/** Update current workspace data in place (e.g. after rename). */
|
||||
updateWorkspace: (ws: Workspace) => void;
|
||||
createWorkspace: (data: {
|
||||
name: string;
|
||||
slug: string;
|
||||
description?: string;
|
||||
}) => Promise<Workspace>;
|
||||
leaveWorkspace: (workspaceId: string) => Promise<void>;
|
||||
deleteWorkspace: (workspaceId: string) => Promise<void>;
|
||||
clearWorkspace: () => void;
|
||||
}
|
||||
|
||||
@@ -38,17 +34,13 @@ export type WorkspaceStore = WorkspaceState & WorkspaceActions;
|
||||
|
||||
export function createWorkspaceStore(api: ApiClient, options?: WorkspaceStoreOptions) {
|
||||
const storage = options?.storage;
|
||||
const onError = options?.onError;
|
||||
|
||||
return create<WorkspaceStore>((set, get) => ({
|
||||
// State
|
||||
return create<WorkspaceStore>((set) => ({
|
||||
// Only the currently selected workspace (UI state).
|
||||
// The workspace list is server state and lives in React Query.
|
||||
workspace: null,
|
||||
workspaces: [],
|
||||
|
||||
// Actions
|
||||
hydrateWorkspace: (wsList, preferredWorkspaceId) => {
|
||||
set({ workspaces: wsList });
|
||||
|
||||
const nextWorkspace =
|
||||
(preferredWorkspaceId
|
||||
? wsList.find((item) => item.id === preferredWorkspaceId)
|
||||
@@ -72,80 +64,29 @@ export function createWorkspaceStore(api: ApiClient, options?: WorkspaceStoreOpt
|
||||
set({ workspace: nextWorkspace });
|
||||
logger.debug("hydrate workspace", nextWorkspace.name, nextWorkspace.id);
|
||||
|
||||
// Members, agents, skills, issues, inbox are all managed by TanStack Query.
|
||||
// They auto-fetch when components mount with the workspace ID in their query key.
|
||||
|
||||
return nextWorkspace;
|
||||
},
|
||||
|
||||
switchWorkspace: (workspaceId) => {
|
||||
logger.info("switching to", workspaceId);
|
||||
const { workspaces, hydrateWorkspace } = get();
|
||||
const ws = workspaces.find((item) => item.id === workspaceId);
|
||||
if (!ws) return;
|
||||
|
||||
switchWorkspace: (ws) => {
|
||||
logger.info("switching to", ws.id);
|
||||
api.setWorkspaceId(ws.id);
|
||||
setCurrentWorkspaceId(ws.id);
|
||||
rehydrateAllWorkspaceStores();
|
||||
storage?.setItem("multica_workspace_id", ws.id);
|
||||
|
||||
// All data caches (issues, inbox, members, agents, skills, runtimes)
|
||||
// are managed by TanStack Query, keyed by wsId — auto-refetch on switch.
|
||||
set({ workspace: ws });
|
||||
|
||||
hydrateWorkspace(workspaces, ws.id);
|
||||
},
|
||||
|
||||
refreshWorkspaces: async () => {
|
||||
const { workspace, hydrateWorkspace } = get();
|
||||
const storedWorkspaceId = storage?.getItem("multica_workspace_id") ?? null;
|
||||
try {
|
||||
const wsList = await api.listWorkspaces();
|
||||
hydrateWorkspace(wsList, workspace?.id ?? storedWorkspaceId);
|
||||
return wsList;
|
||||
} catch (e) {
|
||||
logger.error("failed to refresh workspaces", e);
|
||||
onError?.("Failed to refresh workspaces");
|
||||
return get().workspaces;
|
||||
}
|
||||
},
|
||||
|
||||
updateWorkspace: (ws) => {
|
||||
set((state) => ({
|
||||
workspace: state.workspace?.id === ws.id ? ws : state.workspace,
|
||||
workspaces: state.workspaces.map((item) =>
|
||||
item.id === ws.id ? ws : item,
|
||||
),
|
||||
}));
|
||||
},
|
||||
|
||||
createWorkspace: async (data) => {
|
||||
const ws = await api.createWorkspace(data);
|
||||
set((state) => ({ workspaces: [...state.workspaces, ws] }));
|
||||
return ws;
|
||||
},
|
||||
|
||||
leaveWorkspace: async (workspaceId) => {
|
||||
await api.leaveWorkspace(workspaceId);
|
||||
const { workspace, hydrateWorkspace } = get();
|
||||
const wsList = await api.listWorkspaces();
|
||||
const preferredWorkspaceId =
|
||||
workspace?.id === workspaceId ? null : (workspace?.id ?? null);
|
||||
hydrateWorkspace(wsList, preferredWorkspaceId);
|
||||
},
|
||||
|
||||
deleteWorkspace: async (workspaceId) => {
|
||||
await api.deleteWorkspace(workspaceId);
|
||||
const { workspace, hydrateWorkspace } = get();
|
||||
const wsList = await api.listWorkspaces();
|
||||
const preferredWorkspaceId =
|
||||
workspace?.id === workspaceId ? null : (workspace?.id ?? null);
|
||||
hydrateWorkspace(wsList, preferredWorkspaceId);
|
||||
},
|
||||
|
||||
clearWorkspace: () => {
|
||||
api.setWorkspaceId(null);
|
||||
setCurrentWorkspaceId(null);
|
||||
rehydrateAllWorkspaceStores();
|
||||
set({ workspace: null, workspaces: [] });
|
||||
set({ workspace: null });
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
34
packages/ui/components/common/submit-button.tsx
Normal file
34
packages/ui/components/common/submit-button.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowUp, Loader2, Square } from "lucide-react";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
|
||||
interface SubmitButtonProps {
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
running?: boolean;
|
||||
onStop?: () => void;
|
||||
}
|
||||
|
||||
function SubmitButton({ onClick, disabled, loading, running, onStop }: SubmitButtonProps) {
|
||||
if (running) {
|
||||
return (
|
||||
<Button size="icon-sm" onClick={onStop}>
|
||||
<Square className="fill-current" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button size="icon-sm" disabled={disabled || loading} onClick={onClick}>
|
||||
{loading ? (
|
||||
<Loader2 className="animate-spin" />
|
||||
) : (
|
||||
<ArrowUp />
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export { SubmitButton, type SubmitButtonProps };
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as React from 'react'
|
||||
import ReactMarkdown, { type Components, defaultUrlTransform } from 'react-markdown'
|
||||
import rehypeRaw from 'rehype-raw'
|
||||
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { cn } from '@multica/ui/lib/utils'
|
||||
import { CodeBlock, InlineCode } from './CodeBlock'
|
||||
@@ -49,6 +50,28 @@ export interface MarkdownProps {
|
||||
renderMention?: (props: { type: string; id: string }) => React.ReactNode
|
||||
}
|
||||
|
||||
// Sanitization schema — extends GitHub defaults to allow code highlighting classes
|
||||
// and the mention:// protocol used for @mentions.
|
||||
const sanitizeSchema = {
|
||||
...defaultSchema,
|
||||
protocols: {
|
||||
...defaultSchema.protocols,
|
||||
href: [...(defaultSchema.protocols?.href ?? []), 'mention'],
|
||||
},
|
||||
attributes: {
|
||||
...defaultSchema.attributes,
|
||||
code: [
|
||||
...(defaultSchema.attributes?.code ?? []),
|
||||
['className', /^language-/],
|
||||
['className', /^hljs/],
|
||||
],
|
||||
img: [
|
||||
...(defaultSchema.attributes?.img ?? []),
|
||||
'alt',
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom URL transform that allows mention:// protocol (used for @mentions)
|
||||
* while keeping the default security for all other URLs.
|
||||
@@ -327,7 +350,7 @@ export function Markdown({
|
||||
<div className={cn('markdown-content break-words', className)}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[[remarkGfm, { singleTilde: false }]]}
|
||||
rehypePlugins={[rehypeRaw]}
|
||||
rehypePlugins={[rehypeRaw, [rehypeSanitize, sanitizeSchema]]}
|
||||
urlTransform={urlTransform}
|
||||
components={components}
|
||||
>
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
"@types/linkify-it": "^5.0.0",
|
||||
"@types/react": "catalog:",
|
||||
"@types/react-dom": "catalog:",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"typescript": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,14 @@ const mockApiListWorkspaces = vi.hoisted(() => vi.fn());
|
||||
const mockApiVerifyCode = vi.hoisted(() => vi.fn());
|
||||
const mockApiSetToken = vi.hoisted(() => vi.fn());
|
||||
const mockApiGetMe = vi.hoisted(() => vi.fn());
|
||||
const mockSetQueryData = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@tanstack/react-query", async () => {
|
||||
const actual = await vi.importActual<typeof import("@tanstack/react-query")>(
|
||||
"@tanstack/react-query",
|
||||
);
|
||||
return { ...actual, useQueryClient: () => ({ setQueryData: mockSetQueryData }) };
|
||||
});
|
||||
|
||||
vi.mock("@multica/core/auth", () => ({
|
||||
useAuthStore: Object.assign(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, type ReactNode } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
} from "@multica/ui/components/ui/input-otp";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { useWorkspaceStore } from "@multica/core/workspace";
|
||||
import { workspaceKeys } from "@multica/core/workspace/queries";
|
||||
import { api } from "@multica/core/api";
|
||||
import type { User } from "@multica/core/types";
|
||||
|
||||
@@ -29,6 +31,8 @@ import type { User } from "@multica/core/types";
|
||||
interface GoogleAuthConfig {
|
||||
clientId: string;
|
||||
redirectUri: string;
|
||||
/** Opaque state passed through Google OAuth (e.g. "platform:desktop"). */
|
||||
state?: string;
|
||||
}
|
||||
|
||||
interface CliCallbackConfig {
|
||||
@@ -51,6 +55,8 @@ interface LoginPageProps {
|
||||
lastWorkspaceId?: string | null;
|
||||
/** Called after a token is obtained (e.g. to set cookies). */
|
||||
onTokenObtained?: () => void;
|
||||
/** Override Google login handler (e.g. desktop opens browser externally). When provided, renders the Google button even if `google` config is omitted. */
|
||||
onGoogleLogin?: () => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -86,7 +92,9 @@ export function LoginPage({
|
||||
cliCallback,
|
||||
lastWorkspaceId,
|
||||
onTokenObtained,
|
||||
onGoogleLogin,
|
||||
}: LoginPageProps) {
|
||||
const qc = useQueryClient();
|
||||
const [step, setStep] = useState<"email" | "code" | "cli_confirm">("email");
|
||||
const [email, setEmail] = useState("");
|
||||
const [code, setCode] = useState("");
|
||||
@@ -167,6 +175,7 @@ export function LoginPage({
|
||||
// Normal path
|
||||
await useAuthStore.getState().verifyCode(email, value);
|
||||
const wsList = await api.listWorkspaces();
|
||||
qc.setQueryData(workspaceKeys.list(), wsList);
|
||||
useWorkspaceStore.getState().hydrateWorkspace(wsList, lastWorkspaceId);
|
||||
onTokenObtained?.();
|
||||
onSuccess();
|
||||
@@ -178,7 +187,7 @@ export function LoginPage({
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[email, onSuccess, cliCallback, lastWorkspaceId, onTokenObtained],
|
||||
[email, onSuccess, cliCallback, lastWorkspaceId, onTokenObtained, qc],
|
||||
);
|
||||
|
||||
const handleResend = async () => {
|
||||
@@ -204,6 +213,10 @@ export function LoginPage({
|
||||
};
|
||||
|
||||
const handleGoogleLogin = () => {
|
||||
if (onGoogleLogin) {
|
||||
onGoogleLogin();
|
||||
return;
|
||||
}
|
||||
if (!google) return;
|
||||
const params = new URLSearchParams({
|
||||
client_id: google.clientId,
|
||||
@@ -213,6 +226,7 @@ export function LoginPage({
|
||||
access_type: "offline",
|
||||
prompt: "select_account",
|
||||
});
|
||||
if (google.state) params.set("state", google.state);
|
||||
window.location.href = `https://accounts.google.com/o/oauth2/v2/auth?${params}`;
|
||||
};
|
||||
|
||||
@@ -371,7 +385,7 @@ export function LoginPage({
|
||||
>
|
||||
{loading ? "Sending code..." : "Continue"}
|
||||
</Button>
|
||||
{google && (
|
||||
{(google || onGoogleLogin) && (
|
||||
<>
|
||||
<div className="relative w-full">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { Send } from "lucide-react";
|
||||
import { MessageCircle } from "lucide-react";
|
||||
import { useChatStore } from "@multica/core/chat";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipTrigger,
|
||||
TooltipContent,
|
||||
} from "@multica/ui/components/ui/tooltip";
|
||||
|
||||
export function ChatFab() {
|
||||
const isOpen = useChatStore((s) => s.isOpen);
|
||||
@@ -10,12 +15,14 @@ export function ChatFab() {
|
||||
if (isOpen) return null;
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={toggle}
|
||||
className="fixed bottom-4 right-4 z-50 flex items-center gap-2 rounded-full border bg-background px-4 py-2 text-sm font-medium text-muted-foreground shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<Send className="size-3.5" />
|
||||
Ask Multica
|
||||
</button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
onClick={toggle}
|
||||
className="absolute bottom-2 right-2 z-50 flex size-10 cursor-pointer items-center justify-center rounded-full ring-1 ring-foreground/10 bg-card text-muted-foreground shadow-sm transition-transform hover:scale-110 hover:text-accent-foreground active:scale-95"
|
||||
>
|
||||
<MessageCircle className="size-5" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={10}>Ask Multica</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useCallback } from "react";
|
||||
import { ArrowUp, Square } from "lucide-react";
|
||||
import { useRef, useState } from "react";
|
||||
import { ContentEditor, type ContentEditorRef } from "../../editor";
|
||||
import { SubmitButton } from "@multica/ui/components/common/submit-button";
|
||||
import { useChatStore } from "@multica/core/chat";
|
||||
|
||||
interface ChatInputProps {
|
||||
onSend: (content: string) => void;
|
||||
@@ -11,70 +13,44 @@ interface ChatInputProps {
|
||||
}
|
||||
|
||||
export function ChatInput({ onSend, onStop, isRunning, disabled }: ChatInputProps) {
|
||||
const [value, setValue] = useState("");
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const editorRef = useRef<ContentEditorRef>(null);
|
||||
const inputDraft = useChatStore((s) => s.inputDraft);
|
||||
const setInputDraft = useChatStore((s) => s.setInputDraft);
|
||||
const clearInputDraft = useChatStore((s) => s.clearInputDraft);
|
||||
const [isEmpty, setIsEmpty] = useState(!inputDraft.trim());
|
||||
|
||||
const handleSend = useCallback(() => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || isRunning || disabled) return;
|
||||
onSend(trimmed);
|
||||
setValue("");
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = "auto";
|
||||
}
|
||||
textareaRef.current?.focus();
|
||||
}, [value, isRunning, disabled, onSend]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
},
|
||||
[handleSend],
|
||||
);
|
||||
|
||||
const handleInput = useCallback(() => {
|
||||
const el = textareaRef.current;
|
||||
if (!el) return;
|
||||
el.style.height = "auto";
|
||||
el.style.height = Math.min(el.scrollHeight, 120) + "px";
|
||||
}, []);
|
||||
const handleSend = () => {
|
||||
const content = editorRef.current?.getMarkdown()?.replace(/(\n\s*)+$/, "").trim();
|
||||
if (!content || isRunning || disabled) return;
|
||||
onSend(content);
|
||||
editorRef.current?.clearContent();
|
||||
clearInputDraft();
|
||||
setIsEmpty(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border-t bg-muted/30 p-3">
|
||||
<div className="rounded-lg border bg-background">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
setValue(e.target.value);
|
||||
handleInput();
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={disabled ? "This session is archived" : "Ask Multica..."}
|
||||
disabled={isRunning || disabled}
|
||||
className="block w-full resize-none bg-transparent px-3 pt-3 pb-2 text-sm placeholder:text-muted-foreground focus:outline-none disabled:opacity-50"
|
||||
rows={1}
|
||||
/>
|
||||
<div className="flex items-center justify-end px-2 pb-2">
|
||||
{isRunning ? (
|
||||
<button
|
||||
onClick={onStop}
|
||||
className="flex size-7 items-center justify-center rounded-full bg-foreground text-background transition-opacity hover:opacity-80"
|
||||
>
|
||||
<Square className="size-3 fill-current" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleSend}
|
||||
disabled={!value.trim() || disabled}
|
||||
className="flex size-7 items-center justify-center rounded-full bg-foreground text-background transition-opacity hover:opacity-80 disabled:opacity-30"
|
||||
>
|
||||
<ArrowUp className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
<div className="p-2 pt-0">
|
||||
<div className="relative flex min-h-16 max-h-40 flex-col rounded-lg bg-card pb-8 border-1 border-border transition-colors focus-within:border-brand">
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-3 py-2">
|
||||
<ContentEditor
|
||||
ref={editorRef}
|
||||
defaultValue={inputDraft}
|
||||
placeholder={disabled ? "This session is archived" : "Ask Multica..."}
|
||||
onUpdate={(md) => {
|
||||
setIsEmpty(!md.trim());
|
||||
setInputDraft(md);
|
||||
}}
|
||||
onSubmit={handleSend}
|
||||
debounceMs={100}
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute bottom-1 right-1.5 flex items-center gap-1">
|
||||
<SubmitButton
|
||||
onClick={handleSend}
|
||||
disabled={isEmpty || !!disabled}
|
||||
running={isRunning}
|
||||
onStop={onStop}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,98 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useState, useRef } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@multica/ui/components/ui/avatar";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@multica/ui/components/ui/collapsible";
|
||||
import { Bot, Loader2, ChevronRight, ChevronDown, Brain, AlertCircle } from "lucide-react";
|
||||
import { Loader2, ChevronRight, ChevronDown, Brain, AlertCircle } from "lucide-react";
|
||||
import { useScrollFade } from "@multica/ui/hooks/use-scroll-fade";
|
||||
import { useAutoScroll } from "@multica/ui/hooks/use-auto-scroll";
|
||||
import { api } from "@multica/core/api";
|
||||
import { Markdown } from "@multica/views/common/markdown";
|
||||
import type { ChatMessage, Agent, TaskMessagePayload } from "@multica/core/types";
|
||||
import type { ChatMessage, TaskMessagePayload } from "@multica/core/types";
|
||||
import type { ChatTimelineItem } from "@multica/core/chat";
|
||||
|
||||
// ─── Public component ────────────────────────────────────────────────────
|
||||
|
||||
interface ChatMessageListProps {
|
||||
messages: ChatMessage[];
|
||||
agent: Agent | null;
|
||||
timelineItems: ChatTimelineItem[];
|
||||
isWaiting: boolean;
|
||||
}
|
||||
|
||||
export function ChatMessageList({
|
||||
messages,
|
||||
agent,
|
||||
timelineItems,
|
||||
isWaiting,
|
||||
}: ChatMessageListProps) {
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [messages, timelineItems]);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const fadeStyle = useScrollFade(scrollRef);
|
||||
useAutoScroll(scrollRef);
|
||||
|
||||
const hasTimeline = timelineItems.length > 0;
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto px-4 py-3 space-y-4">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
style={fadeStyle}
|
||||
className="flex-1 overflow-y-auto px-4 py-3 space-y-4"
|
||||
>
|
||||
{messages.map((msg) => (
|
||||
<MessageBubble key={msg.id} message={msg} agent={agent} />
|
||||
<MessageBubble key={msg.id} message={msg} />
|
||||
))}
|
||||
{/* Live streaming timeline */}
|
||||
{hasTimeline && (
|
||||
<div className="flex items-start gap-3">
|
||||
<AgentAvatar agent={agent} />
|
||||
<div className="min-w-0 flex-1 space-y-1.5">
|
||||
<TimelineView items={timelineItems} />
|
||||
</div>
|
||||
<div className="w-full space-y-1.5">
|
||||
<TimelineView items={timelineItems} />
|
||||
</div>
|
||||
)}
|
||||
{isWaiting && !hasTimeline && (
|
||||
<div className="flex items-start gap-3">
|
||||
<AgentAvatar agent={agent} />
|
||||
<div className="flex items-center pt-1">
|
||||
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Message bubbles ─────────────────────────────────────────────────────
|
||||
|
||||
function MessageBubble({
|
||||
message,
|
||||
agent,
|
||||
}: {
|
||||
message: ChatMessage;
|
||||
agent: Agent | null;
|
||||
}) {
|
||||
function MessageBubble({ message }: { message: ChatMessage }) {
|
||||
if (message.role === "user") {
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
<div className="rounded-2xl bg-primary px-3.5 py-2 text-sm text-primary-foreground max-w-[85%] whitespace-pre-wrap break-words">
|
||||
<div className="rounded-2xl bg-muted px-3.5 py-2 text-sm max-w-[80%] whitespace-pre-wrap break-words">
|
||||
{message.content}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <AssistantMessage message={message} agent={agent} />;
|
||||
return <AssistantMessage message={message} />;
|
||||
}
|
||||
|
||||
function AssistantMessage({
|
||||
message,
|
||||
agent,
|
||||
}: {
|
||||
message: ChatMessage;
|
||||
agent: Agent | null;
|
||||
}) {
|
||||
const taskId = message.task_id;
|
||||
|
||||
@@ -116,17 +100,14 @@ function AssistantMessage({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex items-start gap-3">
|
||||
<AgentAvatar agent={agent} />
|
||||
<div className="min-w-0 flex-1 space-y-1.5">
|
||||
{timeline.length > 0 ? (
|
||||
<TimelineView items={timeline} />
|
||||
) : (
|
||||
<div className="text-sm leading-relaxed prose prose-sm dark:prose-invert max-w-none">
|
||||
<Markdown>{message.content}</Markdown>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-full space-y-1.5">
|
||||
{timeline.length > 0 ? (
|
||||
<TimelineView items={timeline} />
|
||||
) : (
|
||||
<div className="text-sm leading-relaxed prose prose-sm dark:prose-invert max-w-none">
|
||||
<Markdown>{message.content}</Markdown>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -356,13 +337,3 @@ function ErrorRow({ item }: { item: ChatTimelineItem }) {
|
||||
|
||||
// ─── Shared ──────────────────────────────────────────────────────────────
|
||||
|
||||
function AgentAvatar({ agent }: { agent: Agent | null }) {
|
||||
return (
|
||||
<Avatar className="size-6 shrink-0 mt-0.5">
|
||||
{agent?.avatar_url && <AvatarImage src={agent.avatar_url} />}
|
||||
<AvatarFallback className="bg-purple-100 text-purple-700">
|
||||
<Bot className="size-3" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
);
|
||||
}
|
||||
|
||||
34
packages/views/chat/components/chat-resize-handles.tsx
Normal file
34
packages/views/chat/components/chat-resize-handles.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
|
||||
type DragDir = "left" | "top" | "corner";
|
||||
|
||||
interface ChatResizeHandlesProps {
|
||||
onDragStart: (e: React.PointerEvent, dir: DragDir) => void;
|
||||
}
|
||||
|
||||
export function ChatResizeHandles({ onDragStart }: ChatResizeHandlesProps) {
|
||||
return (
|
||||
<>
|
||||
{/* Left edge — expands width when dragged left */}
|
||||
<div
|
||||
aria-hidden
|
||||
onPointerDown={(e) => onDragStart(e, "left")}
|
||||
className="absolute left-0 top-4 bottom-0 w-1 z-10 cursor-col-resize"
|
||||
/>
|
||||
{/* Top edge — expands height when dragged up */}
|
||||
<div
|
||||
aria-hidden
|
||||
onPointerDown={(e) => onDragStart(e, "top")}
|
||||
className="absolute top-0 left-4 right-0 h-1 z-10 cursor-row-resize"
|
||||
/>
|
||||
{/* Top-left corner — expands both width and height */}
|
||||
<div
|
||||
aria-hidden
|
||||
onPointerDown={(e) => onDragStart(e, "corner")}
|
||||
className="absolute top-0 left-0 size-4 z-20 cursor-nw-resize"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ArrowLeft, MessageSquare, Archive, Trash2 } from "lucide-react";
|
||||
import { ArrowLeft, MessageSquare, Bot } from "lucide-react";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@multica/ui/components/ui/avatar";
|
||||
import { Bot } from "lucide-react";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { agentListOptions } from "@multica/core/workspace/queries";
|
||||
import { allChatSessionsOptions } from "@multica/core/chat/queries";
|
||||
import { useArchiveChatSession } from "@multica/core/chat/mutations";
|
||||
import { useChatStore } from "@multica/core/chat";
|
||||
import type { ChatSession, Agent } from "@multica/core/types";
|
||||
|
||||
@@ -21,7 +22,6 @@ export function ChatSessionHistory() {
|
||||
|
||||
const { data: sessions = [] } = useQuery(allChatSessionsOptions(wsId));
|
||||
const { data: agents = [] } = useQuery(agentListOptions(wsId));
|
||||
const archiveSession = useArchiveChatSession();
|
||||
|
||||
const agentMap = new Map(agents.map((a) => [a.id, a]));
|
||||
|
||||
@@ -32,27 +32,25 @@ export function ChatSessionHistory() {
|
||||
setShowHistory(false);
|
||||
};
|
||||
|
||||
const handleArchive = (e: React.MouseEvent, sessionId: string) => {
|
||||
e.stopPropagation();
|
||||
archiveSession.mutate(sessionId);
|
||||
if (activeSessionId === sessionId) {
|
||||
setActiveSession(null);
|
||||
}
|
||||
};
|
||||
|
||||
const activeSessions = sessions.filter((s) => s.status === "active");
|
||||
const archivedSessions = sessions.filter((s) => s.status === "archived");
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 border-b px-4 py-2.5">
|
||||
<button
|
||||
onClick={() => setShowHistory(false)}
|
||||
className="flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<ArrowLeft className="size-3.5" />
|
||||
</button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground"
|
||||
onClick={() => setShowHistory(false)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<ArrowLeft />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Back</TooltipContent>
|
||||
</Tooltip>
|
||||
<span className="text-sm font-medium">Chat History</span>
|
||||
</div>
|
||||
|
||||
@@ -64,94 +62,47 @@ export function ChatSessionHistory() {
|
||||
<span className="text-sm">No chat sessions yet</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{activeSessions.length > 0 && (
|
||||
<SessionGroup
|
||||
label="Active"
|
||||
sessions={activeSessions}
|
||||
agentMap={agentMap}
|
||||
activeSessionId={activeSessionId}
|
||||
onSelect={handleSelectSession}
|
||||
onArchive={handleArchive}
|
||||
<div>
|
||||
{sessions.map((session) => (
|
||||
<SessionItem
|
||||
key={session.id}
|
||||
session={session}
|
||||
agent={agentMap.get(session.agent_id) ?? null}
|
||||
isActive={session.id === activeSessionId}
|
||||
onSelect={() => handleSelectSession(session)}
|
||||
/>
|
||||
)}
|
||||
{archivedSessions.length > 0 && (
|
||||
<SessionGroup
|
||||
label="Archived"
|
||||
sessions={archivedSessions}
|
||||
agentMap={agentMap}
|
||||
activeSessionId={activeSessionId}
|
||||
onSelect={handleSelectSession}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionGroup({
|
||||
label,
|
||||
sessions,
|
||||
agentMap,
|
||||
activeSessionId,
|
||||
onSelect,
|
||||
onArchive,
|
||||
}: {
|
||||
label: string;
|
||||
sessions: ChatSession[];
|
||||
agentMap: Map<string, Agent>;
|
||||
activeSessionId: string | null;
|
||||
onSelect: (session: ChatSession) => void;
|
||||
onArchive?: (e: React.MouseEvent, sessionId: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<div className="px-4 pt-3 pb-1">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
{sessions.map((session) => (
|
||||
<SessionItem
|
||||
key={session.id}
|
||||
session={session}
|
||||
agent={agentMap.get(session.agent_id) ?? null}
|
||||
isActive={session.id === activeSessionId}
|
||||
onSelect={() => onSelect(session)}
|
||||
onArchive={onArchive ? (e) => onArchive(e, session.id) : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionItem({
|
||||
session,
|
||||
agent,
|
||||
isActive,
|
||||
onSelect,
|
||||
onArchive,
|
||||
}: {
|
||||
session: ChatSession;
|
||||
agent: Agent | null;
|
||||
isActive: boolean;
|
||||
onSelect: () => void;
|
||||
onArchive?: (e: React.MouseEvent) => void;
|
||||
}) {
|
||||
const timeAgo = formatTimeAgo(session.updated_at);
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onSelect}
|
||||
className={`group flex w-full items-start gap-3 px-4 py-2.5 text-left transition-colors hover:bg-accent/50 ${
|
||||
isActive ? "bg-accent/30" : ""
|
||||
}`}
|
||||
className={cn(
|
||||
"flex w-full items-start gap-3 px-4 py-2.5 text-left transition-colors hover:bg-accent/50",
|
||||
isActive && "bg-accent/30",
|
||||
)}
|
||||
>
|
||||
<Avatar className="size-6 shrink-0 mt-0.5">
|
||||
{agent?.avatar_url && <AvatarImage src={agent.avatar_url} />}
|
||||
<AvatarFallback className="bg-purple-100 text-purple-700 text-[10px]">
|
||||
<AvatarFallback className="bg-purple-100 text-purple-700">
|
||||
<Bot className="size-3" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
@@ -160,9 +111,6 @@ function SessionItem({
|
||||
<span className="truncate text-sm font-medium">
|
||||
{session.title || "Untitled"}
|
||||
</span>
|
||||
{session.status === "archived" && (
|
||||
<Archive className="size-3 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
{agent && (
|
||||
@@ -173,15 +121,6 @@ function SessionItem({
|
||||
<span className="text-xs text-muted-foreground/60">{timeAgo}</span>
|
||||
</div>
|
||||
</div>
|
||||
{onArchive && (
|
||||
<button
|
||||
onClick={onArchive}
|
||||
title="Archive"
|
||||
className="invisible group-hover:visible flex size-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-destructive shrink-0 mt-0.5"
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import React, { useCallback, useEffect, useRef } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Minus, Maximize2, Minimize2, Send, ChevronDown, Bot, Plus, History } from "lucide-react";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@multica/ui/components/ui/avatar";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@multica/ui/components/ui/dropdown-menu";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
@@ -26,19 +31,19 @@ import { useChatStore } from "@multica/core/chat";
|
||||
import { ChatMessageList } from "./chat-message-list";
|
||||
import { ChatInput } from "./chat-input";
|
||||
import { ChatSessionHistory } from "./chat-session-history";
|
||||
import { ChatResizeHandles } from "./chat-resize-handles";
|
||||
import { useChatResize } from "./use-chat-resize";
|
||||
import { useWS } from "@multica/core/realtime";
|
||||
import type { TaskMessagePayload, ChatDonePayload, Agent, ChatMessage } from "@multica/core/types";
|
||||
|
||||
export function ChatWindow() {
|
||||
const wsId = useWorkspaceId();
|
||||
const isOpen = useChatStore((s) => s.isOpen);
|
||||
const isFullscreen = useChatStore((s) => s.isFullscreen);
|
||||
const activeSessionId = useChatStore((s) => s.activeSessionId);
|
||||
const pendingTaskId = useChatStore((s) => s.pendingTaskId);
|
||||
const timelineItems = useChatStore((s) => s.timelineItems);
|
||||
const selectedAgentId = useChatStore((s) => s.selectedAgentId);
|
||||
const setOpen = useChatStore((s) => s.setOpen);
|
||||
const toggleFullscreen = useChatStore((s) => s.toggleFullscreen);
|
||||
const showHistory = useChatStore((s) => s.showHistory);
|
||||
const setActiveSession = useChatStore((s) => s.setActiveSession);
|
||||
const setPendingTask = useChatStore((s) => s.setPendingTask);
|
||||
@@ -46,7 +51,6 @@ export function ChatWindow() {
|
||||
const clearTimeline = useChatStore((s) => s.clearTimeline);
|
||||
const setSelectedAgentId = useChatStore((s) => s.setSelectedAgentId);
|
||||
const setShowHistory = useChatStore((s) => s.setShowHistory);
|
||||
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const { data: agents = [] } = useQuery(agentListOptions(wsId));
|
||||
const { data: members = [] } = useQuery(memberListOptions(wsId));
|
||||
@@ -221,57 +225,105 @@ export function ChatWindow() {
|
||||
[setSelectedAgentId, setActiveSession],
|
||||
);
|
||||
|
||||
if (!isOpen) return null;
|
||||
const windowRef = useRef<HTMLDivElement>(null);
|
||||
const { renderWidth, renderHeight, isAtMax, boundsReady, isDragging, toggleExpand, startDrag } = useChatResize(windowRef);
|
||||
|
||||
const hasMessages = messages.length > 0 || timelineItems.length > 0;
|
||||
|
||||
const containerClass = isFullscreen
|
||||
? "fixed inset-y-0 right-0 z-50 flex flex-col w-[50%] border-l bg-background shadow-2xl"
|
||||
: "fixed bottom-4 right-4 z-50 flex flex-col w-[420px] h-[600px] rounded-xl border bg-background shadow-2xl overflow-hidden";
|
||||
const isVisible = isOpen && boundsReady;
|
||||
|
||||
const containerClass = "absolute bottom-2 right-2 z-50 flex flex-col rounded-xl ring-1 ring-foreground/10 bg-sidebar shadow-2xl overflow-hidden";
|
||||
const containerStyle: React.CSSProperties = {
|
||||
width: `${renderWidth}px`,
|
||||
height: `${renderHeight}px`,
|
||||
opacity: isVisible ? 1 : 0,
|
||||
transform: isVisible ? "scale(1)" : "scale(0.95)",
|
||||
transformOrigin: "bottom right",
|
||||
pointerEvents: isOpen ? "auto" : "none",
|
||||
transition: isDragging
|
||||
? "none"
|
||||
: "width 200ms ease-out, height 200ms ease-out, opacity 150ms ease-out, transform 150ms ease-out",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={containerClass}>
|
||||
<div ref={windowRef} className={containerClass} style={containerStyle}>
|
||||
<ChatResizeHandles onDragStart={startDrag} />
|
||||
{/* Header */}
|
||||
{!showHistory && (
|
||||
<div className="flex items-center justify-between border-b px-4 py-2.5">
|
||||
<AgentSelector
|
||||
agents={availableAgents}
|
||||
activeAgent={activeAgent}
|
||||
userId={user?.id}
|
||||
onSelect={handleSelectAgent}
|
||||
/>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button
|
||||
onClick={() => setShowHistory(true)}
|
||||
title="Chat history"
|
||||
className="flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<History className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setActiveSession(null);
|
||||
clearTimeline();
|
||||
setPendingTask(null);
|
||||
}}
|
||||
title="New chat"
|
||||
className="flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={toggleFullscreen}
|
||||
title={isFullscreen ? "Exit fullscreen" : "Fullscreen"}
|
||||
className="flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
{isFullscreen ? <Minimize2 className="size-3.5" /> : <Maximize2 className="size-3.5" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setOpen(false)}
|
||||
title="Minimize"
|
||||
className="flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<Minus className="size-3.5" />
|
||||
</button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground"
|
||||
onClick={() => setShowHistory(true)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<History />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Chat history</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground"
|
||||
onClick={() => {
|
||||
setActiveSession(null);
|
||||
clearTimeline();
|
||||
setPendingTask(null);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Plus />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">New chat</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground"
|
||||
onClick={toggleExpand}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{isAtMax ? <Minimize2 /> : <Maximize2 />}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{isAtMax ? "Restore" : "Expand"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground"
|
||||
onClick={() => setOpen(false)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Minus />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Minimize</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -284,7 +336,6 @@ export function ChatWindow() {
|
||||
{hasMessages ? (
|
||||
<ChatMessageList
|
||||
messages={messages}
|
||||
agent={activeAgent}
|
||||
timelineItems={timelineItems}
|
||||
isWaiting={!!pendingTaskId}
|
||||
/>
|
||||
@@ -308,10 +359,12 @@ export function ChatWindow() {
|
||||
function AgentSelector({
|
||||
agents,
|
||||
activeAgent,
|
||||
userId,
|
||||
onSelect,
|
||||
}: {
|
||||
agents: Agent[];
|
||||
activeAgent: Agent | null;
|
||||
userId: string | undefined;
|
||||
onSelect: (agent: Agent) => void;
|
||||
}) {
|
||||
if (!activeAgent) {
|
||||
@@ -327,24 +380,48 @@ function AgentSelector({
|
||||
);
|
||||
}
|
||||
|
||||
const myAgents = agents.filter((a) => a.owner_id === userId);
|
||||
const othersAgents = agents.filter((a) => a.owner_id !== userId);
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="flex items-center gap-2 rounded-md px-1.5 py-1 -ml-1.5 transition-colors hover:bg-accent">
|
||||
<DropdownMenuTrigger className="flex items-center gap-2 rounded-md px-1.5 py-1 -ml-1.5 transition-colors hover:bg-accent aria-expanded:bg-accent">
|
||||
<AgentAvatarSmall agent={activeAgent} />
|
||||
<span className="text-sm font-medium">{activeAgent.name}</span>
|
||||
<ChevronDown className="size-3 text-muted-foreground" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
{agents.map((agent) => (
|
||||
<DropdownMenuItem
|
||||
key={agent.id}
|
||||
onClick={() => onSelect(agent)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<AgentAvatarSmall agent={agent} />
|
||||
<span>{agent.name}</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuContent align="start" className="max-h-60 w-auto max-w-56">
|
||||
{myAgents.length > 0 && (
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>My Agents</DropdownMenuLabel>
|
||||
{myAgents.map((agent) => (
|
||||
<DropdownMenuItem
|
||||
key={agent.id}
|
||||
onClick={() => onSelect(agent)}
|
||||
className="flex min-w-0 items-center gap-2"
|
||||
>
|
||||
<AgentAvatarSmall agent={agent} />
|
||||
<span className="truncate">{agent.name}</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
)}
|
||||
{myAgents.length > 0 && othersAgents.length > 0 && <DropdownMenuSeparator />}
|
||||
{othersAgents.length > 0 && (
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>Others</DropdownMenuLabel>
|
||||
{othersAgents.map((agent) => (
|
||||
<DropdownMenuItem
|
||||
key={agent.id}
|
||||
onClick={() => onSelect(agent)}
|
||||
className="flex min-w-0 items-center gap-2"
|
||||
>
|
||||
<AgentAvatarSmall agent={agent} />
|
||||
<span className="truncate">{agent.name}</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
@@ -354,7 +431,7 @@ function AgentAvatarSmall({ agent }: { agent: Agent }) {
|
||||
return (
|
||||
<Avatar className="size-5">
|
||||
{agent.avatar_url && <AvatarImage src={agent.avatar_url} />}
|
||||
<AvatarFallback className="bg-purple-100 text-purple-700 text-[10px]">
|
||||
<AvatarFallback className="bg-purple-100 text-purple-700">
|
||||
<Bot className="size-3" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user