mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-26 17:09:14 +02:00
Compare commits
116 Commits
fix/commen
...
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 | ||
|
|
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
|
||||
|
||||
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. -->
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -48,3 +48,5 @@ _features/
|
||||
*.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,39 +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, you **must** point the CLI to your server before logging in. The CLI defaults to the hosted Multica service — skipping this step means the daemon will authenticate against the wrong server.
|
||||
When connecting to a self-hosted Multica instance, the easiest approach is:
|
||||
|
||||
```bash
|
||||
# Local Docker Compose (default ports):
|
||||
export MULTICA_APP_URL=http://localhost:3000
|
||||
export MULTICA_SERVER_URL=ws://localhost:8080/ws
|
||||
# One command — configures for localhost, authenticates, starts daemon
|
||||
multica setup self-host
|
||||
|
||||
# Production with TLS:
|
||||
# export MULTICA_APP_URL=https://app.example.com
|
||||
# export MULTICA_SERVER_URL=wss://api.example.com/ws
|
||||
# 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 http://localhost:3000
|
||||
multica config set server_url ws://localhost:8080/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
|
||||
@@ -311,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
|
||||
@@ -324,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`."
|
||||
|
||||
---
|
||||
|
||||
|
||||
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`)。
|
||||
|
||||
455
SELF_HOSTING.md
455
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,297 +177,31 @@ Then start everything:
|
||||
docker compose -f docker-compose.selfhost.yml up -d
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
To run AI agents, you also need to set up the daemon on your local machine. See [Setting Up the Agent Daemon](#setting-up-the-agent-daemon) below.
|
||||
|
||||
### 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
|
||||
|
||||
The daemon runs on your local machine (not inside Docker). It detects installed AI agent CLIs, registers them as runtimes with the server, and executes tasks when agents are assigned work.
|
||||
|
||||
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. **Point the CLI to your server**
|
||||
|
||||
The CLI defaults to the hosted Multica service. For self-hosted setups, you **must** set the server URLs before logging in:
|
||||
|
||||
```bash
|
||||
# Local Docker Compose deployment (default ports):
|
||||
export MULTICA_APP_URL=http://localhost:3000
|
||||
export MULTICA_SERVER_URL=ws://localhost:8080/ws
|
||||
|
||||
# Production deployment with TLS:
|
||||
# export MULTICA_APP_URL=https://app.example.com
|
||||
# export MULTICA_SERVER_URL=wss://api.example.com/ws
|
||||
```
|
||||
|
||||
> **Note:** Use `http://` and `ws://` for local deployments without TLS. Use `https://` and `wss://` for production deployments behind a TLS-terminating reverse proxy.
|
||||
|
||||
You can also set these persistently so you don't need to export them each time:
|
||||
|
||||
```bash
|
||||
multica config set app_url http://localhost:3000
|
||||
multica config set server_url ws://localhost:8080/ws
|
||||
```
|
||||
|
||||
4. **Authenticate and start**
|
||||
|
||||
```bash
|
||||
# Login (opens browser to your frontend)
|
||||
multica login
|
||||
|
||||
# Start the daemon
|
||||
multica daemon start
|
||||
```
|
||||
|
||||
The login flow opens your browser, authenticates you via the frontend, and stores a personal access token locally. The daemon then uses this token to register with the backend.
|
||||
|
||||
To verify the daemon is running:
|
||||
|
||||
```bash
|
||||
multica daemon status
|
||||
```
|
||||
|
||||
## 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>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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,46 @@ 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",
|
||||
|
||||
@@ -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,46 @@ 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",
|
||||
|
||||
@@ -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";
|
||||
|
||||
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",
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -52,6 +52,7 @@ import type {
|
||||
ReorderPinsRequest,
|
||||
} from "../types";
|
||||
import { type Logger, noopLogger } from "../logger";
|
||||
import { createRequestId } from "../utils";
|
||||
|
||||
export interface ApiClientOptions {
|
||||
logger?: Logger;
|
||||
@@ -63,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;
|
||||
@@ -76,6 +89,10 @@ export class ApiClient {
|
||||
this.logger = options?.logger ?? noopLogger;
|
||||
}
|
||||
|
||||
getBaseUrl(): string {
|
||||
return this.baseUrl;
|
||||
}
|
||||
|
||||
setToken(token: string | null) {
|
||||
this.token = token;
|
||||
}
|
||||
@@ -84,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;
|
||||
}
|
||||
|
||||
@@ -108,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";
|
||||
|
||||
@@ -132,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` });
|
||||
@@ -167,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");
|
||||
}
|
||||
@@ -234,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" });
|
||||
}
|
||||
@@ -432,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[]> {
|
||||
@@ -610,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 });
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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) });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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";
|
||||
@@ -7,16 +8,17 @@ 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), (old) =>
|
||||
qc.setQueryData<PinnedItem[]>(pinKeys.list(wsId, userId), (old) =>
|
||||
old ? [...old, newPin] : [newPin],
|
||||
);
|
||||
},
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({ queryKey: pinKeys.list(wsId) });
|
||||
qc.invalidateQueries({ queryKey: pinKeys.list(wsId, userId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -24,22 +26,23 @@ export function useCreatePin() {
|
||||
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) });
|
||||
const prev = qc.getQueryData<PinnedItem[]>(pinKeys.list(wsId));
|
||||
qc.setQueryData<PinnedItem[]>(pinKeys.list(wsId), (old) =>
|
||||
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), ctx.prev);
|
||||
if (ctx?.prev) qc.setQueryData(pinKeys.list(wsId, userId), ctx.prev);
|
||||
},
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({ queryKey: pinKeys.list(wsId) });
|
||||
qc.invalidateQueries({ queryKey: pinKeys.list(wsId, userId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -47,19 +50,20 @@ export function useDeletePin() {
|
||||
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) });
|
||||
const prev = qc.getQueryData<PinnedItem[]>(pinKeys.list(wsId));
|
||||
qc.setQueryData<PinnedItem[]>(pinKeys.list(wsId), 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), ctx.prev);
|
||||
if (ctx?.prev) qc.setQueryData(pinKeys.list(wsId, userId), ctx.prev);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,13 +2,13 @@ import { queryOptions } from "@tanstack/react-query";
|
||||
import { api } from "../api";
|
||||
|
||||
export const pinKeys = {
|
||||
all: (wsId: string) => ["pins", wsId] as const,
|
||||
list: (wsId: string) => [...pinKeys.all(wsId), "list"] as const,
|
||||
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) {
|
||||
export function pinListOptions(wsId: string, userId: string) {
|
||||
return queryOptions({
|
||||
queryKey: pinKeys.list(wsId),
|
||||
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 };
|
||||
|
||||
|
||||
@@ -20,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,
|
||||
@@ -102,7 +102,8 @@ export function useRealtimeSync(
|
||||
},
|
||||
pin: () => {
|
||||
const wsId = workspaceStore.getState().workspace?.id;
|
||||
if (wsId) qc.invalidateQueries({ queryKey: pinKeys.all(wsId) });
|
||||
const userId = authStore.getState().user?.id;
|
||||
if (wsId && userId) qc.invalidateQueries({ queryKey: pinKeys.all(wsId, userId) });
|
||||
},
|
||||
daemon: () => {
|
||||
const wsId = workspaceStore.getState().workspace?.id;
|
||||
@@ -250,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);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -262,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);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -270,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",
|
||||
|
||||
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 };
|
||||
@@ -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>
|
||||
|
||||
135
packages/views/chat/components/use-chat-resize.ts
Normal file
135
packages/views/chat/components/use-chat-resize.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
"use client";
|
||||
|
||||
import React, { useRef, useCallback, useState, useEffect } from "react";
|
||||
import { CHAT_MIN_W, CHAT_MIN_H, useChatStore } from "@multica/core/chat";
|
||||
|
||||
type DragDir = "left" | "top" | "corner";
|
||||
|
||||
const MAX_RATIO = 0.9;
|
||||
const FALLBACK_MAX_W = 800;
|
||||
const FALLBACK_MAX_H = 700;
|
||||
|
||||
function clamp(v: number, min: number, max: number) {
|
||||
return Math.max(min, Math.min(max, v));
|
||||
}
|
||||
|
||||
export function useChatResize(
|
||||
windowRef: React.RefObject<HTMLDivElement | null>,
|
||||
) {
|
||||
const chatWidth = useChatStore((s) => s.chatWidth);
|
||||
const chatHeight = useChatStore((s) => s.chatHeight);
|
||||
const isExpanded = useChatStore((s) => s.isExpanded);
|
||||
const setChatSize = useChatStore((s) => s.setChatSize);
|
||||
const setExpanded = useChatStore((s) => s.setExpanded);
|
||||
|
||||
// ── Container bounds via ResizeObserver ────────────────────────────────
|
||||
const boundsRef = useRef({ maxW: FALLBACK_MAX_W, maxH: FALLBACK_MAX_H });
|
||||
const [boundsReady, setBoundsReady] = useState(false);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [, setRevision] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const el = windowRef.current;
|
||||
const parent = el?.parentElement;
|
||||
if (!parent) return;
|
||||
|
||||
const update = () => {
|
||||
boundsRef.current = {
|
||||
maxW: Math.floor(parent.clientWidth * MAX_RATIO),
|
||||
maxH: Math.floor(parent.clientHeight * MAX_RATIO),
|
||||
};
|
||||
setBoundsReady(true);
|
||||
setRevision((r) => r + 1);
|
||||
};
|
||||
|
||||
// Measure immediately (parent is already in DOM at this point)
|
||||
update();
|
||||
|
||||
const ro = new ResizeObserver(update);
|
||||
ro.observe(parent);
|
||||
return () => ro.disconnect();
|
||||
}, [windowRef]);
|
||||
|
||||
// ── Derive rendered size ──────────────────────────────────────────────
|
||||
const { maxW, maxH } = boundsRef.current;
|
||||
|
||||
const renderWidth = isExpanded ? maxW : clamp(chatWidth, CHAT_MIN_W, maxW);
|
||||
const renderHeight = isExpanded ? maxH : clamp(chatHeight, CHAT_MIN_H, maxH);
|
||||
|
||||
// ── Expand / Restore ──────────────────────────────────────────────────
|
||||
const isAtMax = renderWidth >= maxW && renderHeight >= maxH;
|
||||
|
||||
const toggleExpand = useCallback(() => {
|
||||
if (isExpanded || isAtMax) {
|
||||
setChatSize(CHAT_MIN_W, CHAT_MIN_H);
|
||||
} else {
|
||||
setExpanded(true);
|
||||
}
|
||||
}, [isExpanded, isAtMax, setChatSize, setExpanded]);
|
||||
|
||||
// ── Drag ──────────────────────────────────────────────────────────────
|
||||
const dragRef = useRef<{
|
||||
startX: number;
|
||||
startY: number;
|
||||
startW: number;
|
||||
startH: number;
|
||||
dir: DragDir;
|
||||
} | null>(null);
|
||||
|
||||
const startDrag = useCallback(
|
||||
(e: React.PointerEvent, dir: DragDir) => {
|
||||
e.preventDefault();
|
||||
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
||||
|
||||
dragRef.current = {
|
||||
startX: e.clientX,
|
||||
startY: e.clientY,
|
||||
startW: renderWidth,
|
||||
startH: renderHeight,
|
||||
dir,
|
||||
};
|
||||
setIsDragging(true);
|
||||
|
||||
const onPointerMove = (ev: PointerEvent) => {
|
||||
const d = dragRef.current;
|
||||
if (!d) return;
|
||||
|
||||
const { maxW: mw, maxH: mh } = boundsRef.current;
|
||||
|
||||
const rawW =
|
||||
dir === "left" || dir === "corner"
|
||||
? d.startW - (ev.clientX - d.startX)
|
||||
: d.startW;
|
||||
const rawH =
|
||||
dir === "top" || dir === "corner"
|
||||
? d.startH - (ev.clientY - d.startY)
|
||||
: d.startH;
|
||||
|
||||
setChatSize(clamp(rawW, CHAT_MIN_W, mw), clamp(rawH, CHAT_MIN_H, mh));
|
||||
};
|
||||
|
||||
const onPointerUp = () => {
|
||||
dragRef.current = null;
|
||||
setIsDragging(false);
|
||||
document.removeEventListener("pointermove", onPointerMove);
|
||||
document.removeEventListener("pointerup", onPointerUp);
|
||||
document.body.style.cursor = "";
|
||||
document.body.style.userSelect = "";
|
||||
};
|
||||
|
||||
document.addEventListener("pointermove", onPointerMove);
|
||||
document.addEventListener("pointerup", onPointerUp);
|
||||
|
||||
const cursorMap: Record<DragDir, string> = {
|
||||
left: "col-resize",
|
||||
top: "row-resize",
|
||||
corner: "nw-resize",
|
||||
};
|
||||
document.body.style.cursor = cursorMap[dir];
|
||||
document.body.style.userSelect = "none";
|
||||
},
|
||||
[renderWidth, renderHeight, setChatSize],
|
||||
);
|
||||
|
||||
return { renderWidth, renderHeight, isAtMax, boundsReady, isDragging, toggleExpand, startDrag };
|
||||
}
|
||||
549
packages/views/editor/bubble-menu.tsx
Normal file
549
packages/views/editor/bubble-menu.tsx
Normal file
@@ -0,0 +1,549 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { BubbleMenu } from "@tiptap/react/menus";
|
||||
import type { Editor } from "@tiptap/core";
|
||||
import { NodeSelection } from "@tiptap/pm/state";
|
||||
import type { EditorState } from "@tiptap/pm/state";
|
||||
import type { EditorView } from "@tiptap/pm/view";
|
||||
import { Toggle } from "@multica/ui/components/ui/toggle";
|
||||
import { Separator } from "@multica/ui/components/ui/separator";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipTrigger,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
} from "@multica/ui/components/ui/tooltip";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
} from "@multica/ui/components/ui/dropdown-menu";
|
||||
import { Input } from "@multica/ui/components/ui/input";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import {
|
||||
Bold,
|
||||
Italic,
|
||||
Strikethrough,
|
||||
Code,
|
||||
Link2,
|
||||
List,
|
||||
ListOrdered,
|
||||
Quote,
|
||||
ChevronDown,
|
||||
Check,
|
||||
X,
|
||||
Unlink,
|
||||
Type,
|
||||
Heading1,
|
||||
Heading2,
|
||||
Heading3,
|
||||
} from "lucide-react";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Force re-render when editor state changes so isActive() returns fresh values */
|
||||
function useEditorTransactionUpdate(editor: Editor) {
|
||||
const [, setState] = useState(0);
|
||||
useEffect(() => {
|
||||
const handler = () => setState((n) => n + 1);
|
||||
editor.on("transaction", handler);
|
||||
return () => {
|
||||
editor.off("transaction", handler);
|
||||
};
|
||||
}, [editor]);
|
||||
}
|
||||
|
||||
function shouldShowBubbleMenu({
|
||||
editor,
|
||||
state,
|
||||
from,
|
||||
to,
|
||||
}: {
|
||||
editor: Editor;
|
||||
view: EditorView;
|
||||
state: EditorState;
|
||||
oldState?: EditorState;
|
||||
from: number;
|
||||
to: number;
|
||||
}) {
|
||||
if (!editor.isEditable) return false;
|
||||
if (state.selection.empty) return false;
|
||||
if (!state.doc.textBetween(from, to).length) return false;
|
||||
if (state.selection instanceof NodeSelection) return false;
|
||||
const $from = state.doc.resolve(from);
|
||||
if ($from.parent.type.name === "codeBlock") return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Detect macOS for keyboard shortcut labels */
|
||||
const isMac =
|
||||
typeof navigator !== "undefined" && /Mac/.test(navigator.platform);
|
||||
const mod = isMac ? "\u2318" : "Ctrl";
|
||||
|
||||
/** Hoisted to avoid new reference on every render (triggers plugin updateOptions) */
|
||||
const BUBBLE_MENU_OPTIONS = {
|
||||
strategy: "fixed" as const,
|
||||
placement: "top" as const,
|
||||
offset: 8,
|
||||
flip: true,
|
||||
shift: { padding: 8 },
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mark Toggle Button
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type InlineMark = "bold" | "italic" | "strike" | "code";
|
||||
|
||||
const toggleMarkActions: Record<InlineMark, (editor: Editor) => void> = {
|
||||
bold: (e) => e.chain().focus().toggleBold().run(),
|
||||
italic: (e) => e.chain().focus().toggleItalic().run(),
|
||||
strike: (e) => e.chain().focus().toggleStrike().run(),
|
||||
code: (e) => e.chain().focus().toggleCode().run(),
|
||||
};
|
||||
|
||||
function MarkButton({
|
||||
editor,
|
||||
mark,
|
||||
icon: Icon,
|
||||
label,
|
||||
shortcut,
|
||||
}: {
|
||||
editor: Editor;
|
||||
mark: InlineMark;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
label: string;
|
||||
shortcut: string;
|
||||
}) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Toggle
|
||||
size="sm"
|
||||
pressed={editor.isActive(mark)}
|
||||
onPressedChange={() => toggleMarkActions[mark](editor)}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Icon className="size-3.5" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={8}>
|
||||
{label}
|
||||
<span className="ml-1.5 text-muted-foreground">{shortcut}</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Link Edit Bar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function LinkEditBar({
|
||||
editor,
|
||||
onClose,
|
||||
}: {
|
||||
editor: Editor;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const existingHref = editor.getAttributes("link").href as string | undefined;
|
||||
const [url, setUrl] = useState(existingHref ?? "");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// autoFocus workaround — setTimeout to ensure the input is mounted
|
||||
const t = setTimeout(() => inputRef.current?.focus(), 0);
|
||||
return () => clearTimeout(t);
|
||||
}, []);
|
||||
|
||||
const apply = useCallback(() => {
|
||||
let href = url.trim();
|
||||
if (!href) {
|
||||
editor.chain().focus().extendMarkRange("link").unsetLink().run();
|
||||
} else {
|
||||
if (!/^https?:\/\//.test(href) && !href.startsWith("/")) {
|
||||
href = `https://${href}`;
|
||||
}
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.extendMarkRange("link")
|
||||
.setLink({ href })
|
||||
.run();
|
||||
}
|
||||
onClose();
|
||||
}, [editor, url, onClose]);
|
||||
|
||||
const remove = useCallback(() => {
|
||||
editor.chain().focus().extendMarkRange("link").unsetLink().run();
|
||||
onClose();
|
||||
}, [editor, onClose]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="bubble-menu-link-edit"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder="https://..."
|
||||
aria-label="URL"
|
||||
className="h-7 flex-1 text-xs"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
apply();
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
editor.commands.focus();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
onClick={apply}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<Check className="size-3.5" />
|
||||
</Button>
|
||||
{existingHref && (
|
||||
<Button
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
onClick={remove}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<Unlink className="size-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
onClose();
|
||||
editor.commands.focus();
|
||||
}}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Heading Dropdown
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function HeadingDropdown({
|
||||
editor,
|
||||
onOpenChange,
|
||||
}: {
|
||||
editor: Editor;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const activeLevel = [1, 2, 3].find((l) =>
|
||||
editor.isActive("heading", { level: l }),
|
||||
);
|
||||
|
||||
const label = activeLevel ? `H${activeLevel}` : "Text";
|
||||
|
||||
const items = [
|
||||
{
|
||||
label: "Normal Text",
|
||||
icon: Type,
|
||||
active: !activeLevel,
|
||||
action: () => editor.chain().focus().setParagraph().run(),
|
||||
},
|
||||
{
|
||||
label: "Heading 1",
|
||||
icon: Heading1,
|
||||
active: activeLevel === 1,
|
||||
action: () => editor.chain().focus().toggleHeading({ level: 1 }).run(),
|
||||
},
|
||||
{
|
||||
label: "Heading 2",
|
||||
icon: Heading2,
|
||||
active: activeLevel === 2,
|
||||
action: () => editor.chain().focus().toggleHeading({ level: 2 }).run(),
|
||||
},
|
||||
{
|
||||
label: "Heading 3",
|
||||
icon: Heading3,
|
||||
active: activeLevel === 3,
|
||||
action: () => editor.chain().focus().toggleHeading({ level: 3 }).run(),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<DropdownMenu onOpenChange={onOpenChange}>
|
||||
<DropdownMenuTrigger
|
||||
className="inline-flex h-7 items-center gap-0.5 rounded-md px-1.5 text-xs font-medium hover:bg-muted"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
{label}
|
||||
<ChevronDown className="size-3" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
side="bottom"
|
||||
sideOffset={8}
|
||||
align="start"
|
||||
className="w-auto"
|
||||
>
|
||||
{items.map((item) => (
|
||||
<DropdownMenuItem
|
||||
key={item.label}
|
||||
onClick={item.action}
|
||||
className="gap-2 text-xs"
|
||||
>
|
||||
<item.icon className="size-3.5" />
|
||||
{item.label}
|
||||
{item.active && <Check className="ml-auto size-3.5" />}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// List Dropdown
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ListDropdown({
|
||||
editor,
|
||||
onOpenChange,
|
||||
}: {
|
||||
editor: Editor;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const isBullet = editor.isActive("bulletList");
|
||||
const isOrdered = editor.isActive("orderedList");
|
||||
|
||||
return (
|
||||
<DropdownMenu onOpenChange={onOpenChange}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<DropdownMenuTrigger
|
||||
className="inline-flex h-7 items-center gap-0.5 rounded-md px-1.5 text-xs font-medium hover:bg-muted aria-pressed:bg-muted"
|
||||
aria-pressed={isBullet || isOrdered}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<List className="size-3.5" />
|
||||
<ChevronDown className="size-3" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={8}>
|
||||
List
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent
|
||||
side="bottom"
|
||||
sideOffset={8}
|
||||
align="start"
|
||||
className="w-auto"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||
className="gap-2 text-xs"
|
||||
>
|
||||
<List className="size-3.5" />
|
||||
Bullet List
|
||||
{isBullet && <Check className="ml-auto size-3.5" />}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||||
className="gap-2 text-xs"
|
||||
>
|
||||
<ListOrdered className="size-3.5" />
|
||||
Ordered List
|
||||
{isOrdered && <Check className="ml-auto size-3.5" />}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main Bubble Menu
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function EditorBubbleMenu({ editor }: { editor: Editor }) {
|
||||
const [mode, setMode] = useState<"toolbar" | "link-edit">("toolbar");
|
||||
const [focused, setFocused] = useState(editor.view.hasFocus());
|
||||
const modeRef = useRef(mode);
|
||||
modeRef.current = mode;
|
||||
// Track whether a child dropdown is open — blur during dropdown interaction should not hide
|
||||
const menuOpenRef = useRef(false);
|
||||
const handleMenuOpenChange = useCallback((open: boolean) => {
|
||||
menuOpenRef.current = open;
|
||||
}, []);
|
||||
|
||||
useEditorTransactionUpdate(editor);
|
||||
|
||||
// Hide bubble menu when editor loses focus (but not when a child dropdown is open)
|
||||
useEffect(() => {
|
||||
const onFocus = () => setFocused(true);
|
||||
const onBlur = () => {
|
||||
setTimeout(() => {
|
||||
if (!editor.isDestroyed && !editor.view.hasFocus() && !menuOpenRef.current) {
|
||||
setFocused(false);
|
||||
}
|
||||
}, 0);
|
||||
};
|
||||
editor.on("focus", onFocus);
|
||||
editor.on("blur", onBlur);
|
||||
return () => {
|
||||
editor.off("focus", onFocus);
|
||||
editor.off("blur", onBlur);
|
||||
};
|
||||
}, [editor]);
|
||||
|
||||
// Reset to toolbar mode when selection changes — but not during link editing.
|
||||
// Also restore focused state (scroll sets it to false, new selection should bring it back).
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
if (modeRef.current !== "link-edit") setMode("toolbar");
|
||||
if (editor.view.hasFocus()) setFocused(true);
|
||||
};
|
||||
editor.on("selectionUpdate", handler);
|
||||
return () => {
|
||||
editor.off("selectionUpdate", handler);
|
||||
};
|
||||
}, [editor]);
|
||||
|
||||
// Hide when an ancestor of the editor scrolls (capture phase catches non-bubbling scroll events).
|
||||
// Scoped to ancestors only — dropdown/sidebar scrolls won't trigger this.
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
const target = e.target;
|
||||
if (
|
||||
target instanceof HTMLElement &&
|
||||
target.contains(editor.view.dom)
|
||||
) {
|
||||
setFocused(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("scroll", handler, true);
|
||||
return () => document.removeEventListener("scroll", handler, true);
|
||||
}, [editor]);
|
||||
|
||||
const openLinkEdit = useCallback(() => {
|
||||
setMode("link-edit");
|
||||
}, []);
|
||||
|
||||
const closeLinkEdit = useCallback(() => {
|
||||
setMode("toolbar");
|
||||
}, []);
|
||||
|
||||
if (!focused) return null;
|
||||
|
||||
return (
|
||||
<BubbleMenu
|
||||
editor={editor}
|
||||
shouldShow={shouldShowBubbleMenu}
|
||||
updateDelay={0}
|
||||
style={{ zIndex: 50 }}
|
||||
options={BUBBLE_MENU_OPTIONS}
|
||||
>
|
||||
{mode === "link-edit" ? (
|
||||
<LinkEditBar editor={editor} onClose={closeLinkEdit} />
|
||||
) : (
|
||||
<TooltipProvider delay={300}>
|
||||
<div className="bubble-menu">
|
||||
{/* Group 1: Inline Marks */}
|
||||
<MarkButton
|
||||
editor={editor}
|
||||
mark="bold"
|
||||
icon={Bold}
|
||||
label="Bold"
|
||||
shortcut={`${mod}+B`}
|
||||
/>
|
||||
<MarkButton
|
||||
editor={editor}
|
||||
mark="italic"
|
||||
icon={Italic}
|
||||
label="Italic"
|
||||
shortcut={`${mod}+I`}
|
||||
/>
|
||||
<MarkButton
|
||||
editor={editor}
|
||||
mark="strike"
|
||||
icon={Strikethrough}
|
||||
label="Strikethrough"
|
||||
shortcut={`${mod}+Shift+S`}
|
||||
/>
|
||||
<MarkButton
|
||||
editor={editor}
|
||||
mark="code"
|
||||
icon={Code}
|
||||
label="Code"
|
||||
shortcut={`${mod}+E`}
|
||||
/>
|
||||
|
||||
<Separator orientation="vertical" className="mx-0.5 h-5" />
|
||||
|
||||
{/* Group 2: Link */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Toggle
|
||||
size="sm"
|
||||
pressed={editor.isActive("link")}
|
||||
onPressedChange={openLinkEdit}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Link2 className="size-3.5" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={8}>
|
||||
Link
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Separator orientation="vertical" className="mx-0.5 h-5" />
|
||||
|
||||
{/* Group 3: Block Transforms */}
|
||||
<HeadingDropdown editor={editor} onOpenChange={handleMenuOpenChange} />
|
||||
<ListDropdown editor={editor} onOpenChange={handleMenuOpenChange} />
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Toggle
|
||||
size="sm"
|
||||
pressed={editor.isActive("blockquote")}
|
||||
onPressedChange={() =>
|
||||
editor.chain().focus().toggleBlockquote().run()
|
||||
}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Quote className="size-3.5" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={8}>
|
||||
Quote
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</BubbleMenu>
|
||||
);
|
||||
}
|
||||
|
||||
export { EditorBubbleMenu };
|
||||
@@ -457,4 +457,33 @@
|
||||
|
||||
.rich-text-editor .image-toolbar button:hover {
|
||||
background: color-mix(in srgb, white 15%, transparent);
|
||||
}
|
||||
|
||||
/* Bubble menu — floating toolbar pill */
|
||||
.bubble-menu {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1px;
|
||||
padding: 0.25rem;
|
||||
background: var(--popover);
|
||||
border: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
|
||||
border-radius: var(--radius);
|
||||
box-shadow:
|
||||
0 4px 12px color-mix(in srgb, black 12%, transparent),
|
||||
0 0 0 1px color-mix(in srgb, black 4%, transparent);
|
||||
}
|
||||
|
||||
/* Link edit mode — inline URL input */
|
||||
.bubble-menu-link-edit {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem;
|
||||
background: var(--popover);
|
||||
border: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
|
||||
border-radius: var(--radius);
|
||||
box-shadow:
|
||||
0 4px 12px color-mix(in srgb, black 12%, transparent),
|
||||
0 0 0 1px color-mix(in srgb, black 4%, transparent);
|
||||
min-width: 300px;
|
||||
}
|
||||
@@ -38,6 +38,7 @@ import { useQueryClient } from "@tanstack/react-query";
|
||||
import { createEditorExtensions } from "./extensions";
|
||||
import { uploadAndInsertFile } from "./extensions/file-upload";
|
||||
import { preprocessMarkdown } from "./utils/preprocess";
|
||||
import { EditorBubbleMenu } from "./bubble-menu";
|
||||
import "./content-editor.css";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -210,6 +211,7 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
|
||||
return (
|
||||
<div className="relative min-h-full">
|
||||
<EditorContent editor={editor} />
|
||||
{editable && <EditorBubbleMenu editor={editor} />}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Extension } from "@tiptap/core";
|
||||
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
||||
import type { UploadResult } from "@multica/core/hooks/use-file-upload";
|
||||
import { createSafeId } from "@multica/core/utils";
|
||||
|
||||
/** Find and remove a fileCard node by uploadId. */
|
||||
|
||||
@@ -109,7 +110,7 @@ export async function uploadAndInsertFile(
|
||||
}
|
||||
} else {
|
||||
// Non-image: insert skeleton fileCard → upload → finalize with real URL
|
||||
const uploadId = crypto.randomUUID();
|
||||
const uploadId = createSafeId();
|
||||
const cardAttrs = { filename: file.name, href: "", fileSize: file.size, uploading: true, uploadId };
|
||||
const insertContent = { type: "fileCard", attrs: cardAttrs };
|
||||
if (pos !== undefined) {
|
||||
|
||||
@@ -53,7 +53,7 @@ function IssueMention({
|
||||
}) {
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: issues = [] } = useQuery(issueListOptions(wsId));
|
||||
const { openInNewTab } = useNavigation();
|
||||
const { push, openInNewTab } = useNavigation();
|
||||
const issue = issues.find((i) => i.id === issueId);
|
||||
|
||||
const issuePath = `/issues/${issueId}`;
|
||||
@@ -61,11 +61,13 @@ function IssueMention({
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (openInNewTab) {
|
||||
openInNewTab(issuePath, tabTitle);
|
||||
} else {
|
||||
window.open(issuePath, "_blank", "noopener,noreferrer");
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey) {
|
||||
if (openInNewTab) {
|
||||
openInNewTab(issuePath, tabTitle);
|
||||
}
|
||||
return;
|
||||
}
|
||||
push(issuePath);
|
||||
};
|
||||
|
||||
const cardClass =
|
||||
|
||||
@@ -86,7 +86,7 @@ function urlTransform(url: string): string {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function IssueMentionLink({ issueId, label }: { issueId: string; label?: string }) {
|
||||
const { openInNewTab } = useNavigation();
|
||||
const { push, openInNewTab } = useNavigation();
|
||||
const path = `/issues/${issueId}`;
|
||||
return (
|
||||
<span
|
||||
@@ -94,11 +94,13 @@ function IssueMentionLink({ issueId, label }: { issueId: string; label?: string
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (openInNewTab) {
|
||||
openInNewTab(path, label);
|
||||
} else {
|
||||
window.open(path, "_blank", "noopener,noreferrer");
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey) {
|
||||
if (openInNewTab) {
|
||||
openInNewTab(path, label);
|
||||
}
|
||||
return;
|
||||
}
|
||||
push(path);
|
||||
}}
|
||||
>
|
||||
<IssueMentionCard issueId={issueId} fallbackLabel={label} />
|
||||
|
||||
@@ -119,20 +119,40 @@ export function AgentLiveCard({ issueId }: AgentLiveCardProps) {
|
||||
let cancelled = false;
|
||||
api.getActiveTasksForIssue(issueId).then(({ tasks }) => {
|
||||
if (cancelled || tasks.length === 0) return;
|
||||
const newStates = new Map<string, TaskState>();
|
||||
const loadPromises = tasks.map(async (task) => {
|
||||
try {
|
||||
const msgs = await api.listTaskMessages(task.id);
|
||||
|
||||
// Show cards immediately with empty timeline
|
||||
setTaskStates((prev) => {
|
||||
const next = new Map(prev);
|
||||
for (const task of tasks) {
|
||||
if (!next.has(task.id)) {
|
||||
next.set(task.id, { task, items: [] });
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
// Load messages per task in the background
|
||||
for (const task of tasks) {
|
||||
api.listTaskMessages(task.id).then((msgs) => {
|
||||
if (cancelled) return;
|
||||
const timeline = buildTimeline(msgs);
|
||||
for (const m of msgs) seenSeqs.current.add(`${m.task_id}:${m.seq}`);
|
||||
newStates.set(task.id, { task, items: timeline });
|
||||
} catch {
|
||||
newStates.set(task.id, { task, items: [] });
|
||||
}
|
||||
});
|
||||
Promise.all(loadPromises).then(() => {
|
||||
if (!cancelled) setTaskStates(newStates);
|
||||
});
|
||||
setTaskStates((prev) => {
|
||||
const next = new Map(prev);
|
||||
const existing = next.get(task.id);
|
||||
if (existing) {
|
||||
// Merge: keep any WS-delivered items not in the loaded batch
|
||||
const loadedSeqs = new Set(timeline.map((i) => i.seq));
|
||||
const wsOnly = existing.items.filter((i) => !loadedSeqs.has(i.seq));
|
||||
const merged = [...timeline, ...wsOnly].sort((a, b) => a.seq - b.seq);
|
||||
next.set(task.id, { task: existing.task, items: merged });
|
||||
} else {
|
||||
next.set(task.id, { task, items: timeline });
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}).catch(console.error);
|
||||
}
|
||||
}).catch(console.error);
|
||||
|
||||
return () => { cancelled = true; };
|
||||
@@ -188,7 +208,9 @@ export function AgentLiveCard({ issueId }: AgentLiveCardProps) {
|
||||
// Pick up newly dispatched tasks
|
||||
useWSEvent(
|
||||
"task:dispatch",
|
||||
useCallback(() => {
|
||||
useCallback((payload: unknown) => {
|
||||
const p = payload as { issue_id?: string };
|
||||
if (p.issue_id && p.issue_id !== issueId) return;
|
||||
api.getActiveTasksForIssue(issueId).then(({ tasks }) => {
|
||||
setTaskStates((prev) => {
|
||||
const next = new Map(prev);
|
||||
|
||||
@@ -66,14 +66,14 @@ function CommentInput({ issueId, onSubmit }: CommentInputProps) {
|
||||
onSelect={(file) => editorRef.current?.uploadFile(file)}
|
||||
/>
|
||||
<Button
|
||||
size="icon-xs"
|
||||
size="icon-sm"
|
||||
disabled={isEmpty || submitting}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{submitting ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
<Loader2 className="animate-spin" />
|
||||
) : (
|
||||
<ArrowUp className="h-3.5 w-3.5" />
|
||||
<ArrowUp />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -94,7 +94,10 @@ vi.mock("../../editor", () => ({
|
||||
ReadonlyContent: ({ content }: { content: string }) => (
|
||||
<div data-testid="readonly-content">{content}</div>
|
||||
),
|
||||
ContentEditor: forwardRef(({ defaultValue, onUpdate, placeholder }: any, ref: any) => {
|
||||
ContentEditor: forwardRef(function MockContentEditor(
|
||||
{ defaultValue, onUpdate, placeholder }: any,
|
||||
ref: any,
|
||||
) {
|
||||
const valueRef = useRef(defaultValue || "");
|
||||
const [value, setValue] = useState(defaultValue || "");
|
||||
useImperativeHandle(ref, () => ({
|
||||
@@ -116,7 +119,10 @@ vi.mock("../../editor", () => ({
|
||||
/>
|
||||
);
|
||||
}),
|
||||
TitleEditor: forwardRef(({ defaultValue, placeholder, onBlur, onChange }: any, ref: any) => {
|
||||
TitleEditor: forwardRef(function MockTitleEditor(
|
||||
{ defaultValue, placeholder, onBlur, onChange }: any,
|
||||
ref: any,
|
||||
) {
|
||||
const valueRef = useRef(defaultValue || "");
|
||||
const [value, setValue] = useState(defaultValue || "");
|
||||
useImperativeHandle(ref, () => ({
|
||||
|
||||
@@ -195,6 +195,7 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
const id = issueId;
|
||||
const router = useNavigation();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const userId = useAuthStore((s) => s.user?.id);
|
||||
const workspace = useWorkspaceStore((s) => s.workspace);
|
||||
|
||||
// Issue navigation — read from TQ list cache
|
||||
@@ -264,7 +265,10 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
const { data: usage } = useQuery(issueUsageOptions(id));
|
||||
|
||||
// Pinned state
|
||||
const { data: pinnedItems = [] } = useQuery(pinListOptions(wsId));
|
||||
const { data: pinnedItems = [] } = useQuery({
|
||||
...pinListOptions(wsId, userId ?? ""),
|
||||
enabled: !!userId,
|
||||
});
|
||||
const isPinned = pinnedItems.some((p) => p.item_type === "issue" && p.item_id === id);
|
||||
const createPin = useCreatePin();
|
||||
const deletePin = useDeletePin();
|
||||
@@ -501,7 +505,7 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isPinned ? <PinOff className="h-4 w-4" /> : <Pin className="h-4 w-4" />}
|
||||
{isPinned ? <PinOff /> : <Pin />}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -14,7 +14,7 @@ import { BOARD_STATUSES } from "@multica/core/issues/config";
|
||||
import { useWorkspaceStore } from "@multica/core/workspace";
|
||||
import { WorkspaceAvatar } from "../../workspace/workspace-avatar";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { issueListOptions } from "@multica/core/issues/queries";
|
||||
import { issueListOptions, childIssueProgressOptions } from "@multica/core/issues/queries";
|
||||
import { useUpdateIssue } from "@multica/core/issues/mutations";
|
||||
import { useIssueSelectionStore } from "@multica/core/issues/stores/selection-store";
|
||||
import { IssuesHeader } from "./issues-header";
|
||||
@@ -38,7 +38,7 @@ export function IssuesPage() {
|
||||
const includeNoProject = useIssueViewStore((s) => s.includeNoProject);
|
||||
|
||||
useEffect(() => {
|
||||
initFilterWorkspaceSync();
|
||||
initFilterWorkspaceSync((cb) => useWorkspaceStore.subscribe((s) => cb(s.workspace?.id)));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -59,22 +59,9 @@ export function IssuesPage() {
|
||||
[scopedIssues, statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters, projectFilters, includeNoProject],
|
||||
);
|
||||
|
||||
// Compute sub-issue progress for each parent from the full (unfiltered) issue list
|
||||
const childProgressMap = useMemo(() => {
|
||||
const map = new Map<string, { done: number; total: number }>();
|
||||
for (const issue of allIssues) {
|
||||
if (!issue.parent_issue_id) continue;
|
||||
const entry = map.get(issue.parent_issue_id);
|
||||
const isDone = issue.status === "done" || issue.status === "cancelled";
|
||||
if (entry) {
|
||||
entry.total++;
|
||||
if (isDone) entry.done++;
|
||||
} else {
|
||||
map.set(issue.parent_issue_id, { done: isDone ? 1 : 0, total: 1 });
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [allIssues]);
|
||||
// Fetch sub-issue progress from the backend so counts are accurate
|
||||
// regardless of client-side pagination or filtering of done issues.
|
||||
const { data: childProgressMap = new Map() } = useQuery(childIssueProgressOptions(wsId));
|
||||
|
||||
const visibleStatuses = useMemo(() => {
|
||||
if (statusFilters.length > 0)
|
||||
|
||||
@@ -107,17 +107,19 @@ export function AssigneePicker({
|
||||
)
|
||||
}
|
||||
>
|
||||
{/* Unassigned option */}
|
||||
<PickerItem
|
||||
selected={!assigneeType && !assigneeId}
|
||||
onClick={() => {
|
||||
onUpdate({ assignee_type: null, assignee_id: null });
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<UserMinus className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">Unassigned</span>
|
||||
</PickerItem>
|
||||
{/* Unassigned option — hidden when search is active */}
|
||||
{!query && (
|
||||
<PickerItem
|
||||
selected={!assigneeType && !assigneeId}
|
||||
onClick={() => {
|
||||
onUpdate({ assignee_type: null, assignee_id: null });
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<UserMinus className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">Unassigned</span>
|
||||
</PickerItem>
|
||||
)}
|
||||
|
||||
{/* Members */}
|
||||
{filteredMembers.length > 0 && (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { useState, useCallback, useRef, useEffect } from "react";
|
||||
import { Check } from "lucide-react";
|
||||
import {
|
||||
Popover,
|
||||
@@ -8,6 +8,9 @@ import {
|
||||
PopoverContent,
|
||||
} from "@multica/ui/components/ui/popover";
|
||||
|
||||
const HIGHLIGHT_CLASS = "bg-accent";
|
||||
const ITEM_SELECTOR = "button[data-picker-item]:not(:disabled)";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PropertyPicker — generic Popover shell with optional search
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -36,18 +39,71 @@ export function PropertyPicker({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [highlightedIndex, setHighlightedIndex] = useState(-1);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const getItems = useCallback(() => {
|
||||
if (!listRef.current) return [];
|
||||
return Array.from(
|
||||
listRef.current.querySelectorAll<HTMLButtonElement>(ITEM_SELECTOR),
|
||||
);
|
||||
}, []);
|
||||
|
||||
// Apply/remove highlight class via DOM when index changes
|
||||
useEffect(() => {
|
||||
const items = getItems();
|
||||
for (const item of items) {
|
||||
item.classList.remove(HIGHLIGHT_CLASS);
|
||||
}
|
||||
if (highlightedIndex >= 0 && highlightedIndex < items.length) {
|
||||
items[highlightedIndex]?.classList.add(HIGHLIGHT_CLASS);
|
||||
}
|
||||
}, [highlightedIndex, getItems, children]); // re-run when children change (filtered list updates)
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(v: boolean) => {
|
||||
onOpenChange(v);
|
||||
if (!v) {
|
||||
setQuery("");
|
||||
setHighlightedIndex(-1);
|
||||
onSearchChange?.("");
|
||||
}
|
||||
},
|
||||
[onOpenChange, onSearchChange],
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
const items = getItems();
|
||||
if (items.length === 0) return;
|
||||
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setHighlightedIndex((prev) => {
|
||||
const next = prev < items.length - 1 ? prev + 1 : 0;
|
||||
items[next]?.scrollIntoView({ block: "nearest" });
|
||||
return next;
|
||||
});
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setHighlightedIndex((prev) => {
|
||||
const next = prev > 0 ? prev - 1 : items.length - 1;
|
||||
items[next]?.scrollIntoView({ block: "nearest" });
|
||||
return next;
|
||||
});
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
if (highlightedIndex >= 0 && highlightedIndex < items.length) {
|
||||
items[highlightedIndex]?.click();
|
||||
} else if (items.length === 1) {
|
||||
// Auto-select when only one result
|
||||
items[0]?.click();
|
||||
}
|
||||
}
|
||||
},
|
||||
[getItems, highlightedIndex],
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger
|
||||
@@ -64,15 +120,17 @@ export function PropertyPicker({
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
setHighlightedIndex(0);
|
||||
onSearchChange?.(e.target.value);
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={searchPlaceholder}
|
||||
aria-label="Filter options"
|
||||
className="w-full bg-transparent text-sm placeholder:text-muted-foreground outline-none"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="p-1 max-h-60 overflow-y-auto">{children}</div>
|
||||
<div ref={listRef} className="p-1 max-h-60 overflow-y-auto">{children}</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
@@ -98,6 +156,7 @@ export function PickerItem({
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-picker-item
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm ${disabled ? "opacity-50 cursor-not-allowed" : hoverClassName ?? "hover:bg-accent"} transition-colors`}
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
SidebarFooter,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuItem,
|
||||
SidebarRail,
|
||||
} from "@multica/ui/components/ui/sidebar";
|
||||
@@ -55,15 +56,15 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@multica/ui/components/ui/dropdown-menu";
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { useWorkspaceStore } from "@multica/core/workspace";
|
||||
import { workspaceListOptions } from "@multica/core/workspace/queries";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { inboxKeys, deduplicateInboxItems } from "@multica/core/inbox/queries";
|
||||
import { api } from "@multica/core/api";
|
||||
import { useModalStore } from "@multica/core/modals";
|
||||
import { useMyRuntimesNeedUpdate } from "@multica/core/runtimes/hooks";
|
||||
import { pinKeys } from "@multica/core/pins/queries";
|
||||
import { pinListOptions } from "@multica/core/pins/queries";
|
||||
import { useDeletePin, useReorderPins } from "@multica/core/pins/mutations";
|
||||
import type { PinnedItem } from "@multica/core/types";
|
||||
|
||||
@@ -93,7 +94,6 @@ function DraftDot() {
|
||||
function SortablePinItem({ pin, pathname, onUnpin }: { pin: PinnedItem; pathname: string; onUnpin: () => void }) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: pin.id });
|
||||
const wasDragged = useRef(false);
|
||||
const { push } = useNavigation();
|
||||
|
||||
useEffect(() => {
|
||||
if (isDragging) wasDragged.current = true;
|
||||
@@ -114,12 +114,13 @@ function SortablePinItem({ pin, pathname, onUnpin }: { pin: PinnedItem; pathname
|
||||
>
|
||||
<SidebarMenuButton
|
||||
isActive={isActive}
|
||||
onClick={() => {
|
||||
render={<AppLink href={href} />}
|
||||
onClick={(event) => {
|
||||
if (wasDragged.current) {
|
||||
wasDragged.current = false;
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
push(href);
|
||||
}}
|
||||
className="text-muted-foreground hover:not-data-active:bg-sidebar-accent/70 data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground"
|
||||
>
|
||||
@@ -129,17 +130,17 @@ function SortablePinItem({ pin, pathname, onUnpin }: { pin: PinnedItem; pathname
|
||||
<FolderKanban className="size-4 shrink-0" />
|
||||
)}
|
||||
<span className="truncate">{label}</span>
|
||||
<button
|
||||
className="ml-auto opacity-0 group-hover/pin:opacity-100 transition-opacity p-0.5 rounded hover:bg-accent shrink-0"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onUnpin();
|
||||
}}
|
||||
>
|
||||
<PinOff className="size-3 text-muted-foreground" />
|
||||
</button>
|
||||
</SidebarMenuButton>
|
||||
<SidebarMenuAction
|
||||
showOnHover
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onUnpin();
|
||||
}}
|
||||
>
|
||||
<PinOff className="size-3 text-muted-foreground" />
|
||||
</SidebarMenuAction>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
}
|
||||
@@ -158,10 +159,11 @@ interface AppSidebarProps {
|
||||
export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }: AppSidebarProps = {}) {
|
||||
const { pathname, push } = useNavigation();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const userId = useAuthStore((s) => s.user?.id);
|
||||
const authLogout = useAuthStore((s) => s.logout);
|
||||
const workspace = useWorkspaceStore((s) => s.workspace);
|
||||
const workspaces = useWorkspaceStore((s) => s.workspaces);
|
||||
const switchWorkspace = useWorkspaceStore((s) => s.switchWorkspace);
|
||||
const { data: workspaces = [] } = useQuery(workspaceListOptions());
|
||||
|
||||
const wsId = workspace?.id;
|
||||
const { data: inboxItems = [] } = useQuery({
|
||||
@@ -174,10 +176,9 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
|
||||
[inboxItems],
|
||||
);
|
||||
const hasRuntimeUpdates = useMyRuntimesNeedUpdate(wsId);
|
||||
const { data: pinnedItems = [] } = useQuery<PinnedItem[]>({
|
||||
queryKey: wsId ? pinKeys.list(wsId) : ["pins", "disabled"],
|
||||
queryFn: () => api.listPins(),
|
||||
enabled: !!wsId,
|
||||
const { data: pinnedItems = [] } = useQuery({
|
||||
...pinListOptions(wsId ?? "", userId ?? ""),
|
||||
enabled: !!wsId && !!userId,
|
||||
});
|
||||
const deletePin = useDeletePin();
|
||||
const reorderPins = useReorderPins();
|
||||
@@ -256,20 +257,9 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
|
||||
</DropdownMenuLabel>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup className="group/ws-section">
|
||||
<DropdownMenuLabel className="flex items-center text-xs text-muted-foreground">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground">
|
||||
Workspaces
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
className="ml-auto opacity-0 group-hover/ws-section:opacity-100 transition-opacity rounded hover:bg-accent p-0.5"
|
||||
onClick={() => useModalStore.getState().open("create-workspace")}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
Create workspace
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</DropdownMenuLabel>
|
||||
{workspaces.map((ws) => (
|
||||
<DropdownMenuItem
|
||||
@@ -277,7 +267,7 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
|
||||
onClick={() => {
|
||||
if (ws.id !== workspace?.id) {
|
||||
push("/issues");
|
||||
switchWorkspace(ws.id);
|
||||
switchWorkspace(ws);
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -288,6 +278,14 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
useModalStore.getState().open("create-workspace")
|
||||
}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Create workspace
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
|
||||
@@ -8,6 +8,8 @@ interface DashboardGuardProps {
|
||||
children: ReactNode;
|
||||
/** Path to redirect to when user is not authenticated */
|
||||
loginPath?: string;
|
||||
/** Path to redirect to when user has no workspace (onboarding) */
|
||||
onboardingPath?: string;
|
||||
/** Rendered when auth or workspace is loading */
|
||||
loadingFallback?: ReactNode;
|
||||
}
|
||||
@@ -21,9 +23,10 @@ interface DashboardGuardProps {
|
||||
export function DashboardGuard({
|
||||
children,
|
||||
loginPath = "/",
|
||||
onboardingPath,
|
||||
loadingFallback = null,
|
||||
}: DashboardGuardProps) {
|
||||
const { user, isLoading, workspace } = useDashboardGuard(loginPath);
|
||||
const { user, isLoading, workspace } = useDashboardGuard(loginPath, onboardingPath);
|
||||
|
||||
if (isLoading || !workspace) return <>{loadingFallback}</>;
|
||||
if (!user) return null;
|
||||
|
||||
@@ -8,12 +8,14 @@ import { DashboardGuard } from "./dashboard-guard";
|
||||
|
||||
interface DashboardLayoutProps {
|
||||
children: ReactNode;
|
||||
/** Sibling of SidebarInset (e.g. SearchCommand, ChatWindow) */
|
||||
/** Rendered inside SidebarInset (e.g. ChatWindow, ChatFab — absolute-positioned overlays) */
|
||||
extra?: ReactNode;
|
||||
/** Rendered inside sidebar header as a search trigger */
|
||||
searchSlot?: ReactNode;
|
||||
/** Loading indicator */
|
||||
loadingIndicator?: ReactNode;
|
||||
/** Path to redirect when user has no workspace */
|
||||
onboardingPath?: string;
|
||||
}
|
||||
|
||||
export function DashboardLayout({
|
||||
@@ -21,10 +23,12 @@ export function DashboardLayout({
|
||||
extra,
|
||||
searchSlot,
|
||||
loadingIndicator,
|
||||
onboardingPath,
|
||||
}: DashboardLayoutProps) {
|
||||
return (
|
||||
<DashboardGuard
|
||||
loginPath="/"
|
||||
onboardingPath={onboardingPath}
|
||||
loadingFallback={
|
||||
<div className="flex h-svh items-center justify-center">
|
||||
{loadingIndicator}
|
||||
@@ -33,14 +37,14 @@ export function DashboardLayout({
|
||||
>
|
||||
<SidebarProvider className="h-svh">
|
||||
<AppSidebar searchSlot={searchSlot} />
|
||||
<SidebarInset className="overflow-hidden">
|
||||
<SidebarInset className="relative overflow-hidden">
|
||||
<div className="flex h-10 shrink-0 items-center border-b px-2 md:hidden">
|
||||
<SidebarTrigger />
|
||||
</div>
|
||||
{children}
|
||||
<ModalRegistry />
|
||||
{extra}
|
||||
</SidebarInset>
|
||||
{extra}
|
||||
</SidebarProvider>
|
||||
</DashboardGuard>
|
||||
);
|
||||
|
||||
@@ -6,15 +6,22 @@ import { useAuthStore } from "@multica/core/auth";
|
||||
import { useWorkspaceStore } from "@multica/core/workspace";
|
||||
import { useNavigation } from "../navigation";
|
||||
|
||||
export function useDashboardGuard(loginPath = "/") {
|
||||
export function useDashboardGuard(loginPath = "/", onboardingPath?: string) {
|
||||
const { pathname, push } = useNavigation();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const isLoading = useAuthStore((s) => s.isLoading);
|
||||
const workspace = useWorkspaceStore((s) => s.workspace);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !user) push(loginPath);
|
||||
}, [user, isLoading, push, loginPath]);
|
||||
if (isLoading) return;
|
||||
if (!user) {
|
||||
push(loginPath);
|
||||
return;
|
||||
}
|
||||
if (!workspace && onboardingPath) {
|
||||
push(onboardingPath);
|
||||
}
|
||||
}, [user, isLoading, workspace, push, loginPath, onboardingPath]);
|
||||
|
||||
useEffect(() => {
|
||||
useNavigationStore.getState().onPathChange(pathname);
|
||||
|
||||
228
packages/views/modals/create-issue.test.tsx
Normal file
228
packages/views/modals/create-issue.test.tsx
Normal file
@@ -0,0 +1,228 @@
|
||||
import { forwardRef, useImperativeHandle, useRef, useState } from "react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
const mockPush = vi.hoisted(() => vi.fn());
|
||||
const mockCreateIssue = vi.hoisted(() => vi.fn());
|
||||
const mockSetDraft = vi.hoisted(() => vi.fn());
|
||||
const mockClearDraft = vi.hoisted(() => vi.fn());
|
||||
const mockToastCustom = vi.hoisted(() => vi.fn());
|
||||
const mockToastDismiss = vi.hoisted(() => vi.fn());
|
||||
const mockToastError = vi.hoisted(() => vi.fn());
|
||||
|
||||
const mockDraftStore = {
|
||||
draft: {
|
||||
title: "",
|
||||
description: "",
|
||||
status: "todo" as const,
|
||||
priority: "none" as const,
|
||||
assigneeType: undefined,
|
||||
assigneeId: undefined,
|
||||
dueDate: null,
|
||||
},
|
||||
setDraft: mockSetDraft,
|
||||
clearDraft: mockClearDraft,
|
||||
};
|
||||
|
||||
vi.mock("../navigation", () => ({
|
||||
useNavigation: () => ({ push: mockPush }),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/workspace", () => ({
|
||||
useWorkspaceStore: Object.assign(
|
||||
(selector?: (state: { workspace: { name: string } }) => unknown) => {
|
||||
const state = { workspace: { name: "Test Workspace" } };
|
||||
return selector ? selector(state) : state;
|
||||
},
|
||||
{ getState: () => ({ workspace: { name: "Test Workspace" } }) },
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/issues/stores/draft-store", () => ({
|
||||
useIssueDraftStore: Object.assign(
|
||||
(selector?: (state: typeof mockDraftStore) => unknown) =>
|
||||
(selector ? selector(mockDraftStore) : mockDraftStore),
|
||||
{ getState: () => mockDraftStore },
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/issues/mutations", () => ({
|
||||
useCreateIssue: () => ({ mutateAsync: mockCreateIssue }),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/hooks/use-file-upload", () => ({
|
||||
useFileUpload: () => ({ uploadWithToast: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/api", () => ({
|
||||
api: {},
|
||||
}));
|
||||
|
||||
vi.mock("../editor", () => {
|
||||
const ContentEditor = forwardRef(({ defaultValue, onUpdate, placeholder }: any, ref: any) => {
|
||||
const valueRef = useRef(defaultValue || "");
|
||||
const [value, setValue] = useState(defaultValue || "");
|
||||
useImperativeHandle(ref, () => ({
|
||||
getMarkdown: () => valueRef.current,
|
||||
uploadFile: vi.fn(),
|
||||
}));
|
||||
return (
|
||||
<textarea
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => {
|
||||
valueRef.current = e.target.value;
|
||||
setValue(e.target.value);
|
||||
onUpdate?.(e.target.value);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
ContentEditor.displayName = "ContentEditor";
|
||||
|
||||
return {
|
||||
useFileDropZone: () => ({ isDragOver: false, dropZoneProps: {} }),
|
||||
FileDropOverlay: () => null,
|
||||
ContentEditor,
|
||||
TitleEditor: ({ defaultValue, placeholder, onChange, onSubmit }: any) => {
|
||||
const [value, setValue] = useState(defaultValue || "");
|
||||
return (
|
||||
<input
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => {
|
||||
setValue(e.target.value);
|
||||
onChange?.(e.target.value);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") onSubmit?.();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../issues/components", () => ({
|
||||
StatusIcon: ({ status }: { status: string }) => <span data-testid="status-icon">{status}</span>,
|
||||
StatusPicker: () => <div data-testid="status-picker" />,
|
||||
PriorityPicker: () => <div data-testid="priority-picker" />,
|
||||
AssigneePicker: () => <div data-testid="assignee-picker" />,
|
||||
DueDatePicker: () => <div data-testid="due-date-picker" />,
|
||||
}));
|
||||
|
||||
vi.mock("../projects/components/project-picker", () => ({
|
||||
ProjectPicker: () => <div data-testid="project-picker" />,
|
||||
}));
|
||||
|
||||
vi.mock("@multica/ui/components/ui/dialog", () => ({
|
||||
Dialog: ({ children }: { children: React.ReactNode }) => <div data-testid="dialog-root">{children}</div>,
|
||||
DialogContent: ({ children, className }: { children: React.ReactNode; className?: string }) => (
|
||||
<div className={className}>{children}</div>
|
||||
),
|
||||
DialogTitle: ({ children, className }: { children: React.ReactNode; className?: string }) => (
|
||||
<div className={className}>{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/ui/components/ui/tooltip", () => ({
|
||||
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
TooltipTrigger: ({ render }: { render: React.ReactNode }) => <>{render}</>,
|
||||
TooltipContent: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
vi.mock("@multica/ui/components/ui/button", () => ({
|
||||
Button: ({
|
||||
children,
|
||||
disabled,
|
||||
onClick,
|
||||
type = "button",
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
onClick?: () => void;
|
||||
type?: "button" | "submit" | "reset";
|
||||
}) => (
|
||||
<button type={type} disabled={disabled} onClick={onClick}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/ui/components/common/file-upload-button", () => ({
|
||||
FileUploadButton: ({ onSelect }: { onSelect: (file: File) => void }) => (
|
||||
<button type="button" onClick={() => onSelect(new File(["test"], "test.txt"))}>
|
||||
Upload file
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/ui/lib/utils", () => ({
|
||||
cn: (...values: Array<string | false | null | undefined>) => values.filter(Boolean).join(" "),
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: {
|
||||
custom: mockToastCustom,
|
||||
dismiss: mockToastDismiss,
|
||||
error: mockToastError,
|
||||
},
|
||||
}));
|
||||
|
||||
import { CreateIssueModal } from "./create-issue";
|
||||
|
||||
describe("CreateIssueModal", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockCreateIssue.mockResolvedValue({
|
||||
id: "issue-123",
|
||||
identifier: "TES-123",
|
||||
title: "Ship create issue regression coverage",
|
||||
status: "todo",
|
||||
});
|
||||
});
|
||||
|
||||
it("shows success feedback with a direct path to the new issue", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClose = vi.fn();
|
||||
|
||||
render(<CreateIssueModal onClose={onClose} />);
|
||||
|
||||
await user.type(screen.getByPlaceholderText("Issue title"), " Ship create issue regression coverage ");
|
||||
await user.click(screen.getByRole("button", { name: "Create Issue" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreateIssue).toHaveBeenCalledWith({
|
||||
title: "Ship create issue regression coverage",
|
||||
description: undefined,
|
||||
status: "todo",
|
||||
priority: "none",
|
||||
assignee_type: undefined,
|
||||
assignee_id: undefined,
|
||||
due_date: undefined,
|
||||
attachment_ids: undefined,
|
||||
parent_issue_id: undefined,
|
||||
project_id: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
expect(mockClearDraft).toHaveBeenCalled();
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
expect(mockToastCustom).toHaveBeenCalledTimes(1);
|
||||
|
||||
const renderToast = mockToastCustom.mock.calls[0]?.[0];
|
||||
expect(typeof renderToast).toBe("function");
|
||||
|
||||
render(renderToast("toast-1"));
|
||||
|
||||
expect(screen.getByText("Issue created")).toBeInTheDocument();
|
||||
expect(screen.getByText(/TES-123/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Ship create issue regression coverage/)).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "View issue" }));
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith("/issues/issue-123");
|
||||
expect(mockToastDismiss).toHaveBeenCalledWith("toast-1");
|
||||
});
|
||||
});
|
||||
109
packages/views/modals/create-workspace.test.tsx
Normal file
109
packages/views/modals/create-workspace.test.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { describe, expect, it, beforeEach, vi } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
const mockPush = vi.hoisted(() => vi.fn());
|
||||
const mockCreateWorkspaceMutate = vi.hoisted(() => vi.fn());
|
||||
const mockToastError = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../navigation", () => ({
|
||||
useNavigation: () => ({ push: mockPush }),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/workspace/mutations", () => ({
|
||||
useCreateWorkspace: () => ({
|
||||
mutate: mockCreateWorkspaceMutate,
|
||||
isPending: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/ui/components/ui/dialog", () => ({
|
||||
Dialog: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
DialogContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
DialogTitle: ({ children }: { children: ReactNode }) => <h1>{children}</h1>,
|
||||
DialogDescription: ({ children }: { children: ReactNode }) => (
|
||||
<p>{children}</p>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: {
|
||||
error: mockToastError,
|
||||
},
|
||||
}));
|
||||
|
||||
import { CreateWorkspaceModal } from "./create-workspace";
|
||||
|
||||
describe("CreateWorkspaceModal", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("auto-generates the slug until the user edits it", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CreateWorkspaceModal onClose={vi.fn()} />);
|
||||
|
||||
const nameInput = screen.getByPlaceholderText("My Workspace");
|
||||
const slugInput = screen.getByPlaceholderText("my-workspace");
|
||||
|
||||
await user.type(nameInput, "My Team");
|
||||
expect(slugInput).toHaveValue("my-team");
|
||||
|
||||
await user.clear(slugInput);
|
||||
await user.type(slugInput, "custom-team");
|
||||
await user.clear(nameInput);
|
||||
await user.type(nameInput, "Renamed Team");
|
||||
|
||||
expect(slugInput).toHaveValue("custom-team");
|
||||
});
|
||||
|
||||
it("shows a specific slug conflict error on 409 responses", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockCreateWorkspaceMutate.mockImplementation(
|
||||
(
|
||||
_data: unknown,
|
||||
options: { onError: (error: unknown) => void },
|
||||
) => {
|
||||
options.onError({ status: 409 });
|
||||
},
|
||||
);
|
||||
|
||||
render(<CreateWorkspaceModal onClose={vi.fn()} />);
|
||||
|
||||
await user.type(screen.getByPlaceholderText("My Workspace"), "My Team");
|
||||
await user.click(screen.getByRole("button", { name: "Create workspace" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText("That workspace URL is already taken."),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(mockToastError).toHaveBeenCalledWith(
|
||||
"Choose a different workspace URL",
|
||||
);
|
||||
expect(mockCreateWorkspaceMutate).toHaveBeenCalledWith(
|
||||
{ name: "My Team", slug: "my-team" },
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("continues onboarding after successful creation", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClose = vi.fn();
|
||||
mockCreateWorkspaceMutate.mockImplementation(
|
||||
(_data: unknown, options: { onSuccess: () => void }) => {
|
||||
options.onSuccess();
|
||||
},
|
||||
);
|
||||
|
||||
render(<CreateWorkspaceModal onClose={onClose} />);
|
||||
|
||||
await user.type(screen.getByPlaceholderText("My Workspace"), "My Team");
|
||||
await user.click(screen.getByRole("button", { name: "Create workspace" }));
|
||||
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
expect(mockPush).toHaveBeenCalledWith("/onboarding");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { useNavigation } from "../navigation";
|
||||
import { toast } from "sonner";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
@@ -14,55 +14,74 @@ import {
|
||||
DialogDescription,
|
||||
} from "@multica/ui/components/ui/dialog";
|
||||
import { Card, CardContent } from "@multica/ui/components/ui/card";
|
||||
import { useWorkspaceStore } from "@multica/core/workspace";
|
||||
|
||||
const SLUG_REGEX = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
||||
import { useCreateWorkspace } from "@multica/core/workspace/mutations";
|
||||
import {
|
||||
WORKSPACE_SLUG_CONFLICT_ERROR,
|
||||
WORKSPACE_SLUG_FORMAT_ERROR,
|
||||
WORKSPACE_SLUG_REGEX,
|
||||
isWorkspaceSlugConflict,
|
||||
nameToWorkspaceSlug,
|
||||
} from "../workspace/slug";
|
||||
|
||||
export function CreateWorkspaceModal({ onClose }: { onClose: () => void }) {
|
||||
const router = useNavigation();
|
||||
const createWorkspace = useCreateWorkspace();
|
||||
const [name, setName] = useState("");
|
||||
const [slug, setSlug] = useState("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [slugServerError, setSlugServerError] = useState<string | null>(null);
|
||||
const slugTouched = useRef(false);
|
||||
|
||||
const slugError =
|
||||
slug.length > 0 && !SLUG_REGEX.test(slug)
|
||||
? "Only lowercase letters, numbers, and hyphens"
|
||||
const slugValidationError =
|
||||
slug.length > 0 && !WORKSPACE_SLUG_REGEX.test(slug)
|
||||
? WORKSPACE_SLUG_FORMAT_ERROR
|
||||
: null;
|
||||
const slugError = slugValidationError ?? slugServerError;
|
||||
|
||||
const canSubmit = name.trim().length > 0 && slug.trim().length > 0 && !slugError;
|
||||
const canSubmit =
|
||||
name.trim().length > 0 && slug.trim().length > 0 && !slugError;
|
||||
|
||||
const handleNameChange = (value: string) => {
|
||||
setName(value);
|
||||
setSlug(
|
||||
value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-|-$/g, ""),
|
||||
);
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!canSubmit) return;
|
||||
setCreating(true);
|
||||
try {
|
||||
const { createWorkspace, switchWorkspace } =
|
||||
useWorkspaceStore.getState();
|
||||
const ws = await createWorkspace({
|
||||
name: name.trim(),
|
||||
slug: slug.trim(),
|
||||
});
|
||||
onClose();
|
||||
router.push("/issues");
|
||||
await switchWorkspace(ws.id);
|
||||
} catch {
|
||||
toast.error("Failed to create workspace");
|
||||
} finally {
|
||||
setCreating(false);
|
||||
if (!slugTouched.current) {
|
||||
setSlug(nameToWorkspaceSlug(value));
|
||||
setSlugServerError(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSlugChange = (value: string) => {
|
||||
slugTouched.current = true;
|
||||
setSlug(value);
|
||||
setSlugServerError(null);
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
if (!canSubmit) return;
|
||||
createWorkspace.mutate(
|
||||
{ name: name.trim(), slug: slug.trim() },
|
||||
{
|
||||
onSuccess: () => {
|
||||
onClose();
|
||||
router.push("/onboarding");
|
||||
},
|
||||
onError: (error) => {
|
||||
if (isWorkspaceSlugConflict(error)) {
|
||||
setSlugServerError(WORKSPACE_SLUG_CONFLICT_ERROR);
|
||||
toast.error("Choose a different workspace URL");
|
||||
return;
|
||||
}
|
||||
toast.error("Failed to create workspace");
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(v) => { if (!v) onClose(); }}>
|
||||
<Dialog
|
||||
open
|
||||
onOpenChange={(v) => {
|
||||
if (!v) onClose();
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className="inset-0 flex h-full w-full max-w-none sm:max-w-none translate-0 flex-col items-center justify-center rounded-none bg-background ring-0 shadow-none"
|
||||
@@ -104,14 +123,15 @@ export function CreateWorkspaceModal({ onClose }: { onClose: () => void }) {
|
||||
<Label>Workspace URL</Label>
|
||||
<div className="flex items-center gap-0 rounded-md border bg-background focus-within:ring-2 focus-within:ring-ring">
|
||||
<span className="pl-3 text-sm text-muted-foreground select-none">
|
||||
multica.app/
|
||||
multica.ai/
|
||||
</span>
|
||||
<Input
|
||||
type="text"
|
||||
value={slug}
|
||||
onChange={(e) => setSlug(e.target.value)}
|
||||
onChange={(e) => handleSlugChange(e.target.value)}
|
||||
placeholder="my-workspace"
|
||||
className="border-0 shadow-none focus-visible:ring-0"
|
||||
onKeyDown={(e) => e.key === "Enter" && handleCreate()}
|
||||
/>
|
||||
</div>
|
||||
{slugError && (
|
||||
@@ -125,9 +145,9 @@ export function CreateWorkspaceModal({ onClose }: { onClose: () => void }) {
|
||||
className="w-full"
|
||||
size="lg"
|
||||
onClick={handleCreate}
|
||||
disabled={creating || !canSubmit}
|
||||
disabled={createWorkspace.isPending || !canSubmit}
|
||||
>
|
||||
{creating ? "Creating..." : "Create workspace"}
|
||||
{createWorkspace.isPending ? "Creating..." : "Create workspace"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
@@ -20,7 +20,7 @@ import { ListView } from "../../issues/components/list-view";
|
||||
import { BatchActionToolbar } from "../../issues/components/batch-action-toolbar";
|
||||
import { registerViewStoreForWorkspaceSync } from "@multica/core/issues/stores/view-store";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { myIssueListOptions, type MyIssuesFilter } from "@multica/core/issues/queries";
|
||||
import { myIssueListOptions, childIssueProgressOptions, type MyIssuesFilter } from "@multica/core/issues/queries";
|
||||
import { useUpdateIssue, useLoadMoreDoneIssues } from "@multica/core/issues/mutations";
|
||||
import { myIssuesViewStore } from "@multica/core/issues/stores/my-issues-view-store";
|
||||
import { MyIssuesHeader } from "./my-issues-header";
|
||||
@@ -88,21 +88,7 @@ export function MyIssuesPage() {
|
||||
[myIssues, statusFilters, priorityFilters],
|
||||
);
|
||||
|
||||
const childProgressMap = useMemo(() => {
|
||||
const map = new Map<string, { done: number; total: number }>();
|
||||
for (const issue of myIssues) {
|
||||
if (!issue.parent_issue_id) continue;
|
||||
const entry = map.get(issue.parent_issue_id);
|
||||
const isDone = issue.status === "done" || issue.status === "cancelled";
|
||||
if (entry) {
|
||||
entry.total++;
|
||||
if (isDone) entry.done++;
|
||||
} else {
|
||||
map.set(issue.parent_issue_id, { done: isDone ? 1 : 0, total: 1 });
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [myIssues]);
|
||||
const { data: childProgressMap = new Map() } = useQuery(childIssueProgressOptions(wsId));
|
||||
|
||||
const visibleStatuses = useMemo(() => {
|
||||
if (statusFilters.length > 0)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user