mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-18 20:29:21 +02:00
Compare commits
208 Commits
feat/cli-v
...
chore/remo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a2e6f4746 | ||
|
|
69ef002bbb | ||
|
|
7dad45d444 | ||
|
|
7ade4b432d | ||
|
|
cbb2cf0c6c | ||
|
|
d94b704a71 | ||
|
|
76ba9cfb0b | ||
|
|
40aa23a528 | ||
|
|
d3f7570177 | ||
|
|
34e452776b | ||
|
|
2551aa53ef | ||
|
|
d779cbd183 | ||
|
|
10b6afc1ec | ||
|
|
4f58f0c8eb | ||
|
|
0399e387f8 | ||
|
|
a744cd4f45 | ||
|
|
bfa9bec8c4 | ||
|
|
bf71802451 | ||
|
|
09e6190400 | ||
|
|
0798b5f8bb | ||
|
|
e568896357 | ||
|
|
8748557c7b | ||
|
|
7f0c23a6ba | ||
|
|
e6767d2ba3 | ||
|
|
1ceb75e218 | ||
|
|
9138c05993 | ||
|
|
091ed7370a | ||
|
|
35557c0b11 | ||
|
|
03ad47200b | ||
|
|
93b754de53 | ||
|
|
609d2e06ae | ||
|
|
7c436c0dcb | ||
|
|
55ae78b902 | ||
|
|
cc00fda513 | ||
|
|
04e571b02f | ||
|
|
c62bd0ca12 | ||
|
|
51c7dbbeee | ||
|
|
46d745cb60 | ||
|
|
0a998d1cef | ||
|
|
a366984014 | ||
|
|
9ba9ea66f8 | ||
|
|
2be6fdae90 | ||
|
|
653c0adeee | ||
|
|
4458753102 | ||
|
|
3c0ed0f732 | ||
|
|
999d0728c5 | ||
|
|
b6a69c113e | ||
|
|
7995f7368f | ||
|
|
ed1a1dc6b1 | ||
|
|
97755ae45d | ||
|
|
7a896d3852 | ||
|
|
da63165cdc | ||
|
|
013584ef80 | ||
|
|
bb4944bae2 | ||
|
|
42e392c727 | ||
|
|
158a100779 | ||
|
|
e178682acd | ||
|
|
8779db976c | ||
|
|
eba68c15fd | ||
|
|
345cb984a9 | ||
|
|
f3355049bc | ||
|
|
dca86acc69 | ||
|
|
c71525e198 | ||
|
|
977dc6479d | ||
|
|
a97bd3da0b | ||
|
|
9dfe119f47 | ||
|
|
f2efd4b529 | ||
|
|
a1de20e971 | ||
|
|
27d0865f5f | ||
|
|
2cd6024851 | ||
|
|
5e74c411dc | ||
|
|
418049856f | ||
|
|
00042c0ec7 | ||
|
|
7c7d7feed3 | ||
|
|
6a451c1ce7 | ||
|
|
8c0708bb5d | ||
|
|
9170b01739 | ||
|
|
d37595b85e | ||
|
|
03310a581a | ||
|
|
fe0d450471 | ||
|
|
bc1185f525 | ||
|
|
0d95a7c7ef | ||
|
|
8587243ab6 | ||
|
|
740d8e773d | ||
|
|
9550e6c4e0 | ||
|
|
880c614039 | ||
|
|
f1f693afa5 | ||
|
|
c148288d5a | ||
|
|
ff5f6ac2ee | ||
|
|
a0d43ca31a | ||
|
|
a29ecfe02a | ||
|
|
8d3cb21c03 | ||
|
|
2b16cbb27a | ||
|
|
a757f3a8c4 | ||
|
|
56c38dc521 | ||
|
|
4bc9969765 | ||
|
|
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 | ||
|
|
a73a9d4036 | ||
|
|
12bf7cac34 | ||
|
|
64ed0806ff | ||
|
|
b927684e3d | ||
|
|
e9bed4eb13 | ||
|
|
297b436e65 | ||
|
|
4165401d16 | ||
|
|
6097f7392e | ||
|
|
a749d310dd | ||
|
|
a473110078 | ||
|
|
2f1000d815 | ||
|
|
dbc6308c20 | ||
|
|
9e8c20df3d | ||
|
|
4d31b1ecee | ||
|
|
17ea7797df | ||
|
|
418fe4b18e | ||
|
|
e5881601ad | ||
|
|
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 | ||
|
|
77dbcaefad | ||
|
|
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 | ||
|
|
f99f50eb0c | ||
|
|
5bae3368d7 | ||
|
|
f100b5b707 | ||
|
|
701399536f | ||
|
|
4ca607f888 | ||
|
|
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.
|
||||
60
.github/PULL_REQUEST_TEMPLATE.md
vendored
60
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -1,34 +1,58 @@
|
||||
## 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
|
||||
|
||||
- [ ] `make check` passes (typecheck, unit tests, Go tests, E2E)
|
||||
- [ ] Changes follow existing code patterns and conventions
|
||||
- [ ] No unrelated changes included
|
||||
- [ ] I have included a thinking path that traces from project context to this change
|
||||
- [ ] I have run tests locally and they pass
|
||||
- [ ] I have added or updated tests where applicable
|
||||
- [ ] If this change affects the UI, I have included before/after screenshots
|
||||
- [ ] I have updated relevant documentation to reflect my changes
|
||||
- [ ] I have considered and documented any risks above
|
||||
- [ ] I will address all reviewer comments before requesting merge
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -7,8 +7,7 @@ The `multica` CLI connects your local machine to Multica. It handles authenticat
|
||||
### Homebrew (macOS/Linux)
|
||||
|
||||
```bash
|
||||
brew tap multica-ai/tap
|
||||
brew install multica
|
||||
brew install multica-ai/tap/multica
|
||||
```
|
||||
|
||||
### Build from Source
|
||||
@@ -22,11 +21,17 @@ cp server/bin/multica /usr/local/bin/multica
|
||||
|
||||
### Update
|
||||
|
||||
```bash
|
||||
brew upgrade multica-ai/tap/multica
|
||||
```
|
||||
|
||||
For install script or manual installs, use:
|
||||
|
||||
```bash
|
||||
multica update
|
||||
```
|
||||
|
||||
This auto-detects your installation method (Homebrew or manual) and upgrades accordingly.
|
||||
`multica update` auto-detects your installation method and upgrades accordingly.
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -35,7 +40,7 @@ This auto-detects your installation method (Homebrew or manual) and upgrades acc
|
||||
multica setup
|
||||
|
||||
# For self-hosted (local) deployments:
|
||||
multica setup --local
|
||||
multica setup self-host
|
||||
```
|
||||
|
||||
Or step by step:
|
||||
@@ -135,6 +140,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.
|
||||
|
||||
@@ -169,29 +177,35 @@ Agent-specific overrides:
|
||||
| `MULTICA_CLAUDE_MODEL` | Override the Claude model used |
|
||||
| `MULTICA_CODEX_PATH` | Custom path to the `codex` binary |
|
||||
| `MULTICA_CODEX_MODEL` | Override the Codex model used |
|
||||
| `MULTICA_OPENCODE_PATH` | Custom path to the `opencode` binary |
|
||||
| `MULTICA_OPENCODE_MODEL` | Override the OpenCode model used |
|
||||
| `MULTICA_OPENCLAW_PATH` | Custom path to the `openclaw` binary |
|
||||
| `MULTICA_OPENCLAW_MODEL` | Override the OpenClaw model used |
|
||||
| `MULTICA_HERMES_PATH` | Custom path to the `hermes` binary |
|
||||
| `MULTICA_HERMES_MODEL` | Override the Hermes model used |
|
||||
|
||||
### Self-Hosted Server
|
||||
|
||||
When connecting to a self-hosted Multica instance, the easiest approach is:
|
||||
|
||||
```bash
|
||||
# One command — auto-detects local server, configures, authenticates, starts daemon
|
||||
multica setup --local
|
||||
# One command — configures for localhost, authenticates, starts daemon
|
||||
multica setup self-host
|
||||
|
||||
# Or for on-premise with custom domains:
|
||||
multica setup self-host --server-url https://api.example.com --app-url https://app.example.com
|
||||
```
|
||||
|
||||
Or configure manually:
|
||||
|
||||
```bash
|
||||
# Configure for local Docker Compose (default ports)
|
||||
multica config local
|
||||
|
||||
# Or set URLs individually:
|
||||
# multica config set app_url http://localhost:3000
|
||||
# multica config set server_url http://localhost:8080
|
||||
# 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 app_url https://app.example.com
|
||||
# multica config set server_url https://api.example.com
|
||||
# multica config set app_url https://app.example.com
|
||||
|
||||
multica login
|
||||
multica daemon start
|
||||
@@ -202,9 +216,11 @@ multica daemon start
|
||||
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
|
||||
@@ -327,17 +343,20 @@ The `runs` command shows all past and current executions for an issue, including
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
# One-command setup: configure, authenticate, and start the daemon
|
||||
# One-command setup for Multica Cloud: configure, authenticate, and start the daemon
|
||||
multica setup
|
||||
|
||||
# For local self-hosted deployments (auto-detects or forces local mode)
|
||||
multica setup --local
|
||||
# For local self-hosted deployments
|
||||
multica setup self-host
|
||||
|
||||
# Custom ports
|
||||
multica setup --local --port 9090 --frontend-port 4000
|
||||
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` detects whether a local Multica server is running, configures the CLI, opens your browser for authentication, and starts the daemon — all in one step.
|
||||
`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
|
||||
|
||||
@@ -349,15 +368,6 @@ multica config show
|
||||
|
||||
Shows config file path, server URL, app URL, and default workspace.
|
||||
|
||||
### Configure for Local Self-Hosted
|
||||
|
||||
```bash
|
||||
multica config local # Uses default ports (8080/3000)
|
||||
multica config local --port 9090 --frontend-port 4000 # Custom ports
|
||||
```
|
||||
|
||||
Sets `server_url` and `app_url` for a local Docker Compose deployment in one command.
|
||||
|
||||
### Set Values
|
||||
|
||||
```bash
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -38,7 +40,7 @@ which brew
|
||||
If `brew` is found, install via Homebrew:
|
||||
|
||||
```bash
|
||||
brew tap multica-ai/tap && brew install multica
|
||||
brew install multica-ai/tap/multica
|
||||
```
|
||||
|
||||
Then verify:
|
||||
@@ -49,7 +51,13 @@ multica version
|
||||
|
||||
If the version prints successfully, skip to **Step 3**.
|
||||
|
||||
### Option B: Download from GitHub Releases (no Homebrew)
|
||||
To upgrade later, run:
|
||||
|
||||
```bash
|
||||
brew upgrade multica-ai/tap/multica
|
||||
```
|
||||
|
||||
### Option B: Download from GitHub Releases (macOS/Linux, no Homebrew)
|
||||
|
||||
If Homebrew is not available, download the binary directly.
|
||||
|
||||
@@ -85,6 +93,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 +165,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 +184,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`."
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -37,8 +37,10 @@ RUN pnpm install --frozen-lockfile --offline
|
||||
# Set build-time env: tells Next.js rewrites to proxy API calls to the backend service
|
||||
ARG REMOTE_API_URL=http://backend:8080
|
||||
ARG NEXT_PUBLIC_GOOGLE_CLIENT_ID
|
||||
ARG NEXT_PUBLIC_WS_URL
|
||||
ENV REMOTE_API_URL=$REMOTE_API_URL
|
||||
ENV NEXT_PUBLIC_GOOGLE_CLIENT_ID=$NEXT_PUBLIC_GOOGLE_CLIENT_ID
|
||||
ENV NEXT_PUBLIC_WS_URL=$NEXT_PUBLIC_WS_URL
|
||||
ENV STANDALONE=true
|
||||
|
||||
# Build the web app (standalone output for minimal runtime)
|
||||
|
||||
5
Makefile
5
Makefile
@@ -70,7 +70,7 @@ selfhost:
|
||||
echo ""; \
|
||||
echo "Next — install the CLI and connect your machine:"; \
|
||||
echo " brew install multica-ai/tap/multica"; \
|
||||
echo " multica setup --local"; \
|
||||
echo " multica setup self-host"; \
|
||||
else \
|
||||
echo ""; \
|
||||
echo "Services are still starting. Check logs:"; \
|
||||
@@ -190,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:
|
||||
|
||||
69
README.md
69
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)**
|
||||
|
||||
@@ -51,24 +50,39 @@ Multica manages the full agent lifecycle: from task assignment to execution moni
|
||||
|
||||
## Quick Install
|
||||
|
||||
### macOS / Linux (Homebrew - recommended)
|
||||
|
||||
```bash
|
||||
brew install multica-ai/tap/multica
|
||||
```
|
||||
|
||||
Use `brew upgrade multica-ai/tap/multica` to keep the CLI current.
|
||||
|
||||
### macOS / Linux (install script)
|
||||
|
||||
```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.
|
||||
Use this if Homebrew is not available. The script installs the Multica CLI on macOS and Linux by using Homebrew when it is on `PATH`, otherwise it downloads the binary directly.
|
||||
|
||||
After installation:
|
||||
### Windows (PowerShell)
|
||||
|
||||
```bash
|
||||
multica login # Authenticate (opens browser)
|
||||
multica daemon start # Start the local agent runtime
|
||||
multica daemon stop # Stop the daemon when done
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
> **Self-hosting?** Add `--local` to deploy a full Multica server on your machine:
|
||||
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 -- --local
|
||||
> 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.
|
||||
@@ -77,11 +91,10 @@ multica daemon stop # Stop the daemon when done
|
||||
|
||||
## Getting Started
|
||||
|
||||
### 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 auto-detects agent CLIs (`claude`, `codex`, `openclaw`, `opencode`) on your PATH.
|
||||
@@ -102,6 +115,21 @@ Create an issue from the board (or via `multica issue create`), then assign it t
|
||||
|
||||
---
|
||||
|
||||
## Multica vs Paperclip
|
||||
|
||||
| | Multica | Paperclip |
|
||||
|---|---------|-----------|
|
||||
| **Focus** | Team AI agent collaboration platform | Solo AI agent company simulator |
|
||||
| **User model** | Multi-user teams with roles & permissions | Single board operator |
|
||||
| **Agent interaction** | Issues + Chat conversations | Issues + Heartbeat |
|
||||
| **Deployment** | Cloud-first | Local-first |
|
||||
| **Management depth** | Lightweight (Issues / Projects / Labels) | Heavy governance (Org chart / Approvals / Budgets) |
|
||||
| **Extensibility** | Skills system | Skills + Plugin system |
|
||||
|
||||
**TL;DR — Multica is built for teams that want to collaborate with AI agents on real projects together.**
|
||||
|
||||
---
|
||||
|
||||
## CLI
|
||||
|
||||
The `multica` CLI connects your local machine to Multica — authenticate, manage workspaces, and run the agent daemon.
|
||||
@@ -111,9 +139,8 @@ The `multica` CLI connects your local machine to Multica — authenticate, manag
|
||||
| `multica login` | Authenticate (opens browser) |
|
||||
| `multica daemon start` | Start the local agent runtime |
|
||||
| `multica daemon status` | Check daemon status |
|
||||
| `multica setup` | One-command setup (configure + login + start daemon) |
|
||||
| `multica setup --local` | Same, but for self-hosted deployments |
|
||||
| `multica config local` | Configure CLI for a local self-hosted server |
|
||||
| `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 |
|
||||
@@ -157,3 +184,13 @@ make dev
|
||||
`make dev` auto-detects your environment (main checkout or worktree), creates the env file, installs dependencies, sets up the database, runs migrations, and starts all services.
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for the full development workflow, worktree support, testing, and troubleshooting.
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/?repos=multica-ai%2Fmultica&type=date&legend=bottom-right">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=multica-ai/multica&type=date&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=multica-ai/multica&type=date&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=multica-ai/multica&type=date&legend=top-left" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
@@ -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) | 简体中文**
|
||||
|
||||
@@ -51,24 +50,39 @@ Multica 管理完整的 Agent 生命周期:从任务分配到执行监控再
|
||||
|
||||
## 快速安装
|
||||
|
||||
### macOS / Linux(推荐 Homebrew)
|
||||
|
||||
```bash
|
||||
brew install multica-ai/tap/multica
|
||||
```
|
||||
|
||||
后续可用 `brew upgrade multica-ai/tap/multica` 更新 CLI。
|
||||
|
||||
### macOS / Linux(安装脚本)
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash
|
||||
```
|
||||
|
||||
安装 Multica CLI,支持 macOS 和 Linux。有 Homebrew 用 Homebrew,没有则直接下载二进制。
|
||||
如果没有 Homebrew,可以使用安装脚本。脚本会安装 Multica CLI:检测到 `brew` 时通过 Homebrew 安装,否则直接下载二进制。
|
||||
|
||||
安装完成后:
|
||||
### Windows (PowerShell)
|
||||
|
||||
```bash
|
||||
multica login # 认证(打开浏览器)
|
||||
multica daemon start # 启动本地 Agent 运行时
|
||||
multica daemon stop # 停止 daemon
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
> **自部署?** 加上 `--local` 在本地部署完整的 Multica 服务:
|
||||
安装完成后,一条命令完成配置、认证和启动:
|
||||
|
||||
```bash
|
||||
multica setup # 连接 Multica Cloud,登录,启动 daemon
|
||||
```
|
||||
|
||||
> **自部署?** 加上 `--with-server` 在本地部署完整的 Multica 服务:
|
||||
>
|
||||
> ```bash
|
||||
> curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash -s -- --local
|
||||
> 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)。
|
||||
@@ -79,11 +93,10 @@ multica daemon stop # 停止 daemon
|
||||
|
||||
安装好 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`)。
|
||||
@@ -104,6 +117,21 @@ daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动
|
||||
|
||||
大功告成!你的 Agent 现在是团队的一员了。 🎉
|
||||
|
||||
---
|
||||
|
||||
## Multica vs Paperclip
|
||||
|
||||
| | Multica | Paperclip |
|
||||
|---|---------|-----------|
|
||||
| **定位** | 团队 AI Agent 协作平台 | 个人 AI Agent 公司模拟器 |
|
||||
| **用户模型** | 多人团队,角色权限 | 单人 Board Operator |
|
||||
| **Agent 交互** | Issue + Chat 对话 | Issue + Heartbeat |
|
||||
| **部署** | 云端优先 | 本地优先 |
|
||||
| **管理深度** | 轻量(Issue / Project / Labels) | 重度(组织架构 / 审批 / 预算) |
|
||||
| **扩展** | Skills 系统 | Skills + 插件系统 |
|
||||
|
||||
**简单来说:Multica 专为团队协作打造,让团队和 AI Agent 一起高效完成项目。**
|
||||
|
||||
## 架构
|
||||
|
||||
```
|
||||
@@ -144,3 +172,13 @@ make start
|
||||
## 开源协议
|
||||
|
||||
[Apache 2.0](LICENSE)
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/?repos=multica-ai%2Fmultica&type=date&legend=bottom-right">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=multica-ai/multica&type=date&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=multica-ai/multica&type=date&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=multica-ai/multica&type=date&legend=top-left" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
@@ -14,22 +14,27 @@ Each user who runs AI agents locally also installs the **`multica` CLI** and run
|
||||
|
||||
## Quick Install (Recommended)
|
||||
|
||||
One command to set up everything — server, CLI, and configuration:
|
||||
Two commands to set up everything — server, CLI, and configuration:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash -s -- --local
|
||||
# 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 automatically clones the repository, starts all services via Docker Compose, and installs the `multica` CLI.
|
||||
This clones the repository, starts all services via Docker Compose, installs the `multica` CLI, then configures it for localhost.
|
||||
|
||||
Once complete, open http://localhost:3000, log in with any email + verification code **`888888`**, then:
|
||||
|
||||
```bash
|
||||
multica login # Authenticate (opens browser)
|
||||
multica daemon start # Start the agent daemon
|
||||
```
|
||||
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.
|
||||
>
|
||||
> **CLI only?** If the self-host server is already running and you only need the CLI on a macOS/Linux machine, install it with Homebrew:
|
||||
>
|
||||
> ```bash
|
||||
> brew install multica-ai/tap/multica
|
||||
> ```
|
||||
|
||||
---
|
||||
|
||||
@@ -77,11 +82,14 @@ brew install multica-ai/tap/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 --local
|
||||
multica setup self-host
|
||||
```
|
||||
|
||||
This automatically:
|
||||
@@ -90,6 +98,12 @@ This automatically:
|
||||
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
|
||||
@@ -128,16 +142,10 @@ multica daemon stop
|
||||
If you've been self-hosting and want to switch your CLI to [Multica Cloud](https://multica.ai):
|
||||
|
||||
```bash
|
||||
multica config set server_url https://api.multica.ai
|
||||
multica config set app_url https://multica.ai
|
||||
multica login
|
||||
multica setup
|
||||
```
|
||||
|
||||
Or re-run the install script without `--local` — it will reconfigure the CLI automatically:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash
|
||||
```
|
||||
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.
|
||||
|
||||
@@ -180,11 +188,8 @@ If you prefer configuring the CLI step by step instead of `multica setup`:
|
||||
|
||||
```bash
|
||||
# Point CLI to your local server
|
||||
multica config local
|
||||
|
||||
# Or set URLs manually:
|
||||
# multica config set app_url http://localhost:3000
|
||||
# multica config set server_url http://localhost:8080
|
||||
multica config set server_url http://localhost:8080
|
||||
multica config set app_url http://localhost:3000
|
||||
|
||||
# Login (opens browser)
|
||||
multica login
|
||||
|
||||
@@ -66,6 +66,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.
|
||||
@@ -203,6 +218,26 @@ NEXT_PUBLIC_API_URL=https://api.example.com
|
||||
NEXT_PUBLIC_WS_URL=wss://api.example.com/ws
|
||||
```
|
||||
|
||||
## LAN / Non-localhost Access
|
||||
|
||||
By default, Multica works on `localhost`. If you access it from another machine on the LAN (e.g. `http://192.168.1.100:3000`), you need to tell the backend to accept that origin:
|
||||
|
||||
```bash
|
||||
# .env — replace with your server's LAN IP
|
||||
FRONTEND_ORIGIN=http://192.168.1.100:3000
|
||||
CORS_ALLOWED_ORIGINS=http://192.168.1.100:3000
|
||||
```
|
||||
|
||||
Then rebuild:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.selfhost.yml up -d --build
|
||||
```
|
||||
|
||||
The frontend automatically derives the WebSocket URL from the page address, so real-time features (chat streaming, live issue updates, notifications) work over LAN without extra configuration.
|
||||
|
||||
> **Note:** If you need to override the WebSocket URL explicitly (e.g. when using a separate backend domain), set `NEXT_PUBLIC_WS_URL` in `.env` and rebuild the frontend image.
|
||||
|
||||
## Health Check
|
||||
|
||||
The backend exposes a health check endpoint:
|
||||
|
||||
@@ -8,15 +8,17 @@ This document is designed for AI agents to execute. Follow these steps exactly t
|
||||
- Homebrew installed (for CLI)
|
||||
- At least one AI agent CLI on PATH: `claude` or `codex`
|
||||
|
||||
## One-Command Install
|
||||
## Install
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash -s -- --local
|
||||
# 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
|
||||
```
|
||||
|
||||
This installs everything: clones the repo, starts Docker services, and installs the CLI.
|
||||
|
||||
Wait for the output `✓ Multica is installed and running!` before proceeding.
|
||||
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
|
||||
@@ -30,10 +32,10 @@ git clone https://github.com/multica-ai/multica.git
|
||||
cd multica
|
||||
make selfhost
|
||||
brew install multica-ai/tap/multica
|
||||
multica setup --local
|
||||
multica setup self-host
|
||||
```
|
||||
|
||||
The `multica setup --local` command will:
|
||||
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
|
||||
@@ -64,7 +66,7 @@ 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 --local --port <PORT> --frontend-port <FRONTEND_PORT>`
|
||||
3. Run `multica setup self-host --port <PORT> --frontend-port <FRONTEND_PORT>`
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
|
||||
2
apps/desktop/.gitignore
vendored
2
apps/desktop/.gitignore
vendored
@@ -4,3 +4,5 @@ out
|
||||
.DS_Store
|
||||
.eslintcache
|
||||
*.log*
|
||||
# CLI binary bundled at build time (from server/bin/)
|
||||
resources/bin/
|
||||
|
||||
@@ -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:
|
||||
@@ -28,4 +32,8 @@ win:
|
||||
target:
|
||||
- nsis
|
||||
artifactName: ${name}-${version}-setup.${ext}
|
||||
publish:
|
||||
provider: github
|
||||
owner: multica-ai
|
||||
repo: multica
|
||||
npmRebuild: false
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import globals from "globals";
|
||||
import reactConfig from "@multica/eslint-config/react";
|
||||
|
||||
export default [
|
||||
...reactConfig,
|
||||
{ ignores: ["out/", "dist/"] },
|
||||
{
|
||||
files: ["scripts/**/*.{mjs,js}"],
|
||||
languageOptions: {
|
||||
globals: { ...globals.node },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -4,13 +4,14 @@
|
||||
"private": true,
|
||||
"main": "./out/main/index.js",
|
||||
"scripts": {
|
||||
"dev": "electron-vite dev",
|
||||
"build": "electron-vite build",
|
||||
"bundle-cli": "node scripts/bundle-cli.mjs",
|
||||
"dev": "pnpm run bundle-cli && electron-vite dev",
|
||||
"build": "pnpm run bundle-cli && electron-vite build",
|
||||
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
|
||||
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
|
||||
"typecheck": "pnpm run typecheck:node && pnpm run typecheck:web",
|
||||
"preview": "electron-vite preview",
|
||||
"package": "electron-builder",
|
||||
"package": "pnpm run bundle-cli && electron-builder",
|
||||
"lint": "eslint .",
|
||||
"postinstall": "electron-builder install-app-deps"
|
||||
},
|
||||
@@ -21,11 +22,12 @@
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@electron-toolkit/preload": "^3.0.2",
|
||||
"@electron-toolkit/utils": "^4.0.0",
|
||||
"@fontsource/geist-mono": "^5.2.7",
|
||||
"@fontsource/geist-sans": "^5.2.5",
|
||||
"@multica/core": "workspace:*",
|
||||
"@multica/ui": "workspace:*",
|
||||
"@multica/views": "workspace:*",
|
||||
"@fontsource/geist-mono": "^5.2.7",
|
||||
"@fontsource/geist-sans": "^5.2.5",
|
||||
"electron-updater": "^6.8.3",
|
||||
"react-router-dom": "^7.6.0",
|
||||
"shadcn": "^4.1.0",
|
||||
"sonner": "^2.0.7",
|
||||
|
||||
110
apps/desktop/scripts/bundle-cli.mjs
Normal file
110
apps/desktop/scripts/bundle-cli.mjs
Normal file
@@ -0,0 +1,110 @@
|
||||
#!/usr/bin/env node
|
||||
// Builds the `multica` CLI from server/cmd/multica and copies the binary
|
||||
// into apps/desktop/resources/bin/ so electron-vite (dev) and electron-
|
||||
// builder (prod) pick it up. Running this on every dev/build/package
|
||||
// invocation guarantees the bundled CLI always matches the current Go
|
||||
// source — no more stale binary surprises. Go's build cache makes the
|
||||
// no-op case (nothing changed) effectively free.
|
||||
//
|
||||
// ldflags mirror `make build` so `multica --version` reports a meaningful
|
||||
// version / commit / date.
|
||||
//
|
||||
// Graceful: if `go` is not installed (e.g. frontend-only contributor), we
|
||||
// skip the build and fall through to auto-install at runtime. A genuine
|
||||
// Go compile error is fatal — you want that to block dev, not hide.
|
||||
|
||||
import { access, chmod, copyFile, mkdir } from "node:fs/promises";
|
||||
import { constants } from "node:fs";
|
||||
import { execFileSync, execSync } from "node:child_process";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = resolve(here, "..", "..", "..");
|
||||
const serverDir = join(repoRoot, "server");
|
||||
|
||||
const binName = process.platform === "win32" ? "multica.exe" : "multica";
|
||||
const srcBinary = join(serverDir, "bin", binName);
|
||||
const destDir = join(repoRoot, "apps", "desktop", "resources", "bin");
|
||||
const destBinary = join(destDir, binName);
|
||||
|
||||
function sh(cmd) {
|
||||
try {
|
||||
return execSync(cmd, { encoding: "utf-8" }).trim();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function hasGo() {
|
||||
try {
|
||||
execSync("go version", { stdio: "pipe" });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function exists(p) {
|
||||
try {
|
||||
await access(p, constants.F_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasGo()) {
|
||||
const version = sh("git describe --tags --always --dirty") || "dev";
|
||||
const commit = sh("git rev-parse --short HEAD") || "unknown";
|
||||
const date = new Date().toISOString().replace(/\.\d+Z$/, "Z");
|
||||
const ldflags = `-X main.version=${version} -X main.commit=${commit} -X main.date=${date}`;
|
||||
|
||||
console.log(
|
||||
`[bundle-cli] go build → ${srcBinary} (version=${version} commit=${commit})`,
|
||||
);
|
||||
execFileSync(
|
||||
"go",
|
||||
[
|
||||
"build",
|
||||
"-ldflags",
|
||||
ldflags,
|
||||
"-o",
|
||||
join("bin", binName),
|
||||
"./cmd/multica",
|
||||
],
|
||||
{ cwd: serverDir, stdio: "inherit" },
|
||||
);
|
||||
} else {
|
||||
console.warn(
|
||||
"[bundle-cli] `go` not found in PATH — skipping CLI build. " +
|
||||
"Desktop will use whatever is already in resources/bin/, or fall back " +
|
||||
"to auto-installing the latest release at runtime.",
|
||||
);
|
||||
}
|
||||
|
||||
if (!(await exists(srcBinary))) {
|
||||
console.warn(
|
||||
`[bundle-cli] ${srcBinary} not present — Desktop will fall back to ` +
|
||||
`auto-installing the latest release at runtime.`,
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
await mkdir(destDir, { recursive: true });
|
||||
await copyFile(srcBinary, destBinary);
|
||||
await chmod(destBinary, 0o755);
|
||||
|
||||
// macOS: ad-hoc sign so Gatekeeper doesn't complain when the parent app
|
||||
// (which itself may be unsigned in dev) spawns the child.
|
||||
if (process.platform === "darwin") {
|
||||
try {
|
||||
execSync(`codesign -s - --force ${JSON.stringify(destBinary)}`, {
|
||||
stdio: "pipe",
|
||||
});
|
||||
} catch {
|
||||
// Non-fatal. Unsigned binaries still run when the parent app is trusted.
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[bundle-cli] bundled ${srcBinary} → ${destBinary}`);
|
||||
173
apps/desktop/src/main/cli-bootstrap.ts
Normal file
173
apps/desktop/src/main/cli-bootstrap.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { app } from "electron";
|
||||
import { execFile } from "child_process";
|
||||
import { createHash } from "crypto";
|
||||
import { createReadStream, createWriteStream, existsSync } from "fs";
|
||||
import { chmod, mkdir, rename, rm } from "fs/promises";
|
||||
import { join, dirname } from "path";
|
||||
import { pipeline } from "stream/promises";
|
||||
import { tmpdir } from "os";
|
||||
import { Readable } from "stream";
|
||||
|
||||
// Desktop bootstraps its own copy of the `multica` CLI into userData on first
|
||||
// launch, so users never have to brew-install anything. Build-time decoupled:
|
||||
// we don't bundle the binary into the .app, we download whatever the upstream
|
||||
// release is at first run.
|
||||
|
||||
const GITHUB_LATEST_BASE =
|
||||
"https://github.com/multica-ai/multica/releases/latest/download";
|
||||
|
||||
function platformAssetName(): string {
|
||||
const osMap: Record<string, string> = {
|
||||
darwin: "darwin",
|
||||
linux: "linux",
|
||||
win32: "windows",
|
||||
};
|
||||
const archMap: Record<string, string> = {
|
||||
x64: "amd64",
|
||||
arm64: "arm64",
|
||||
};
|
||||
const os = osMap[process.platform];
|
||||
const arch = archMap[process.arch];
|
||||
if (!os || !arch) {
|
||||
throw new Error(
|
||||
`unsupported platform for CLI auto-install: ${process.platform}/${process.arch}`,
|
||||
);
|
||||
}
|
||||
const ext = process.platform === "win32" ? "zip" : "tar.gz";
|
||||
return `multica_${os}_${arch}.${ext}`;
|
||||
}
|
||||
|
||||
function binaryName(): string {
|
||||
return process.platform === "win32" ? "multica.exe" : "multica";
|
||||
}
|
||||
|
||||
export function managedCliPath(): string {
|
||||
return join(app.getPath("userData"), "bin", binaryName());
|
||||
}
|
||||
|
||||
function run(cmd: string, args: string[], cwd?: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(cmd, args, { cwd }, (err) => (err ? reject(err) : resolve()));
|
||||
});
|
||||
}
|
||||
|
||||
async function downloadToFile(url: string, dest: string): Promise<void> {
|
||||
const res = await fetch(url, { redirect: "follow" });
|
||||
if (!res.ok || !res.body) {
|
||||
throw new Error(`download failed: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
await mkdir(dirname(dest), { recursive: true });
|
||||
// Node's fetch returns a web ReadableStream; adapt to a Node stream for pipeline.
|
||||
const nodeStream = Readable.fromWeb(res.body as Parameters<typeof Readable.fromWeb>[0]);
|
||||
await pipeline(nodeStream, createWriteStream(dest));
|
||||
}
|
||||
|
||||
// Fetch goreleaser's published checksums.txt and parse it into a
|
||||
// filename → sha256 lookup. Format is `<hex> <filename>` per line.
|
||||
async function fetchChecksums(): Promise<Map<string, string>> {
|
||||
const url = `${GITHUB_LATEST_BASE}/checksums.txt`;
|
||||
const res = await fetch(url, { redirect: "follow" });
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`checksums.txt fetch failed: ${res.status} ${res.statusText}`,
|
||||
);
|
||||
}
|
||||
const text = await res.text();
|
||||
const map = new Map<string, string>();
|
||||
for (const rawLine of text.split("\n")) {
|
||||
const line = rawLine.trim();
|
||||
if (!line) continue;
|
||||
const match = line.match(/^([a-f0-9]{64})\s+\*?(\S+)$/i);
|
||||
if (match) map.set(match[2], match[1].toLowerCase());
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
async function sha256OfFile(path: string): Promise<string> {
|
||||
const hash = createHash("sha256");
|
||||
await pipeline(createReadStream(path), hash);
|
||||
return hash.digest("hex");
|
||||
}
|
||||
|
||||
async function verifyChecksum(
|
||||
archivePath: string,
|
||||
assetName: string,
|
||||
): Promise<void> {
|
||||
const checksums = await fetchChecksums();
|
||||
const expected = checksums.get(assetName);
|
||||
if (!expected) {
|
||||
throw new Error(
|
||||
`no checksum for ${assetName} in checksums.txt — refusing to install unverified binary`,
|
||||
);
|
||||
}
|
||||
const actual = await sha256OfFile(archivePath);
|
||||
if (actual.toLowerCase() !== expected) {
|
||||
throw new Error(
|
||||
`checksum mismatch for ${assetName}: expected ${expected}, got ${actual}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function extractArchive(archive: string, dest: string): Promise<void> {
|
||||
await mkdir(dest, { recursive: true });
|
||||
// Modern OSes all ship a `tar` that auto-detects tar.gz and zip:
|
||||
// - macOS/Linux: GNU tar or bsdtar
|
||||
// - Windows 10+: bsdtar is bundled as `tar.exe` since build 17063
|
||||
await run("tar", ["-xf", archive, "-C", dest]);
|
||||
}
|
||||
|
||||
async function installFresh(): Promise<string> {
|
||||
const target = managedCliPath();
|
||||
const assetName = platformAssetName();
|
||||
const url = `${GITHUB_LATEST_BASE}/${assetName}`;
|
||||
|
||||
const workDir = join(tmpdir(), `multica-cli-${Date.now()}`);
|
||||
await mkdir(workDir, { recursive: true });
|
||||
|
||||
try {
|
||||
const archivePath = join(workDir, assetName);
|
||||
console.log(`[cli-bootstrap] downloading ${url}`);
|
||||
await downloadToFile(url, archivePath);
|
||||
|
||||
console.log(`[cli-bootstrap] verifying ${assetName} against checksums.txt`);
|
||||
await verifyChecksum(archivePath, assetName);
|
||||
|
||||
console.log(`[cli-bootstrap] extracting ${assetName}`);
|
||||
await extractArchive(archivePath, workDir);
|
||||
|
||||
const extractedBin = join(workDir, binaryName());
|
||||
if (!existsSync(extractedBin)) {
|
||||
throw new Error(
|
||||
`archive ${assetName} did not contain ${binaryName()} at its root`,
|
||||
);
|
||||
}
|
||||
|
||||
await mkdir(dirname(target), { recursive: true });
|
||||
await rename(extractedBin, target);
|
||||
await chmod(target, 0o755);
|
||||
|
||||
// macOS: ad-hoc sign so spawning the child never hits a gatekeeper quirk.
|
||||
// Non-fatal: unsigned binaries still execute when the parent app is trusted.
|
||||
if (process.platform === "darwin") {
|
||||
await run("codesign", ["-s", "-", "--force", target]).catch((err) => {
|
||||
console.warn("[cli-bootstrap] ad-hoc codesign failed:", err);
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`[cli-bootstrap] installed CLI at ${target}`);
|
||||
return target;
|
||||
} finally {
|
||||
await rm(workDir, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the path to a usable `multica` binary. If one is already present at
|
||||
* the managed userData location, returns it immediately. Otherwise downloads
|
||||
* the latest release asset for the current platform and installs it.
|
||||
*/
|
||||
export async function ensureManagedCli(): Promise<string> {
|
||||
const target = managedCliPath();
|
||||
if (existsSync(target)) return target;
|
||||
return installFresh();
|
||||
}
|
||||
877
apps/desktop/src/main/daemon-manager.ts
Normal file
877
apps/desktop/src/main/daemon-manager.ts
Normal file
@@ -0,0 +1,877 @@
|
||||
import { app, ipcMain, BrowserWindow } from "electron";
|
||||
import { execFile } from "child_process";
|
||||
import {
|
||||
readFile,
|
||||
writeFile,
|
||||
mkdir,
|
||||
rm,
|
||||
open,
|
||||
stat,
|
||||
} from "fs/promises";
|
||||
import {
|
||||
existsSync,
|
||||
watchFile,
|
||||
unwatchFile,
|
||||
type StatsListener,
|
||||
} from "fs";
|
||||
import { join } from "path";
|
||||
import { homedir } from "os";
|
||||
import type { DaemonStatus, DaemonPrefs } from "../shared/daemon-types";
|
||||
import { ensureManagedCli, managedCliPath } from "./cli-bootstrap";
|
||||
|
||||
const DEFAULT_HEALTH_PORT = 19514;
|
||||
const POLL_INTERVAL_MS = 5_000;
|
||||
const PREFS_PATH = join(homedir(), ".multica", "desktop_prefs.json");
|
||||
const LOG_TAIL_RETRY_MS = 2_000;
|
||||
const LOG_TAIL_MAX_RETRIES = 5;
|
||||
|
||||
const DEFAULT_PREFS: DaemonPrefs = { autoStart: true, autoStop: false };
|
||||
|
||||
interface ActiveProfile {
|
||||
name: string; // "" = default profile
|
||||
port: number;
|
||||
}
|
||||
|
||||
let statusPollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let logTailWatcher: { path: string; listener: StatsListener } | null = null;
|
||||
let currentState: DaemonStatus["state"] = "installing_cli";
|
||||
let getMainWindow: () => BrowserWindow | null = () => null;
|
||||
let operationInProgress = false;
|
||||
let cachedCliBinary: string | null | undefined = undefined;
|
||||
let cliResolvePromise: Promise<string | null> | null = null;
|
||||
let targetApiBaseUrl: string | null = null;
|
||||
let activeProfile: ActiveProfile | null = null;
|
||||
|
||||
// Serialize all writes to any profile config file. Multiple paths
|
||||
// (syncToken, resolveActiveProfile, clearToken, watch/unwatch handlers)
|
||||
// may try to write concurrently; chaining them avoids interleaved writes
|
||||
// corrupting the JSON.
|
||||
let configWriteChain: Promise<void> = Promise.resolve();
|
||||
|
||||
// Keep the Go impl in sync: server/cmd/multica/cmd_daemon.go healthPortForProfile.
|
||||
function healthPortForProfile(profile: string): number {
|
||||
if (!profile) return DEFAULT_HEALTH_PORT;
|
||||
let sum = 0;
|
||||
for (const b of Buffer.from(profile, "utf-8")) sum += b;
|
||||
return DEFAULT_HEALTH_PORT + 1 + (sum % 1000);
|
||||
}
|
||||
|
||||
function profileDir(profile: string): string {
|
||||
return profile
|
||||
? join(homedir(), ".multica", "profiles", profile)
|
||||
: join(homedir(), ".multica");
|
||||
}
|
||||
|
||||
function profileConfigPath(profile: string): string {
|
||||
return join(profileDir(profile), "config.json");
|
||||
}
|
||||
|
||||
function profileLogPath(profile: string): string {
|
||||
return join(profileDir(profile), "daemon.log");
|
||||
}
|
||||
|
||||
// Sidecar file that records which Multica user the cached PAT in config.json
|
||||
// was minted for. The Go CLI/daemon never read or write this file, so it
|
||||
// survives Go-side config rewrites. Used to detect user switches and mint a
|
||||
// fresh PAT instead of reusing a token that belongs to a previous user.
|
||||
function profileUserIdPath(profile: string): string {
|
||||
return join(profileDir(profile), ".desktop-user-id");
|
||||
}
|
||||
|
||||
async function readProfileUserId(profile: string): Promise<string | null> {
|
||||
try {
|
||||
const raw = await readFile(profileUserIdPath(profile), "utf-8");
|
||||
const trimmed = raw.trim();
|
||||
return trimmed || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeProfileUserId(
|
||||
profile: string,
|
||||
userId: string,
|
||||
): Promise<void> {
|
||||
await mkdir(profileDir(profile), { recursive: true });
|
||||
await writeFile(profileUserIdPath(profile), userId, "utf-8");
|
||||
}
|
||||
|
||||
async function removeProfileUserId(profile: string): Promise<void> {
|
||||
try {
|
||||
await rm(profileUserIdPath(profile));
|
||||
} catch {
|
||||
// Already gone — nothing to do.
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeUrl(u: string): string {
|
||||
if (!u) return "";
|
||||
try {
|
||||
const parsed = new URL(u);
|
||||
return `${parsed.protocol}//${parsed.host}`.toLowerCase();
|
||||
} catch {
|
||||
return u.replace(/\/+$/, "").toLowerCase();
|
||||
}
|
||||
}
|
||||
|
||||
function urlsMatch(a: string, b: string): boolean {
|
||||
const na = normalizeUrl(a);
|
||||
const nb = normalizeUrl(b);
|
||||
return na.length > 0 && na === nb;
|
||||
}
|
||||
|
||||
function sendStatus(status: DaemonStatus): void {
|
||||
const win = getMainWindow();
|
||||
win?.webContents.send("daemon:status", status);
|
||||
}
|
||||
|
||||
interface HealthPayload {
|
||||
status?: string;
|
||||
pid?: number;
|
||||
uptime?: string;
|
||||
daemon_id?: string;
|
||||
device_name?: string;
|
||||
server_url?: string;
|
||||
agents?: string[];
|
||||
workspaces?: unknown[];
|
||||
}
|
||||
|
||||
async function fetchHealthAtPort(
|
||||
port: number,
|
||||
): Promise<HealthPayload | null> {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 2_000);
|
||||
const res = await fetch(`http://127.0.0.1:${port}/health`, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timeout);
|
||||
if (!res.ok) return null;
|
||||
return (await res.json()) as HealthPayload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Desktop owns a dedicated CLI profile named after the target API host, so it
|
||||
// never reads or writes the user's hand-configured profiles. Profile dir:
|
||||
// ~/.multica/profiles/desktop-<host>/
|
||||
function deriveProfileName(targetUrl: string): string {
|
||||
try {
|
||||
const url = new URL(targetUrl);
|
||||
const host = url.host.replace(/:/g, "-").toLowerCase();
|
||||
return `desktop-${host}`;
|
||||
} catch {
|
||||
return "desktop";
|
||||
}
|
||||
}
|
||||
|
||||
async function readProfileConfig(
|
||||
profile: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const raw = await readFile(profileConfigPath(profile), "utf-8");
|
||||
const parsed = JSON.parse(raw);
|
||||
return parsed && typeof parsed === "object" ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
async function writeProfileConfig(
|
||||
profile: string,
|
||||
cfg: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const op = async () => {
|
||||
await mkdir(profileDir(profile), { recursive: true });
|
||||
await writeFile(
|
||||
profileConfigPath(profile),
|
||||
JSON.stringify(cfg, null, 2),
|
||||
"utf-8",
|
||||
);
|
||||
};
|
||||
const next = configWriteChain.catch(() => {}).then(op);
|
||||
configWriteChain = next.catch(() => {});
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Desktop-owned profile for the current target API URL. Creates
|
||||
* the profile's config.json on demand with `server_url` pinned to the target.
|
||||
*
|
||||
* This function never falls back to the default profile, and never touches a
|
||||
* profile whose name doesn't start with `desktop-`, so the user's manually
|
||||
* configured CLI profiles are untouched.
|
||||
*/
|
||||
async function resolveActiveProfile(): Promise<ActiveProfile> {
|
||||
const target = targetApiBaseUrl;
|
||||
if (!target) return { name: "", port: DEFAULT_HEALTH_PORT };
|
||||
|
||||
const name = deriveProfileName(target);
|
||||
const cfg = await readProfileConfig(name);
|
||||
|
||||
if (cfg.server_url !== target) {
|
||||
cfg.server_url = target;
|
||||
await writeProfileConfig(name, cfg);
|
||||
console.log(`[daemon] initialized profile "${name}" → ${target}`);
|
||||
}
|
||||
|
||||
return { name, port: healthPortForProfile(name) };
|
||||
}
|
||||
|
||||
async function ensureActiveProfile(): Promise<ActiveProfile> {
|
||||
if (activeProfile) return activeProfile;
|
||||
activeProfile = await resolveActiveProfile();
|
||||
return activeProfile;
|
||||
}
|
||||
|
||||
function invalidateActiveProfile(): void {
|
||||
activeProfile = null;
|
||||
}
|
||||
|
||||
async function fetchHealth(): Promise<DaemonStatus> {
|
||||
// While the CLI is being downloaded or has permanently failed, short-circuit
|
||||
// polling — there's nothing to probe yet and /health calls would just return
|
||||
// "stopped", which would overwrite the correct setup state in the UI.
|
||||
if (currentState === "installing_cli" || currentState === "cli_not_found") {
|
||||
return { state: currentState };
|
||||
}
|
||||
|
||||
const active = await ensureActiveProfile();
|
||||
const data = await fetchHealthAtPort(active.port);
|
||||
|
||||
if (!data || data.status !== "running") {
|
||||
return {
|
||||
state: currentState === "starting" ? "starting" : "stopped",
|
||||
profile: active.name,
|
||||
};
|
||||
}
|
||||
|
||||
// Safety: if we have a target URL and the daemon on our port reports a
|
||||
// different server_url, it's not "our" daemon — drop it and re-resolve.
|
||||
if (
|
||||
targetApiBaseUrl &&
|
||||
data.server_url &&
|
||||
!urlsMatch(data.server_url, targetApiBaseUrl)
|
||||
) {
|
||||
invalidateActiveProfile();
|
||||
return { state: "stopped" };
|
||||
}
|
||||
|
||||
return {
|
||||
state: "running",
|
||||
pid: data.pid,
|
||||
uptime: data.uptime,
|
||||
daemonId: data.daemon_id,
|
||||
deviceName: data.device_name,
|
||||
agents: data.agents ?? [],
|
||||
workspaceCount: Array.isArray(data.workspaces)
|
||||
? data.workspaces.length
|
||||
: 0,
|
||||
profile: active.name,
|
||||
serverUrl: data.server_url,
|
||||
};
|
||||
}
|
||||
|
||||
function findCliOnPath(): string | null {
|
||||
const candidates = process.platform === "win32" ? ["multica.exe"] : ["multica"];
|
||||
const paths = (process.env["PATH"] ?? "").split(
|
||||
process.platform === "win32" ? ";" : ":",
|
||||
);
|
||||
if (process.platform === "darwin") {
|
||||
paths.push("/opt/homebrew/bin", "/usr/local/bin");
|
||||
}
|
||||
for (const name of candidates) {
|
||||
for (const dir of paths) {
|
||||
const full = join(dir, name);
|
||||
if (existsSync(full)) return full;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the path to the CLI binary bundled inside the Desktop app.
|
||||
*
|
||||
* - Dev (`electron-vite dev`): `app.getAppPath()` → `apps/desktop`, resolving
|
||||
* to `apps/desktop/resources/bin/multica`. `bundle-cli.mjs` populates this
|
||||
* before dev starts, so iterating on Go changes is "make build → restart".
|
||||
* - Packaged: `app.getAppPath()` → `<Multica.app>/Contents/Resources/app.asar`.
|
||||
* electron-builder's `asarUnpack: resources/**` extracts the binary to
|
||||
* `app.asar.unpacked/`, so we swap the path segment to execute it.
|
||||
*/
|
||||
function bundledCliPath(): string {
|
||||
const binName = process.platform === "win32" ? "multica.exe" : "multica";
|
||||
return join(app.getAppPath(), "resources", "bin", binName).replace(
|
||||
"app.asar",
|
||||
"app.asar.unpacked",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a usable `multica` binary path. Priority:
|
||||
* 1. Cached result from a previous successful resolve.
|
||||
* 2. Bundled binary shipped with the Desktop app (`bundle-cli.mjs`).
|
||||
* 3. Managed binary already installed in userData (`managedCliPath`).
|
||||
* 4. Download + install latest release into userData.
|
||||
* 5. `multica` on PATH (dev convenience / user-installed via brew).
|
||||
* Returns `null` only when all of the above fail.
|
||||
*
|
||||
* Bundled is preferred so Desktop iterates in lockstep with Go changes in
|
||||
* the same repo — avoids the 404 / stale-API problem when the Desktop's
|
||||
* TS side is ahead of the last published CLI release.
|
||||
*
|
||||
* This function is idempotent and safe to call concurrently — in-flight
|
||||
* installs are de-duplicated via `cliResolvePromise`.
|
||||
*/
|
||||
async function resolveCliBinary(): Promise<string | null> {
|
||||
if (cachedCliBinary !== undefined) return cachedCliBinary;
|
||||
if (cliResolvePromise) return cliResolvePromise;
|
||||
|
||||
cliResolvePromise = (async () => {
|
||||
const bundled = bundledCliPath();
|
||||
if (existsSync(bundled)) {
|
||||
console.log(`[daemon] using bundled CLI at ${bundled}`);
|
||||
cachedCliBinary = bundled;
|
||||
return bundled;
|
||||
}
|
||||
|
||||
const managed = managedCliPath();
|
||||
if (existsSync(managed)) {
|
||||
cachedCliBinary = managed;
|
||||
return managed;
|
||||
}
|
||||
|
||||
try {
|
||||
const installed = await ensureManagedCli();
|
||||
cachedCliBinary = installed;
|
||||
return installed;
|
||||
} catch (err) {
|
||||
console.warn("[daemon] CLI auto-install failed, falling back to PATH:", err);
|
||||
const onPath = findCliOnPath();
|
||||
cachedCliBinary = onPath;
|
||||
return onPath;
|
||||
}
|
||||
})();
|
||||
|
||||
try {
|
||||
return await cliResolvePromise;
|
||||
} finally {
|
||||
cliResolvePromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange the user's JWT for a long-lived PAT via POST /api/tokens. The
|
||||
* daemon needs a PAT (or `mul_` / `mdt_` token) because JWTs expire in 30
|
||||
* days and signatures are tied to a specific backend instance.
|
||||
*/
|
||||
async function mintPat(jwt: string): Promise<string> {
|
||||
if (!targetApiBaseUrl) {
|
||||
throw new Error("mint PAT: target API URL not set");
|
||||
}
|
||||
const url = `${targetApiBaseUrl.replace(/\/+$/, "")}/api/tokens`;
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${jwt}`,
|
||||
},
|
||||
// Omit expires_in_days → server treats as null → non-expiring PAT.
|
||||
body: JSON.stringify({ name: "Multica Desktop" }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => "");
|
||||
throw new Error(`mint PAT failed: ${res.status} ${res.statusText} ${body}`);
|
||||
}
|
||||
const data = (await res.json()) as { token?: unknown };
|
||||
if (typeof data.token !== "string" || !data.token.startsWith("mul_")) {
|
||||
throw new Error("mint PAT: response missing token");
|
||||
}
|
||||
return data.token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the active profile's config.json has a usable token for the daemon.
|
||||
*
|
||||
* - Input from the renderer is the user's JWT (from localStorage) plus the
|
||||
* current user's id, so we can detect session changes.
|
||||
* - If the profile already has a cached PAT (`mul_...`) AND the sidecar user
|
||||
* id matches the caller, reuse it — minting fresh on every launch would
|
||||
* accumulate garbage in the user's tokens page.
|
||||
* - On user mismatch (or first run) call POST /api/tokens with the JWT to
|
||||
* mint a fresh PAT, overwriting any stale cached PAT. This is the critical
|
||||
* path: without it, a previous user's PAT would be used by a new session.
|
||||
* - If the caller happens to pass a PAT directly, write it through.
|
||||
* - When we mint fresh and a daemon is already running, restart it so the
|
||||
* new credentials take effect (the Go daemon reads config at startup).
|
||||
*/
|
||||
async function syncToken(
|
||||
tokenFromRenderer: string,
|
||||
userId: string,
|
||||
): Promise<void> {
|
||||
const active = await ensureActiveProfile();
|
||||
const config = await readProfileConfig(active.name);
|
||||
const previousUserId = await readProfileUserId(active.name);
|
||||
const userChanged = Boolean(previousUserId) && previousUserId !== userId;
|
||||
const sameUserWithCachedPat =
|
||||
!userChanged &&
|
||||
previousUserId === userId &&
|
||||
typeof config.token === "string" &&
|
||||
config.token.startsWith("mul_");
|
||||
|
||||
let finalToken: string;
|
||||
if (tokenFromRenderer.startsWith("mul_")) {
|
||||
finalToken = tokenFromRenderer;
|
||||
} else if (sameUserWithCachedPat) {
|
||||
finalToken = config.token as string;
|
||||
} else {
|
||||
try {
|
||||
finalToken = await mintPat(tokenFromRenderer);
|
||||
console.log(
|
||||
`[daemon] minted PAT for profile "${active.name}" (user_changed=${userChanged})`,
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("[daemon] failed to mint PAT:", err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
config.token = finalToken;
|
||||
if (targetApiBaseUrl) config.server_url = targetApiBaseUrl;
|
||||
await writeProfileConfig(active.name, config);
|
||||
await writeProfileUserId(active.name, userId);
|
||||
|
||||
// If we just rotated credentials onto a running daemon, restart it so the
|
||||
// in-memory token in the Go process matches the new config.
|
||||
if (userChanged) {
|
||||
try {
|
||||
const existing = await fetchHealthAtPort(active.port);
|
||||
if (existing?.status === "running") {
|
||||
console.log(
|
||||
"[daemon] user switched — restarting daemon with new credentials",
|
||||
);
|
||||
void restartDaemon();
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("[daemon] restart-on-user-switch failed:", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPrefs(): Promise<DaemonPrefs> {
|
||||
try {
|
||||
const raw = await readFile(PREFS_PATH, "utf-8");
|
||||
const parsed = JSON.parse(raw);
|
||||
return { ...DEFAULT_PREFS, ...parsed };
|
||||
} catch {
|
||||
return { ...DEFAULT_PREFS };
|
||||
}
|
||||
}
|
||||
|
||||
async function savePrefs(prefs: DaemonPrefs): Promise<void> {
|
||||
const dir = join(homedir(), ".multica");
|
||||
await mkdir(dir, { recursive: true });
|
||||
await writeFile(PREFS_PATH, JSON.stringify(prefs, null, 2), "utf-8");
|
||||
}
|
||||
|
||||
async function clearToken(): Promise<void> {
|
||||
const active = await ensureActiveProfile();
|
||||
const config = await readProfileConfig(active.name);
|
||||
if ("token" in config) {
|
||||
delete config.token;
|
||||
await writeProfileConfig(active.name, config);
|
||||
}
|
||||
// Always drop the sidecar so a subsequent syncToken from any user is
|
||||
// treated as a fresh mint, not a reuse of a stale cached PAT.
|
||||
await removeProfileUserId(active.name);
|
||||
}
|
||||
|
||||
interface WatchedWorkspace {
|
||||
id: string;
|
||||
name: string;
|
||||
runtime_count?: number;
|
||||
}
|
||||
|
||||
interface WatchListResponse {
|
||||
watched: WatchedWorkspace[];
|
||||
unwatched: string[];
|
||||
}
|
||||
|
||||
async function daemonFetch(
|
||||
path: string,
|
||||
init?: RequestInit,
|
||||
): Promise<Response> {
|
||||
const active = await ensureActiveProfile();
|
||||
const url = `http://127.0.0.1:${active.port}${path}`;
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 10_000);
|
||||
try {
|
||||
return await fetch(url, { ...init, signal: controller.signal });
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
async function listWatchedWorkspaces(): Promise<WatchListResponse> {
|
||||
const empty: WatchListResponse = { watched: [], unwatched: [] };
|
||||
try {
|
||||
const res = await daemonFetch("/watch");
|
||||
if (!res.ok) {
|
||||
// Older daemon versions don't have /watch. Treat as "nothing watched
|
||||
// yet" so the UI renders cleanly; the user can take manual action.
|
||||
if (res.status === 404) return empty;
|
||||
throw new Error(`list /watch failed: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
const data = (await res.json()) as WatchListResponse;
|
||||
return {
|
||||
watched: Array.isArray(data.watched) ? data.watched : [],
|
||||
unwatched: Array.isArray(data.unwatched) ? data.unwatched : [],
|
||||
};
|
||||
} catch (err) {
|
||||
// Network errors (ECONNREFUSED when daemon is still starting, etc.) are
|
||||
// expected during startup races — return empty instead of throwing so
|
||||
// we don't spam the main-process error log on every poll.
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.log(`[daemon] list /watch unreachable: ${msg}`);
|
||||
return empty;
|
||||
}
|
||||
}
|
||||
|
||||
async function watchWorkspace(id: string, name: string): Promise<void> {
|
||||
const res = await daemonFetch("/watch", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ workspace_id: id, name }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => "");
|
||||
throw new Error(`watch failed: ${res.status} ${body}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function unwatchWorkspace(id: string): Promise<void> {
|
||||
const res = await daemonFetch(`/watch/${encodeURIComponent(id)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => "");
|
||||
throw new Error(`unwatch failed: ${res.status} ${body}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function withGuard<T>(fn: () => Promise<T>): Promise<T | { success: false; error: string }> {
|
||||
if (operationInProgress) {
|
||||
return { success: false, error: "Another daemon operation is in progress" };
|
||||
}
|
||||
operationInProgress = true;
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
operationInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
function profileArgs(active: ActiveProfile): string[] {
|
||||
return active.name ? ["--profile", active.name] : [];
|
||||
}
|
||||
|
||||
// Env passed to every CLI child so the daemon process knows it was spawned
|
||||
// by the Desktop app. The server uses this to mark runtimes as managed and
|
||||
// hide CLI self-update UI.
|
||||
const DESKTOP_SPAWN_ENV = {
|
||||
...process.env,
|
||||
MULTICA_LAUNCHED_BY: "desktop",
|
||||
};
|
||||
|
||||
async function startDaemon(): Promise<{ success: boolean; error?: string }> {
|
||||
const bin = await resolveCliBinary();
|
||||
if (!bin) return { success: false, error: "multica CLI is not installed" };
|
||||
|
||||
const active = await ensureActiveProfile();
|
||||
const existing = await fetchHealthAtPort(active.port);
|
||||
if (existing?.status === "running") {
|
||||
pollOnce();
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
currentState = "starting";
|
||||
sendStatus({ state: "starting" });
|
||||
|
||||
const args = ["daemon", "start", ...profileArgs(active)];
|
||||
|
||||
return new Promise((resolve) => {
|
||||
execFile(
|
||||
bin,
|
||||
args,
|
||||
{ timeout: 20_000, env: DESKTOP_SPAWN_ENV },
|
||||
(err) => {
|
||||
if (err) {
|
||||
currentState = "stopped";
|
||||
sendStatus({ state: "stopped" });
|
||||
resolve({ success: false, error: err.message });
|
||||
return;
|
||||
}
|
||||
// Stay in "starting" until pollOnce confirms /health — the CLI
|
||||
// returning 0 only means the supervisor was spawned, not that the
|
||||
// daemon process is already listening.
|
||||
pollOnce();
|
||||
resolve({ success: true });
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function stopDaemon(): Promise<{ success: boolean; error?: string }> {
|
||||
const bin = await resolveCliBinary();
|
||||
if (!bin) return { success: false, error: "multica CLI is not installed" };
|
||||
|
||||
const active = await ensureActiveProfile();
|
||||
currentState = "stopping";
|
||||
sendStatus({ state: "stopping" });
|
||||
|
||||
const args = ["daemon", "stop", ...profileArgs(active)];
|
||||
|
||||
return new Promise((resolve) => {
|
||||
execFile(bin, args, { timeout: 15_000 }, (err) => {
|
||||
if (err) {
|
||||
resolve({ success: false, error: err.message });
|
||||
} else {
|
||||
resolve({ success: true });
|
||||
}
|
||||
currentState = "stopped";
|
||||
sendStatus({ state: "stopped" });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function restartDaemon(): Promise<{ success: boolean; error?: string }> {
|
||||
const stopResult = await stopDaemon();
|
||||
if (!stopResult.success) return stopResult;
|
||||
return startDaemon();
|
||||
}
|
||||
|
||||
async function pollOnce(): Promise<void> {
|
||||
const status = await fetchHealth();
|
||||
currentState = status.state;
|
||||
sendStatus(status);
|
||||
}
|
||||
|
||||
function startPolling(): void {
|
||||
if (statusPollTimer) return;
|
||||
pollOnce();
|
||||
statusPollTimer = setInterval(pollOnce, POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures the CLI binary is available, then transitions into the normal
|
||||
* stopped/running state machine. Called once at startup and again on
|
||||
* user-triggered `daemon:retry-install`.
|
||||
*/
|
||||
async function bootstrapCli(): Promise<void> {
|
||||
const bin = await resolveCliBinary();
|
||||
if (!bin) {
|
||||
currentState = "cli_not_found";
|
||||
sendStatus({ state: "cli_not_found" });
|
||||
return;
|
||||
}
|
||||
currentState = "stopped";
|
||||
sendStatus({ state: "stopped" });
|
||||
startPolling();
|
||||
}
|
||||
|
||||
function stopPolling(): void {
|
||||
if (statusPollTimer) {
|
||||
clearInterval(statusPollTimer);
|
||||
statusPollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
const LOG_TAIL_INITIAL_WINDOW_BYTES = 32 * 1024;
|
||||
const LOG_TAIL_INITIAL_LINES = 200;
|
||||
const LOG_TAIL_POLL_MS = 500;
|
||||
|
||||
async function readLogRange(
|
||||
path: string,
|
||||
startAt: number,
|
||||
length: number,
|
||||
): Promise<string> {
|
||||
const handle = await open(path, "r");
|
||||
try {
|
||||
const buffer = Buffer.alloc(length);
|
||||
const { bytesRead } = await handle.read(buffer, 0, length, startAt);
|
||||
return buffer.subarray(0, bytesRead).toString("utf-8");
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
function sendLines(win: BrowserWindow, text: string): void {
|
||||
const lines = text.split("\n").filter((line) => line.length > 0);
|
||||
for (const line of lines) {
|
||||
win.webContents.send("daemon:log-line", line);
|
||||
}
|
||||
}
|
||||
|
||||
// Cross-platform tail -f replacement: read the tail of the file once, then
|
||||
// poll its stat with fs.watchFile and forward any new bytes since the last
|
||||
// known offset. watchFile works on macOS, Linux, and Windows; spawn("tail")
|
||||
// would silently fail on Windows.
|
||||
function startLogTail(win: BrowserWindow, retryCount = 0): void {
|
||||
stopLogTail();
|
||||
|
||||
void ensureActiveProfile().then(async (active) => {
|
||||
const logPath = profileLogPath(active.name);
|
||||
if (!existsSync(logPath)) {
|
||||
if (retryCount < LOG_TAIL_MAX_RETRIES) {
|
||||
setTimeout(() => startLogTail(win, retryCount + 1), LOG_TAIL_RETRY_MS);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let position = 0;
|
||||
try {
|
||||
const initialStats = await stat(logPath);
|
||||
const windowBytes = Math.min(
|
||||
initialStats.size,
|
||||
LOG_TAIL_INITIAL_WINDOW_BYTES,
|
||||
);
|
||||
const startAt = initialStats.size - windowBytes;
|
||||
if (windowBytes > 0) {
|
||||
const text = await readLogRange(logPath, startAt, windowBytes);
|
||||
const lines = text
|
||||
.split("\n")
|
||||
.filter((line) => line.length > 0)
|
||||
.slice(-LOG_TAIL_INITIAL_LINES);
|
||||
for (const line of lines) {
|
||||
win.webContents.send("daemon:log-line", line);
|
||||
}
|
||||
}
|
||||
position = initialStats.size;
|
||||
} catch (err) {
|
||||
console.warn("[daemon] log tail initial read failed:", err);
|
||||
return;
|
||||
}
|
||||
|
||||
const listener: StatsListener = (curr) => {
|
||||
const target = getMainWindow();
|
||||
if (!target) return;
|
||||
// File rotated/truncated — restart from the new beginning.
|
||||
if (curr.size < position) position = 0;
|
||||
if (curr.size === position) return;
|
||||
const from = position;
|
||||
const length = curr.size - from;
|
||||
position = curr.size;
|
||||
readLogRange(logPath, from, length)
|
||||
.then((text) => sendLines(target, text))
|
||||
.catch((err) => {
|
||||
console.warn("[daemon] log tail read failed:", err);
|
||||
});
|
||||
};
|
||||
|
||||
watchFile(logPath, { interval: LOG_TAIL_POLL_MS }, listener);
|
||||
logTailWatcher = { path: logPath, listener };
|
||||
});
|
||||
}
|
||||
|
||||
function stopLogTail(): void {
|
||||
if (logTailWatcher) {
|
||||
unwatchFile(logTailWatcher.path, logTailWatcher.listener);
|
||||
logTailWatcher = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function setupDaemonManager(
|
||||
windowGetter: () => BrowserWindow | null,
|
||||
): void {
|
||||
getMainWindow = windowGetter;
|
||||
|
||||
ipcMain.handle("daemon:set-target-api-url", async (_e, url: string) => {
|
||||
const normalized = url || null;
|
||||
if (targetApiBaseUrl !== normalized) {
|
||||
console.log(`[daemon] target API URL set to ${normalized ?? "(none)"}`);
|
||||
targetApiBaseUrl = normalized;
|
||||
invalidateActiveProfile();
|
||||
await pollOnce();
|
||||
}
|
||||
});
|
||||
ipcMain.handle("daemon:start", () => withGuard(() => startDaemon()));
|
||||
ipcMain.handle("daemon:stop", () => withGuard(() => stopDaemon()));
|
||||
ipcMain.handle("daemon:restart", () => withGuard(() => restartDaemon()));
|
||||
ipcMain.handle("daemon:get-status", () => fetchHealth());
|
||||
ipcMain.handle(
|
||||
"daemon:sync-token",
|
||||
(_event, token: string, userId: string) => syncToken(token, userId),
|
||||
);
|
||||
ipcMain.handle("daemon:clear-token", () => clearToken());
|
||||
ipcMain.handle("daemon:list-watched", () => listWatchedWorkspaces());
|
||||
ipcMain.handle(
|
||||
"daemon:watch-workspace",
|
||||
(_event, id: string, name: string) => watchWorkspace(id, name),
|
||||
);
|
||||
ipcMain.handle("daemon:unwatch-workspace", (_event, id: string) =>
|
||||
unwatchWorkspace(id),
|
||||
);
|
||||
ipcMain.handle("daemon:is-cli-installed", async () => {
|
||||
const bin = await resolveCliBinary();
|
||||
return bin !== null;
|
||||
});
|
||||
ipcMain.handle("daemon:retry-install", async () => {
|
||||
cachedCliBinary = undefined;
|
||||
cliResolvePromise = null;
|
||||
await bootstrapCli();
|
||||
});
|
||||
ipcMain.handle("daemon:get-prefs", () => loadPrefs());
|
||||
ipcMain.handle(
|
||||
"daemon:set-prefs",
|
||||
(_event, prefs: Partial<DaemonPrefs>) =>
|
||||
loadPrefs().then((cur) => {
|
||||
const merged = { ...cur, ...prefs };
|
||||
return savePrefs(merged).then(() => merged);
|
||||
}),
|
||||
);
|
||||
ipcMain.handle("daemon:auto-start", async () => {
|
||||
const prefs = await loadPrefs();
|
||||
if (!prefs.autoStart) return;
|
||||
const bin = await resolveCliBinary();
|
||||
if (!bin) return;
|
||||
const health = await fetchHealth();
|
||||
if (health.state === "running") return;
|
||||
await startDaemon();
|
||||
});
|
||||
|
||||
ipcMain.on("daemon:start-log-stream", () => {
|
||||
const win = getMainWindow();
|
||||
if (win) startLogTail(win);
|
||||
});
|
||||
|
||||
ipcMain.on("daemon:stop-log-stream", () => {
|
||||
stopLogTail();
|
||||
});
|
||||
|
||||
// First-run CLI install kicks off here. Status bar shows "Setting up…"
|
||||
// until the managed binary is on disk (instant on subsequent launches).
|
||||
currentState = "installing_cli";
|
||||
sendStatus({ state: "installing_cli" });
|
||||
void bootstrapCli();
|
||||
|
||||
let isQuitting = false;
|
||||
app.on("before-quit", (event) => {
|
||||
if (isQuitting) return;
|
||||
stopPolling();
|
||||
stopLogTail();
|
||||
|
||||
loadPrefs().then(async (prefs) => {
|
||||
if (prefs.autoStop) {
|
||||
isQuitting = true;
|
||||
event.preventDefault();
|
||||
try {
|
||||
await stopDaemon();
|
||||
} catch {
|
||||
// Best-effort stop on quit
|
||||
}
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,9 +1,34 @@
|
||||
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";
|
||||
import { setupAutoUpdater } from "./updater";
|
||||
import { setupDaemonManager } from "./daemon-manager";
|
||||
|
||||
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 +46,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 +72,83 @@ 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);
|
||||
});
|
||||
|
||||
// IPC: toggle immersive mode — hides the macOS traffic lights so full-screen
|
||||
// modals (create-workspace, onboarding) can place UI in the top-left corner
|
||||
// without fighting the native window controls' hit-test.
|
||||
ipcMain.handle("window:setImmersive", (_event, immersive: boolean) => {
|
||||
if (process.platform !== "darwin") return;
|
||||
mainWindow?.setWindowButtonVisibility(!immersive);
|
||||
});
|
||||
|
||||
createWindow();
|
||||
|
||||
setupAutoUpdater(() => mainWindow);
|
||||
setupDaemonManager(() => mainWindow);
|
||||
|
||||
// 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();
|
||||
|
||||
46
apps/desktop/src/main/updater.ts
Normal file
46
apps/desktop/src/main/updater.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { autoUpdater } from "electron-updater";
|
||||
import { BrowserWindow, ipcMain } from "electron";
|
||||
|
||||
autoUpdater.autoDownload = false;
|
||||
autoUpdater.autoInstallOnAppQuit = true;
|
||||
|
||||
export function setupAutoUpdater(getMainWindow: () => BrowserWindow | null): void {
|
||||
autoUpdater.on("update-available", (info) => {
|
||||
const win = getMainWindow();
|
||||
win?.webContents.send("updater:update-available", {
|
||||
version: info.version,
|
||||
releaseNotes: info.releaseNotes,
|
||||
});
|
||||
});
|
||||
|
||||
autoUpdater.on("download-progress", (progress) => {
|
||||
const win = getMainWindow();
|
||||
win?.webContents.send("updater:download-progress", {
|
||||
percent: progress.percent,
|
||||
});
|
||||
});
|
||||
|
||||
autoUpdater.on("update-downloaded", () => {
|
||||
const win = getMainWindow();
|
||||
win?.webContents.send("updater:update-downloaded");
|
||||
});
|
||||
|
||||
autoUpdater.on("error", (err) => {
|
||||
console.error("Auto-updater error:", err);
|
||||
});
|
||||
|
||||
ipcMain.handle("updater:download", () => {
|
||||
return autoUpdater.downloadUpdate();
|
||||
});
|
||||
|
||||
ipcMain.handle("updater:install", () => {
|
||||
autoUpdater.quitAndInstall(false, true);
|
||||
});
|
||||
|
||||
// Check for updates after a short delay to avoid blocking startup
|
||||
setTimeout(() => {
|
||||
autoUpdater.checkForUpdates().catch((err) => {
|
||||
console.error("Failed to check for updates:", err);
|
||||
});
|
||||
}, 5000);
|
||||
}
|
||||
62
apps/desktop/src/preload/index.d.ts
vendored
62
apps/desktop/src/preload/index.d.ts
vendored
@@ -1,8 +1,70 @@
|
||||
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>;
|
||||
/** Hide macOS traffic lights for full-screen modals; restore when false. */
|
||||
setImmersiveMode: (immersive: boolean) => Promise<void>;
|
||||
}
|
||||
|
||||
interface DaemonStatus {
|
||||
state: "running" | "stopped" | "starting" | "stopping" | "installing_cli" | "cli_not_found";
|
||||
pid?: number;
|
||||
uptime?: string;
|
||||
daemonId?: string;
|
||||
deviceName?: string;
|
||||
agents?: string[];
|
||||
workspaceCount?: number;
|
||||
profile?: string;
|
||||
serverUrl?: string;
|
||||
}
|
||||
|
||||
interface DaemonPrefs {
|
||||
autoStart: boolean;
|
||||
autoStop: boolean;
|
||||
}
|
||||
|
||||
interface DaemonAPI {
|
||||
start: () => Promise<{ success: boolean; error?: string }>;
|
||||
stop: () => Promise<{ success: boolean; error?: string }>;
|
||||
restart: () => Promise<{ success: boolean; error?: string }>;
|
||||
getStatus: () => Promise<DaemonStatus>;
|
||||
onStatusChange: (callback: (status: DaemonStatus) => void) => () => void;
|
||||
setTargetApiUrl: (url: string) => Promise<void>;
|
||||
syncToken: (token: string, userId: string) => Promise<void>;
|
||||
clearToken: () => Promise<void>;
|
||||
listWatched: () => Promise<{
|
||||
watched: Array<{ id: string; name: string; runtime_count?: number }>;
|
||||
unwatched: string[];
|
||||
}>;
|
||||
watchWorkspace: (id: string, name: string) => Promise<void>;
|
||||
unwatchWorkspace: (id: string) => Promise<void>;
|
||||
isCliInstalled: () => Promise<boolean>;
|
||||
getPrefs: () => Promise<DaemonPrefs>;
|
||||
setPrefs: (prefs: Partial<DaemonPrefs>) => Promise<DaemonPrefs>;
|
||||
autoStart: () => Promise<void>;
|
||||
retryInstall: () => Promise<void>;
|
||||
startLogStream: () => void;
|
||||
stopLogStream: () => void;
|
||||
onLogLine: (callback: (line: string) => void) => () => void;
|
||||
}
|
||||
|
||||
interface UpdaterAPI {
|
||||
onUpdateAvailable: (callback: (info: { version: string; releaseNotes?: string }) => void) => () => void;
|
||||
onDownloadProgress: (callback: (progress: { percent: number }) => void) => () => void;
|
||||
onUpdateDownloaded: (callback: () => void) => () => void;
|
||||
downloadUpdate: () => Promise<void>;
|
||||
installUpdate: () => Promise<void>;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
electron: ElectronAPI;
|
||||
desktopAPI: DesktopAPI;
|
||||
daemonAPI: DaemonAPI;
|
||||
updater: UpdaterAPI;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,114 @@
|
||||
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),
|
||||
/** Toggle immersive mode — hide macOS traffic lights for full-screen modals */
|
||||
setImmersiveMode: (immersive: boolean) =>
|
||||
ipcRenderer.invoke("window:setImmersive", immersive),
|
||||
};
|
||||
|
||||
interface DaemonStatus {
|
||||
state: "running" | "stopped" | "starting" | "stopping" | "installing_cli" | "cli_not_found";
|
||||
pid?: number;
|
||||
uptime?: string;
|
||||
daemonId?: string;
|
||||
deviceName?: string;
|
||||
agents?: string[];
|
||||
workspaceCount?: number;
|
||||
profile?: string;
|
||||
serverUrl?: string;
|
||||
}
|
||||
|
||||
const daemonAPI = {
|
||||
start: (): Promise<{ success: boolean; error?: string }> =>
|
||||
ipcRenderer.invoke("daemon:start"),
|
||||
stop: (): Promise<{ success: boolean; error?: string }> =>
|
||||
ipcRenderer.invoke("daemon:stop"),
|
||||
restart: (): Promise<{ success: boolean; error?: string }> =>
|
||||
ipcRenderer.invoke("daemon:restart"),
|
||||
getStatus: (): Promise<DaemonStatus> =>
|
||||
ipcRenderer.invoke("daemon:get-status"),
|
||||
onStatusChange: (callback: (status: DaemonStatus) => void) => {
|
||||
const handler = (_: unknown, status: DaemonStatus) => callback(status);
|
||||
ipcRenderer.on("daemon:status", handler);
|
||||
return () => ipcRenderer.removeListener("daemon:status", handler);
|
||||
},
|
||||
setTargetApiUrl: (url: string): Promise<void> =>
|
||||
ipcRenderer.invoke("daemon:set-target-api-url", url),
|
||||
syncToken: (token: string, userId: string): Promise<void> =>
|
||||
ipcRenderer.invoke("daemon:sync-token", token, userId),
|
||||
clearToken: (): Promise<void> =>
|
||||
ipcRenderer.invoke("daemon:clear-token"),
|
||||
listWatched: (): Promise<{
|
||||
watched: Array<{ id: string; name: string; runtime_count?: number }>;
|
||||
unwatched: string[];
|
||||
}> => ipcRenderer.invoke("daemon:list-watched"),
|
||||
watchWorkspace: (id: string, name: string): Promise<void> =>
|
||||
ipcRenderer.invoke("daemon:watch-workspace", id, name),
|
||||
unwatchWorkspace: (id: string): Promise<void> =>
|
||||
ipcRenderer.invoke("daemon:unwatch-workspace", id),
|
||||
isCliInstalled: (): Promise<boolean> =>
|
||||
ipcRenderer.invoke("daemon:is-cli-installed"),
|
||||
getPrefs: (): Promise<{ autoStart: boolean; autoStop: boolean }> =>
|
||||
ipcRenderer.invoke("daemon:get-prefs"),
|
||||
setPrefs: (prefs: Partial<{ autoStart: boolean; autoStop: boolean }>): Promise<{ autoStart: boolean; autoStop: boolean }> =>
|
||||
ipcRenderer.invoke("daemon:set-prefs", prefs),
|
||||
autoStart: (): Promise<void> =>
|
||||
ipcRenderer.invoke("daemon:auto-start"),
|
||||
retryInstall: (): Promise<void> =>
|
||||
ipcRenderer.invoke("daemon:retry-install"),
|
||||
startLogStream: () => ipcRenderer.send("daemon:start-log-stream"),
|
||||
stopLogStream: () => ipcRenderer.send("daemon:stop-log-stream"),
|
||||
onLogLine: (callback: (line: string) => void) => {
|
||||
const handler = (_: unknown, line: string) => callback(line);
|
||||
ipcRenderer.on("daemon:log-line", handler);
|
||||
return () => ipcRenderer.removeListener("daemon:log-line", handler);
|
||||
},
|
||||
};
|
||||
|
||||
const updaterAPI = {
|
||||
onUpdateAvailable: (callback: (info: { version: string; releaseNotes?: string }) => void) => {
|
||||
const handler = (_: unknown, info: { version: string; releaseNotes?: string }) => callback(info);
|
||||
ipcRenderer.on("updater:update-available", handler);
|
||||
return () => ipcRenderer.removeListener("updater:update-available", handler);
|
||||
},
|
||||
onDownloadProgress: (callback: (progress: { percent: number }) => void) => {
|
||||
const handler = (_: unknown, progress: { percent: number }) => callback(progress);
|
||||
ipcRenderer.on("updater:download-progress", handler);
|
||||
return () => ipcRenderer.removeListener("updater:download-progress", handler);
|
||||
},
|
||||
onUpdateDownloaded: (callback: () => void) => {
|
||||
const handler = () => callback();
|
||||
ipcRenderer.on("updater:update-downloaded", handler);
|
||||
return () => ipcRenderer.removeListener("updater:update-downloaded", handler);
|
||||
},
|
||||
downloadUpdate: () => ipcRenderer.invoke("updater:download"),
|
||||
installUpdate: () => ipcRenderer.invoke("updater:install"),
|
||||
};
|
||||
|
||||
if (process.contextIsolated) {
|
||||
contextBridge.exposeInMainWorld("electron", electronAPI);
|
||||
contextBridge.exposeInMainWorld("desktopAPI", desktopAPI);
|
||||
contextBridge.exposeInMainWorld("daemonAPI", daemonAPI);
|
||||
contextBridge.exposeInMainWorld("updater", updaterAPI);
|
||||
} else {
|
||||
// @ts-expect-error - fallback for non-isolated context
|
||||
window.electron = electronAPI;
|
||||
// @ts-expect-error - fallback for non-isolated context
|
||||
window.desktopAPI = desktopAPI;
|
||||
// @ts-expect-error - fallback for non-isolated context
|
||||
window.daemonAPI = daemonAPI;
|
||||
// @ts-expect-error - fallback for non-isolated context
|
||||
window.updater = updaterAPI;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,112 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { CoreProvider } from "@multica/core/platform";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { useWorkspaceStore, workspaceListOptions } from "@multica/core/workspace";
|
||||
import { api } from "@multica/core/api";
|
||||
import { ThemeProvider } from "@multica/ui/components/common/theme-provider";
|
||||
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
|
||||
import { Toaster } from "sonner";
|
||||
import { DesktopLoginPage } from "./pages/login";
|
||||
import { DesktopShell } from "./components/desktop-layout";
|
||||
import { UpdateNotification } from "./components/update-notification";
|
||||
|
||||
function AppContent() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const isLoading = useAuthStore((s) => s.isLoading);
|
||||
const [daemonRunning, setDaemonRunning] = useState(false);
|
||||
|
||||
// Tell the main process which backend URL we talk to, so daemon-manager
|
||||
// can pick the matching CLI profile (server_url from ~/.multica config).
|
||||
useEffect(() => {
|
||||
window.daemonAPI.setTargetApiUrl(DAEMON_TARGET_API_URL);
|
||||
}, []);
|
||||
|
||||
// Track daemon lifecycle so workspace reconciliation only runs when the
|
||||
// daemon is actually listening — avoids a startup race where the reconcile
|
||||
// effect fires before autoStart has spawned the child process.
|
||||
useEffect(() => {
|
||||
const unsub = window.daemonAPI.onStatusChange((s) => {
|
||||
setDaemonRunning(s.state === "running");
|
||||
});
|
||||
window.daemonAPI.getStatus().then((s) => {
|
||||
setDaemonRunning(s.state === "running");
|
||||
});
|
||||
return unsub;
|
||||
}, []);
|
||||
|
||||
// Listen for auth token delivered via deep link (multica://auth/callback?token=...)
|
||||
useEffect(() => {
|
||||
return window.desktopAPI.onAuthToken(async (token) => {
|
||||
try {
|
||||
const loggedIn = await useAuthStore.getState().loginWithToken(token);
|
||||
await window.daemonAPI.syncToken(token, loggedIn.id);
|
||||
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
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Sync token and start the daemon whenever the user logs in.
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
const token = localStorage.getItem("multica_token");
|
||||
if (!token) return;
|
||||
const userId = user.id;
|
||||
(async () => {
|
||||
try {
|
||||
await window.daemonAPI.syncToken(token, userId);
|
||||
await window.daemonAPI.autoStart();
|
||||
} catch (err) {
|
||||
console.error("Failed to sync daemon on login", err);
|
||||
}
|
||||
})();
|
||||
}, [user]);
|
||||
|
||||
// Reconcile the daemon's watched workspaces with what the user is a member
|
||||
// of. The query already hydrates on login and invalidates on create/delete
|
||||
// mutations, so this one effect covers both initial sync and incremental
|
||||
// updates. Opt-outs (unwatched denylist) are respected.
|
||||
const { data: workspaces } = useQuery({
|
||||
...workspaceListOptions(),
|
||||
enabled: !!user,
|
||||
});
|
||||
const lastSyncedIds = useRef<Set<string>>(new Set());
|
||||
useEffect(() => {
|
||||
if (!user || !workspaces || !daemonRunning) return;
|
||||
(async () => {
|
||||
const state = await window.daemonAPI.listWatched().catch(() => null);
|
||||
if (!state) return;
|
||||
const watchedIds = new Set(state.watched.map((w) => w.id));
|
||||
const unwatchedIds = new Set(state.unwatched);
|
||||
const currentIds = new Set(workspaces.map((w) => w.id));
|
||||
|
||||
// Add: anything in the API list but not yet watched (and not opted out).
|
||||
for (const ws of workspaces) {
|
||||
if (watchedIds.has(ws.id) || unwatchedIds.has(ws.id)) continue;
|
||||
try {
|
||||
await window.daemonAPI.watchWorkspace(ws.id, ws.name);
|
||||
} catch (err) {
|
||||
console.warn("watch workspace failed", ws.id, err);
|
||||
}
|
||||
}
|
||||
// Remove: anything we previously synced that is no longer in the API
|
||||
// list (the user left or deleted it).
|
||||
for (const prevId of lastSyncedIds.current) {
|
||||
if (!currentIds.has(prevId)) {
|
||||
try {
|
||||
await window.daemonAPI.unwatchWorkspace(prevId);
|
||||
} catch (err) {
|
||||
console.warn("unwatch workspace failed", prevId, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
lastSyncedIds.current = currentIds;
|
||||
})();
|
||||
}, [user, workspaces, daemonRunning]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -22,16 +120,37 @@ function AppContent() {
|
||||
return <DesktopShell />;
|
||||
}
|
||||
|
||||
// Backend the daemon should connect to — same URL the renderer talks to.
|
||||
const DAEMON_TARGET_API_URL =
|
||||
import.meta.env.VITE_API_URL || "http://localhost:8080";
|
||||
|
||||
// On logout, clear any cached PAT and stop the daemon so that a subsequent
|
||||
// login as a different user never inherits the previous user's credentials.
|
||||
async function handleDaemonLogout() {
|
||||
try {
|
||||
await window.daemonAPI.clearToken();
|
||||
} catch {
|
||||
// Best-effort — clearing is followed by stop which also hardens state.
|
||||
}
|
||||
try {
|
||||
await window.daemonAPI.stop();
|
||||
} catch {
|
||||
// Daemon may already be stopped.
|
||||
}
|
||||
}
|
||||
|
||||
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"}
|
||||
onLogout={handleDaemonLogout}
|
||||
>
|
||||
<AppContent />
|
||||
</CoreProvider>
|
||||
<Toaster />
|
||||
<UpdateNotification />
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
384
apps/desktop/src/renderer/src/components/daemon-panel.tsx
Normal file
384
apps/desktop/src/renderer/src/components/daemon-panel.tsx
Normal file
@@ -0,0 +1,384 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import {
|
||||
Play,
|
||||
Square,
|
||||
RotateCw,
|
||||
Server,
|
||||
ChevronDown,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { workspaceListOptions } from "@multica/core/workspace";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { Checkbox } from "@multica/ui/components/ui/checkbox";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@multica/ui/components/ui/sheet";
|
||||
import type { DaemonStatus, DaemonState } from "../../../shared/daemon-types";
|
||||
import { DAEMON_STATE_COLORS, DAEMON_STATE_LABELS } from "../../../shared/daemon-types";
|
||||
|
||||
interface DaemonPanelProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
status: DaemonStatus;
|
||||
}
|
||||
|
||||
const LOG_LEVEL_COLORS: Record<string, string> = {
|
||||
INFO: "text-info",
|
||||
WARN: "text-warning",
|
||||
ERROR: "text-destructive",
|
||||
DEBUG: "text-muted-foreground",
|
||||
};
|
||||
|
||||
function colorizeLogLine(line: string): { level: string; className: string } {
|
||||
for (const [level, className] of Object.entries(LOG_LEVEL_COLORS)) {
|
||||
if (line.includes(level)) return { level, className };
|
||||
}
|
||||
return { level: "", className: "text-muted-foreground" };
|
||||
}
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-baseline justify-between gap-4 py-1">
|
||||
<span className="shrink-0 text-xs text-muted-foreground">{label}</span>
|
||||
<span className="truncate text-right text-sm">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusDot({ state }: { state: DaemonState }) {
|
||||
return <span className={cn("inline-block size-2 rounded-full", DAEMON_STATE_COLORS[state])} />;
|
||||
}
|
||||
|
||||
interface LogEntry {
|
||||
id: number;
|
||||
line: string;
|
||||
}
|
||||
|
||||
const MAX_LOG_LINES = 500;
|
||||
let logIdCounter = 0;
|
||||
|
||||
export function DaemonPanel({ open, onOpenChange, status }: DaemonPanelProps) {
|
||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||
const [autoScroll, setAutoScroll] = useState(true);
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
const logContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Watched workspaces — populated from the daemon when the panel opens and
|
||||
// refreshed after every toggle so the checkbox state reflects reality.
|
||||
const { data: allWorkspaces } = useQuery({
|
||||
...workspaceListOptions(),
|
||||
enabled: open,
|
||||
});
|
||||
const [watchedIds, setWatchedIds] = useState<Set<string>>(new Set());
|
||||
const [togglingId, setTogglingId] = useState<string | null>(null);
|
||||
|
||||
const refreshWatched = useCallback(async () => {
|
||||
const state = await window.daemonAPI.listWatched().catch(() => null);
|
||||
if (state) setWatchedIds(new Set(state.watched.map((w) => w.id)));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && status.state === "running") void refreshWatched();
|
||||
}, [open, status.state, refreshWatched]);
|
||||
|
||||
const handleToggleWatch = useCallback(
|
||||
async (id: string, name: string, nextChecked: boolean) => {
|
||||
setTogglingId(id);
|
||||
try {
|
||||
if (nextChecked) {
|
||||
await window.daemonAPI.watchWorkspace(id, name);
|
||||
} else {
|
||||
await window.daemonAPI.unwatchWorkspace(id);
|
||||
}
|
||||
await refreshWatched();
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
nextChecked ? "Failed to watch workspace" : "Failed to unwatch workspace",
|
||||
{ description: err instanceof Error ? err.message : String(err) },
|
||||
);
|
||||
} finally {
|
||||
setTogglingId(null);
|
||||
}
|
||||
},
|
||||
[refreshWatched],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
window.daemonAPI.startLogStream();
|
||||
const unsub = window.daemonAPI.onLogLine((line) => {
|
||||
setLogs((prev) => {
|
||||
const next = [...prev, { id: ++logIdCounter, line }];
|
||||
return next.length > MAX_LOG_LINES ? next.slice(-MAX_LOG_LINES) : next;
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsub();
|
||||
window.daemonAPI.stopLogStream();
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoScroll && logContainerRef.current) {
|
||||
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
|
||||
}
|
||||
}, [logs, autoScroll]);
|
||||
|
||||
const handleLogScroll = useCallback(() => {
|
||||
const el = logContainerRef.current;
|
||||
if (!el) return;
|
||||
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
|
||||
setAutoScroll(atBottom);
|
||||
}, []);
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
if (logContainerRef.current) {
|
||||
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
|
||||
setAutoScroll(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleStart = useCallback(async () => {
|
||||
setActionLoading(true);
|
||||
const result = await window.daemonAPI.start();
|
||||
setActionLoading(false);
|
||||
if (!result.success) {
|
||||
toast.error("Failed to start daemon", { description: result.error });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleStop = useCallback(async () => {
|
||||
setActionLoading(true);
|
||||
const result = await window.daemonAPI.stop();
|
||||
setActionLoading(false);
|
||||
if (!result.success) {
|
||||
toast.error("Failed to stop daemon", { description: result.error });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleRestart = useCallback(async () => {
|
||||
setActionLoading(true);
|
||||
const result = await window.daemonAPI.restart();
|
||||
setActionLoading(false);
|
||||
if (!result.success) {
|
||||
toast.error("Failed to restart daemon", { description: result.error });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const isTransitioning = status.state === "starting" || status.state === "stopping";
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="flex flex-col sm:max-w-md"
|
||||
showCloseButton={false}
|
||||
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
|
||||
>
|
||||
<SheetHeader className="flex-row items-center justify-between gap-2 pr-3">
|
||||
<SheetTitle className="flex items-center gap-2">
|
||||
<Server className="size-4" />
|
||||
Local Daemon
|
||||
</SheetTitle>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenChange(false)}
|
||||
aria-label="Close"
|
||||
className="flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="flex-1 min-h-0 flex flex-col gap-4 px-4">
|
||||
<div className="shrink-0 space-y-4">
|
||||
{/* Status info */}
|
||||
<div className="rounded-lg border p-3 space-y-0.5">
|
||||
<InfoRow
|
||||
label="Status"
|
||||
value={
|
||||
<span className="flex items-center gap-1.5">
|
||||
<StatusDot state={status.state} />
|
||||
{DAEMON_STATE_LABELS[status.state]}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
{status.uptime && <InfoRow label="Uptime" value={status.uptime} />}
|
||||
<InfoRow label="Profile" value={status.profile || "default"} />
|
||||
{status.serverUrl && (
|
||||
<InfoRow
|
||||
label="Server"
|
||||
value={
|
||||
<span className="font-mono text-xs" title={status.serverUrl}>
|
||||
{status.serverUrl}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{status.agents && status.agents.length > 0 && (
|
||||
<InfoRow label="Agents" value={status.agents.join(", ")} />
|
||||
)}
|
||||
{status.deviceName && <InfoRow label="Device" value={status.deviceName} />}
|
||||
{status.daemonId && (
|
||||
<InfoRow
|
||||
label="Daemon ID"
|
||||
value={<span className="font-mono text-xs">{status.daemonId}</span>}
|
||||
/>
|
||||
)}
|
||||
{typeof status.workspaceCount === "number" && (
|
||||
<InfoRow label="Workspaces" value={status.workspaceCount} />
|
||||
)}
|
||||
{status.pid && (
|
||||
<InfoRow
|
||||
label="PID"
|
||||
value={<span className="font-mono text-xs">{status.pid}</span>}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{status.state === "installing_cli" ? (
|
||||
<div className="rounded-lg border border-dashed p-3 text-sm text-muted-foreground">
|
||||
Setting up the local runtime… this only happens the first time.
|
||||
</div>
|
||||
) : status.state === "cli_not_found" ? (
|
||||
<div className="rounded-lg border border-destructive/40 bg-destructive/5 p-3 space-y-2">
|
||||
<p className="text-sm">
|
||||
Couldn't download the local runtime. Check your network
|
||||
connection and try again.
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={async () => {
|
||||
setActionLoading(true);
|
||||
try {
|
||||
await window.daemonAPI.retryInstall();
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
}}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<RotateCw className="size-3.5 mr-1.5" />
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
{status.state === "stopped" ? (
|
||||
<Button size="sm" onClick={handleStart} disabled={actionLoading}>
|
||||
<Play className="size-3.5 mr-1.5" />
|
||||
Start
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleStop}
|
||||
disabled={actionLoading || isTransitioning}
|
||||
>
|
||||
<Square className="size-3.5 mr-1.5" />
|
||||
Stop
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleRestart}
|
||||
disabled={actionLoading || isTransitioning}
|
||||
>
|
||||
<RotateCw className="size-3.5 mr-1.5" />
|
||||
Restart
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Watched workspaces */}
|
||||
{status.state === "running" && allWorkspaces && allWorkspaces.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium">Watched Workspaces</h3>
|
||||
<div className="rounded-lg border divide-y max-h-48 overflow-y-auto">
|
||||
{allWorkspaces.map((ws) => {
|
||||
const checked = watchedIds.has(ws.id);
|
||||
const disabled = togglingId === ws.id;
|
||||
return (
|
||||
<label
|
||||
key={ws.id}
|
||||
className={cn(
|
||||
"flex items-center gap-2.5 px-3 py-2",
|
||||
disabled
|
||||
? "opacity-60 cursor-wait"
|
||||
: "cursor-pointer hover:bg-muted/40",
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
onCheckedChange={(next) =>
|
||||
handleToggleWatch(ws.id, ws.name, next === true)
|
||||
}
|
||||
/>
|
||||
<span className="truncate text-sm">{ws.name}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Logs — fills remaining vertical space down to the sheet bottom */}
|
||||
<div className="flex-1 min-h-0 flex flex-col gap-2 pb-4">
|
||||
<div className="flex items-center justify-between shrink-0">
|
||||
<h3 className="text-sm font-medium">Logs</h3>
|
||||
{!autoScroll && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={scrollToBottom}
|
||||
>
|
||||
<ChevronDown className="size-3 mr-1" />
|
||||
Scroll to bottom
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
ref={logContainerRef}
|
||||
onScroll={handleLogScroll}
|
||||
className="flex-1 min-h-0 overflow-y-auto rounded-lg border bg-muted/30 p-2 font-mono text-xs leading-relaxed"
|
||||
>
|
||||
{logs.length === 0 ? (
|
||||
<p className="text-muted-foreground/50 text-center py-8">
|
||||
{status.state === "running"
|
||||
? "Waiting for logs…"
|
||||
: "Start the daemon to see logs"}
|
||||
</p>
|
||||
) : (
|
||||
logs.map((entry) => {
|
||||
const { className } = colorizeLogLine(entry.line);
|
||||
return (
|
||||
<div key={entry.id} className={cn("whitespace-pre-wrap break-all", className)}>
|
||||
{entry.line}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
155
apps/desktop/src/renderer/src/components/daemon-runtime-card.tsx
Normal file
155
apps/desktop/src/renderer/src/components/daemon-runtime-card.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
Play,
|
||||
Square,
|
||||
RotateCw,
|
||||
Server,
|
||||
Activity,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { toast } from "sonner";
|
||||
import { DaemonPanel } from "./daemon-panel";
|
||||
import type { DaemonStatus } from "../../../shared/daemon-types";
|
||||
import { DAEMON_STATE_COLORS, DAEMON_STATE_LABELS, formatUptime } from "../../../shared/daemon-types";
|
||||
|
||||
export function DaemonRuntimeCard() {
|
||||
const [status, setStatus] = useState<DaemonStatus>({ state: "stopped" });
|
||||
const [panelOpen, setPanelOpen] = useState(false);
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
window.daemonAPI.getStatus().then((s) => setStatus(s));
|
||||
const unsub = window.daemonAPI.onStatusChange((s) => {
|
||||
setStatus(s);
|
||||
setActionLoading(false);
|
||||
});
|
||||
return unsub;
|
||||
}, []);
|
||||
|
||||
const handleStart = useCallback(async () => {
|
||||
setActionLoading(true);
|
||||
const result = await window.daemonAPI.start();
|
||||
if (!result.success) {
|
||||
setActionLoading(false);
|
||||
toast.error("Failed to start daemon", { description: result.error });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleStop = useCallback(async () => {
|
||||
setActionLoading(true);
|
||||
const result = await window.daemonAPI.stop();
|
||||
if (!result.success) {
|
||||
toast.error("Failed to stop daemon", { description: result.error });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleRestart = useCallback(async () => {
|
||||
setActionLoading(true);
|
||||
const result = await window.daemonAPI.restart();
|
||||
if (!result.success) {
|
||||
toast.error("Failed to restart daemon", { description: result.error });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const isTransitioning = status.state === "starting" || status.state === "stopping";
|
||||
const isRunning = status.state === "running";
|
||||
const isStopped = status.state === "stopped" || status.state === "cli_not_found";
|
||||
|
||||
const stopPropagation = (e: React.MouseEvent) => e.stopPropagation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setPanelOpen(true)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
setPanelOpen(true);
|
||||
}
|
||||
}}
|
||||
className="border-b px-4 py-3 cursor-pointer transition-colors hover:bg-muted/40 focus-visible:outline-none focus-visible:bg-muted/40"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex size-8 items-center justify-center rounded-lg bg-muted">
|
||||
<Server className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-medium">Local Daemon</h3>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<span className={cn("size-1.5 rounded-full", DAEMON_STATE_COLORS[status.state])} />
|
||||
<span className="text-xs text-muted-foreground">{DAEMON_STATE_LABELS[status.state]}</span>
|
||||
{isRunning && status.uptime && (
|
||||
<>
|
||||
<span className="text-xs text-muted-foreground">·</span>
|
||||
<span className="text-xs text-muted-foreground">{formatUptime(status.uptime)}</span>
|
||||
</>
|
||||
)}
|
||||
{isRunning && status.agents && status.agents.length > 0 && (
|
||||
<>
|
||||
<span className="text-xs text-muted-foreground">·</span>
|
||||
<span className="text-xs text-muted-foreground">{status.agents.join(", ")}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex items-center gap-1.5 shrink-0"
|
||||
onClick={stopPropagation}
|
||||
>
|
||||
{isStopped && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleStart}
|
||||
disabled={actionLoading || status.state === "cli_not_found"}
|
||||
>
|
||||
{actionLoading ? (
|
||||
<Activity className="size-3.5 mr-1.5 animate-pulse" />
|
||||
) : (
|
||||
<Play className="size-3.5 mr-1.5" />
|
||||
)}
|
||||
Start
|
||||
</Button>
|
||||
)}
|
||||
{isRunning && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={handleRestart}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<RotateCw className="size-3.5 mr-1.5" />
|
||||
Restart
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleStop}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<Square className="size-3.5 mr-1.5" />
|
||||
Stop
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{isTransitioning && (
|
||||
<Button size="sm" variant="outline" disabled>
|
||||
<Activity className="size-3.5 mr-1.5 animate-pulse" />
|
||||
{DAEMON_STATE_LABELS[status.state]}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DaemonPanel open={panelOpen} onOpenChange={setPanelOpen} status={status} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
103
apps/desktop/src/renderer/src/components/daemon-settings-tab.tsx
Normal file
103
apps/desktop/src/renderer/src/components/daemon-settings-tab.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { Switch } from "@multica/ui/components/ui/switch";
|
||||
import type { DaemonPrefs } from "../../../shared/daemon-types";
|
||||
|
||||
function SettingRow({
|
||||
label,
|
||||
description,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
description: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-6 py-4">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium">{label}</p>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">{description}</p>
|
||||
</div>
|
||||
<div className="shrink-0">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DaemonSettingsTab() {
|
||||
const [prefs, setPrefs] = useState<DaemonPrefs>({ autoStart: true, autoStop: false });
|
||||
const [cliInstalled, setCliInstalled] = useState<boolean | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
window.daemonAPI.getPrefs().then(setPrefs);
|
||||
window.daemonAPI.isCliInstalled().then(setCliInstalled);
|
||||
}, []);
|
||||
|
||||
const updatePref = useCallback(
|
||||
async (key: keyof DaemonPrefs, value: boolean) => {
|
||||
setSaving(true);
|
||||
const updated = await window.daemonAPI.setPrefs({ [key]: value });
|
||||
setPrefs(updated);
|
||||
setSaving(false);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Daemon</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Configure how the local agent daemon behaves with the desktop app.
|
||||
</p>
|
||||
|
||||
<div className="mt-6 divide-y">
|
||||
<SettingRow
|
||||
label="Auto-start on launch"
|
||||
description="Automatically start the daemon when the app opens and you are logged in."
|
||||
>
|
||||
<Switch
|
||||
checked={prefs.autoStart}
|
||||
onCheckedChange={(checked) => updatePref("autoStart", checked)}
|
||||
disabled={saving}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
label="Auto-stop on quit"
|
||||
description="Stop the daemon when the desktop app is closed. Disable this to keep the daemon running in the background."
|
||||
>
|
||||
<Switch
|
||||
checked={prefs.autoStop}
|
||||
onCheckedChange={(checked) => updatePref("autoStop", checked)}
|
||||
disabled={saving}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<div className="py-4">
|
||||
<p className="text-sm font-medium">CLI Status</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{cliInstalled === null
|
||||
? "Checking…"
|
||||
: cliInstalled
|
||||
? "multica CLI is installed and available in PATH."
|
||||
: "multica CLI not found. Install it to enable daemon management."}
|
||||
</p>
|
||||
{cliInstalled === false && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-2"
|
||||
onClick={() =>
|
||||
window.desktopAPI.openExternal(
|
||||
"https://github.com/multica-ai/multica#cli-installation",
|
||||
)
|
||||
}
|
||||
>
|
||||
Installation Guide
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
import { useEffect } from "react";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { useTabHistory } from "@/hooks/use-tab-history";
|
||||
import { useActiveTitleSync } from "@/hooks/use-tab-sync";
|
||||
import { useTabStore, resolveRouteIcon } from "@/stores/tab-store";
|
||||
import { SidebarProvider } from "@multica/ui/components/ui/sidebar";
|
||||
import {
|
||||
SidebarProvider,
|
||||
useSidebar,
|
||||
} from "@multica/ui/components/ui/sidebar";
|
||||
import { ModalRegistry } from "@multica/views/modals/registry";
|
||||
import { AppSidebar, DashboardGuard } from "@multica/views/layout";
|
||||
import { SearchCommand, SearchTrigger } from "@multica/views/search";
|
||||
@@ -28,6 +32,7 @@ function SidebarTopBar() {
|
||||
<button
|
||||
onClick={goBack}
|
||||
disabled={!canGoBack}
|
||||
aria-label="Go back"
|
||||
className="flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-30 disabled:pointer-events-none"
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
@@ -35,6 +40,7 @@ function SidebarTopBar() {
|
||||
<button
|
||||
onClick={goForward}
|
||||
disabled={!canGoForward}
|
||||
aria-label="Go forward"
|
||||
className="flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-30 disabled:pointer-events-none"
|
||||
>
|
||||
<ChevronRight className="size-4" />
|
||||
@@ -44,6 +50,23 @@ function SidebarTopBar() {
|
||||
);
|
||||
}
|
||||
|
||||
// The main area's top bar doubles as a window drag region. When the sidebar
|
||||
// is collapsed, we pad the left side so tabs don't land under the macOS
|
||||
// traffic lights (which live at roughly x=16..68 and always hit-test above HTML).
|
||||
function MainTopBar() {
|
||||
const { state } = useSidebar();
|
||||
const sidebarCollapsed = state === "collapsed";
|
||||
|
||||
return (
|
||||
<header
|
||||
className={cn("h-12 shrink-0", sidebarCollapsed && "pl-20")}
|
||||
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
|
||||
>
|
||||
<TabBar />
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function useInternalLinkHandler() {
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
@@ -78,24 +101,18 @@ export function DesktopShell() {
|
||||
<AppSidebar topSlot={<SidebarTopBar />} searchSlot={<SearchTrigger />} />
|
||||
{/* Right side: header + content container */}
|
||||
<div className="flex flex-1 min-w-0 flex-col">
|
||||
{/* Tab bar + drag region */}
|
||||
<header
|
||||
className="h-12 shrink-0"
|
||||
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
|
||||
>
|
||||
<TabBar />
|
||||
</header>
|
||||
{/* Content area with inset styling */}
|
||||
<div className="flex flex-1 min-h-0 flex-col overflow-hidden mr-2 mb-2 ml-0.5 rounded-xl shadow-sm bg-background">
|
||||
<MainTopBar />
|
||||
{/* 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>
|
||||
);
|
||||
|
||||
124
apps/desktop/src/renderer/src/components/update-notification.tsx
Normal file
124
apps/desktop/src/renderer/src/components/update-notification.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { ArrowDownToLine, RefreshCw, X } from "lucide-react";
|
||||
|
||||
type UpdateState =
|
||||
| { status: "idle" }
|
||||
| { status: "available"; version: string }
|
||||
| { status: "downloading"; percent: number }
|
||||
| { status: "ready" };
|
||||
|
||||
export function UpdateNotification() {
|
||||
const [state, setState] = useState<UpdateState>({ status: "idle" });
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const cleanups: (() => void)[] = [];
|
||||
|
||||
cleanups.push(
|
||||
window.updater.onUpdateAvailable((info) => {
|
||||
setState({ status: "available", version: info.version });
|
||||
setDismissed(false);
|
||||
}),
|
||||
);
|
||||
|
||||
cleanups.push(
|
||||
window.updater.onDownloadProgress((progress) => {
|
||||
setState({ status: "downloading", percent: progress.percent });
|
||||
}),
|
||||
);
|
||||
|
||||
cleanups.push(
|
||||
window.updater.onUpdateDownloaded(() => {
|
||||
setState({ status: "ready" });
|
||||
}),
|
||||
);
|
||||
|
||||
return () => cleanups.forEach((fn) => fn());
|
||||
}, []);
|
||||
|
||||
const handleDownload = useCallback(() => {
|
||||
// Prevent double-click: immediately transition to downloading state
|
||||
if (state.status !== "available") return;
|
||||
setState({ status: "downloading", percent: 0 });
|
||||
window.updater.downloadUpdate();
|
||||
}, [state.status]);
|
||||
|
||||
const handleInstall = useCallback(() => {
|
||||
window.updater.installUpdate();
|
||||
}, []);
|
||||
|
||||
// Only allow dismiss when update is available (not during download or ready)
|
||||
if (state.status === "idle") return null;
|
||||
if (dismissed && state.status === "available") return null;
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-50 w-80 rounded-lg border border-border bg-background p-4 shadow-lg animate-in slide-in-from-bottom-2 fade-in duration-300">
|
||||
<button
|
||||
onClick={() => setDismissed(true)}
|
||||
className="absolute top-2 right-2 rounded-md p-1 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
|
||||
{state.status === "available" && (
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 rounded-md bg-primary/10 p-1.5">
|
||||
<ArrowDownToLine className="size-4 text-primary" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium">New version available</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
v{state.version} is ready to download
|
||||
</p>
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className="mt-2 inline-flex items-center rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Download update
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state.status === "downloading" && (
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 rounded-md bg-primary/10 p-1.5">
|
||||
<ArrowDownToLine className="size-4 text-primary animate-pulse" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium">Downloading update...</p>
|
||||
<div className="mt-2 h-1.5 w-full rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary transition-all duration-300"
|
||||
style={{ width: `${Math.round(state.percent)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{Math.round(state.percent)}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state.status === "ready" && (
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 rounded-md bg-success/10 p-1.5">
|
||||
<RefreshCw className="size-4 text-success" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium">Update ready</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Restart to apply the update
|
||||
</p>
|
||||
<button
|
||||
onClick={handleInstall}
|
||||
className="mt-2 inline-flex items-center rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Restart now
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -13,9 +13,14 @@ import { ProjectsPage } from "@multica/views/projects/components";
|
||||
import { MyIssuesPage } from "@multica/views/my-issues";
|
||||
import { RuntimesPage } from "@multica/views/runtimes";
|
||||
import { SkillsPage } from "@multica/views/skills";
|
||||
import { DaemonRuntimeCard } from "./components/daemon-runtime-card";
|
||||
import { AgentsPage } from "@multica/views/agents";
|
||||
import { InboxPage } from "@multica/views/inbox";
|
||||
import { SettingsPage } from "@multica/views/settings";
|
||||
import { OnboardingWizard } from "@multica/views/onboarding";
|
||||
import { useNavigation } from "@multica/views/navigation";
|
||||
import { Server } from "lucide-react";
|
||||
import { DaemonSettingsTab } from "./components/daemon-settings-tab";
|
||||
|
||||
/**
|
||||
* Sets document.title from the deepest matched route's handle.title.
|
||||
@@ -47,6 +52,11 @@ function PageShell() {
|
||||
);
|
||||
}
|
||||
|
||||
function OnboardingRoute() {
|
||||
const nav = useNavigation();
|
||||
return <OnboardingWizard onComplete={() => nav.push("/issues")} />;
|
||||
}
|
||||
|
||||
/** Route definitions shared by all tabs (no layout wrapper). */
|
||||
export const appRoutes: RouteObject[] = [
|
||||
{
|
||||
@@ -76,15 +86,31 @@ export const appRoutes: RouteObject[] = [
|
||||
},
|
||||
{
|
||||
path: "runtimes",
|
||||
element: <RuntimesPage />,
|
||||
element: <RuntimesPage topSlot={<DaemonRuntimeCard />} />,
|
||||
handle: { title: "Runtimes" },
|
||||
},
|
||||
{ path: "skills", element: <SkillsPage />, handle: { title: "Skills" } },
|
||||
{ path: "agents", element: <AgentsPage />, handle: { title: "Agents" } },
|
||||
{ path: "inbox", element: <InboxPage />, handle: { title: "Inbox" } },
|
||||
{
|
||||
path: "onboarding",
|
||||
element: <OnboardingRoute />,
|
||||
handle: { title: "Get Started" },
|
||||
},
|
||||
{
|
||||
path: "settings",
|
||||
element: <SettingsPage />,
|
||||
element: (
|
||||
<SettingsPage
|
||||
extraAccountTabs={[
|
||||
{
|
||||
value: "daemon",
|
||||
label: "Daemon",
|
||||
icon: Server,
|
||||
content: <DaemonSettingsTab />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
),
|
||||
handle: { title: "Settings" },
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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 {
|
||||
|
||||
53
apps/desktop/src/shared/daemon-types.ts
Normal file
53
apps/desktop/src/shared/daemon-types.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
export type DaemonState =
|
||||
| "running"
|
||||
| "stopped"
|
||||
| "starting"
|
||||
| "stopping"
|
||||
| "installing_cli"
|
||||
| "cli_not_found";
|
||||
|
||||
export interface DaemonStatus {
|
||||
state: DaemonState;
|
||||
pid?: number;
|
||||
uptime?: string;
|
||||
daemonId?: string;
|
||||
deviceName?: string;
|
||||
agents?: string[];
|
||||
workspaceCount?: number;
|
||||
/** CLI profile this daemon belongs to. Empty string means the default profile. */
|
||||
profile?: string;
|
||||
/** Backend URL the daemon connects to. */
|
||||
serverUrl?: string;
|
||||
}
|
||||
|
||||
export interface DaemonPrefs {
|
||||
autoStart: boolean;
|
||||
autoStop: boolean;
|
||||
}
|
||||
|
||||
export const DAEMON_STATE_COLORS: Record<DaemonState, string> = {
|
||||
running: "bg-emerald-500",
|
||||
stopped: "bg-muted-foreground/40",
|
||||
starting: "bg-amber-500 animate-pulse",
|
||||
stopping: "bg-amber-500 animate-pulse",
|
||||
installing_cli: "bg-sky-500 animate-pulse",
|
||||
cli_not_found: "bg-red-500",
|
||||
};
|
||||
|
||||
export const DAEMON_STATE_LABELS: Record<DaemonState, string> = {
|
||||
running: "Running",
|
||||
stopped: "Stopped",
|
||||
starting: "Starting…",
|
||||
stopping: "Stopping…",
|
||||
installing_cli: "Setting up…",
|
||||
cli_not_found: "Setup Failed",
|
||||
};
|
||||
|
||||
export function formatUptime(uptime?: string): string {
|
||||
if (!uptime) return "";
|
||||
const match = uptime.match(/(?:(\d+)h)?(\d+)m/);
|
||||
if (!match) return uptime;
|
||||
const h = match[1] ? `${match[1]}h ` : "";
|
||||
const m = match[2] ? `${match[2]}m` : "";
|
||||
return `${h}${m}`.trim() || uptime;
|
||||
}
|
||||
@@ -8,8 +8,7 @@ description: Install the Multica CLI and start the agent daemon.
|
||||
### Homebrew (macOS/Linux)
|
||||
|
||||
```bash
|
||||
brew tap multica-ai/tap
|
||||
brew install multica
|
||||
brew install multica-ai/tap/multica
|
||||
```
|
||||
|
||||
### Build from Source
|
||||
@@ -48,25 +47,28 @@ rm /tmp/multica.tar.gz
|
||||
|
||||
### Update
|
||||
|
||||
```bash
|
||||
brew upgrade multica-ai/tap/multica
|
||||
```
|
||||
|
||||
For install script or manual installs, use:
|
||||
|
||||
```bash
|
||||
multica update
|
||||
```
|
||||
|
||||
This auto-detects your installation method (Homebrew or manual) and upgrades accordingly.
|
||||
`multica update` auto-detects your installation method and upgrades accordingly.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# 1. Authenticate (opens browser for login)
|
||||
multica login
|
||||
|
||||
# 2. Start the agent daemon
|
||||
multica daemon start
|
||||
|
||||
# 3. Done — agents in your watched workspaces can now execute tasks on your machine
|
||||
# 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 +78,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
|
||||
|
||||
@@ -19,14 +19,30 @@ 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
|
||||
### macOS / Linux (Homebrew - recommended)
|
||||
|
||||
# Authenticate and start
|
||||
multica login
|
||||
multica daemon start
|
||||
```bash
|
||||
brew install multica-ai/tap/multica
|
||||
```
|
||||
|
||||
### macOS / Linux (install script)
|
||||
|
||||
```bash
|
||||
# Install the CLI
|
||||
curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash
|
||||
```
|
||||
|
||||
### Windows (PowerShell)
|
||||
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
Then configure, authenticate, and start the daemon:
|
||||
|
||||
```bash
|
||||
# Configure, authenticate, and start the daemon
|
||||
multica setup
|
||||
```
|
||||
|
||||
The daemon auto-detects available agent CLIs (`claude`, `codex`, `openclaw`, `opencode`) on your PATH. When an agent is assigned a task, the daemon creates an isolated environment, runs the agent, and reports results back.
|
||||
|
||||
@@ -21,16 +21,21 @@ Each user who wants to run AI agents locally also installs the **`multica` CLI**
|
||||
|
||||
## Quick Install
|
||||
|
||||
One command to set up everything:
|
||||
Two commands to set up everything:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash -s -- --local
|
||||
# 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 everything. Then:
|
||||
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`**.
|
||||
|
||||
1. Open http://localhost:3000 — log in with any email + code **`888888`**
|
||||
2. Run `multica login` and `multica daemon start`
|
||||
<Callout>
|
||||
If the self-host server is already running and you only need the CLI on a macOS/Linux machine, install it with Homebrew: `brew install multica-ai/tap/multica`.
|
||||
</Callout>
|
||||
|
||||
<Callout>
|
||||
For a step-by-step setup, see below.
|
||||
@@ -78,11 +83,14 @@ brew install multica-ai/tap/multica
|
||||
You also need at least one AI agent CLI:
|
||||
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code) (`claude` on PATH)
|
||||
- [Codex](https://github.com/openai/codex) (`codex` on PATH)
|
||||
- OpenCode (`opencode` on PATH)
|
||||
- OpenClaw (`openclaw` on PATH)
|
||||
- Hermes (`hermes` on PATH)
|
||||
|
||||
### b) One-command setup
|
||||
|
||||
```bash
|
||||
multica setup --local
|
||||
multica setup self-host
|
||||
```
|
||||
|
||||
This automatically:
|
||||
@@ -91,6 +99,12 @@ This automatically:
|
||||
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
|
||||
@@ -98,7 +112,7 @@ multica daemon status
|
||||
```
|
||||
|
||||
<Callout>
|
||||
Alternatively, configure manually: `multica config local && multica login && multica daemon start`
|
||||
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
|
||||
@@ -123,16 +137,10 @@ multica daemon stop
|
||||
If you've been self-hosting and want to switch your CLI to [Multica Cloud](https://multica.ai):
|
||||
|
||||
```bash
|
||||
multica config set server_url https://api.multica.ai
|
||||
multica config set app_url https://multica.ai
|
||||
multica login
|
||||
multica setup
|
||||
```
|
||||
|
||||
Or re-run the install script without `--local` — it will reconfigure the CLI automatically:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash
|
||||
```
|
||||
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.
|
||||
@@ -211,6 +219,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.
|
||||
|
||||
@@ -36,14 +36,19 @@ The daemon auto-detects which CLIs are available on your PATH and registers them
|
||||
|
||||
## Reusable Skills
|
||||
|
||||
Every solution an agent creates can become a reusable skill for the whole team. Skills compound your team's capabilities over time:
|
||||
Multica supports two layers of skills:
|
||||
|
||||
- **Local skills** — Skills already installed in your local runtime (e.g., `.claude/skills/`, `.config/opencode/skills/`) are automatically discovered and used by agents. You do **not** need to upload them to Multica.
|
||||
- **Workspace skills** — Skills created or imported in the Multica Skills page are shared across the workspace. They are automatically injected into agent runs as supplementary context, so every team member's agents benefit from them.
|
||||
|
||||
Workspace skills are designed for team-wide sharing and collaboration — codify your team's best practices once, and every agent can leverage them:
|
||||
|
||||
- Deployments
|
||||
- Migrations
|
||||
- Code reviews
|
||||
- Common patterns
|
||||
|
||||
Skills are shared across the workspace, so any agent (or human) can leverage them.
|
||||
Your skill library compounds over time. Local skills give individual agents their capabilities; workspace skills align the entire team.
|
||||
|
||||
## Multi-Workspace Support
|
||||
|
||||
|
||||
@@ -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,8 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
loadingIndicator={<MulticaIcon className="size-6" />}
|
||||
searchSlot={<SearchTrigger />}
|
||||
extra={<><SearchCommand /><ChatWindow /><ChatFab /></>}
|
||||
onboardingPath="/onboarding"
|
||||
loginPath="/login"
|
||||
>
|
||||
{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,38 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
// Derive WebSocket URL from the page origin so self-hosted / LAN deployments
|
||||
// work without explicit NEXT_PUBLIC_WS_URL. The Next.js rewrite rule
|
||||
// (/ws → backend) handles proxying.
|
||||
function deriveWsUrl(): string | undefined {
|
||||
if (process.env.NEXT_PUBLIC_WS_URL) return process.env.NEXT_PUBLIC_WS_URL;
|
||||
if (typeof window === "undefined") return undefined;
|
||||
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
return `${proto}//${window.location.host}/ws`;
|
||||
}
|
||||
|
||||
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}
|
||||
wsUrl={deriveWsUrl()}
|
||||
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,72 @@ export const en: LandingDict = {
|
||||
fixes: "Bug Fixes",
|
||||
},
|
||||
entries: [
|
||||
{
|
||||
version: "0.1.33",
|
||||
date: "2026-04-14",
|
||||
title: "Gemini CLI & Agent Env Vars",
|
||||
changes: [],
|
||||
features: [
|
||||
"Google Gemini CLI as a new agent runtime with live log streaming",
|
||||
"Custom environment variables for agents (router/proxy mode) with dedicated settings tab",
|
||||
"\"Set parent issue\" and \"Add sub-issue\" actions in issue context menu",
|
||||
"CLI `--parent` flag for issue update and `--content-stdin` for piping comment content",
|
||||
"Sub-issues inherit parent project automatically",
|
||||
],
|
||||
improvements: [
|
||||
"Editor bubble menu and link preview rewritten for reliability",
|
||||
"OpenClaw backend P0+P1 improvements (multi-line JSON, incremental parsing)",
|
||||
"Self-hosted WebSocket URL auto-derived for LAN access",
|
||||
],
|
||||
fixes: [
|
||||
"S3 upload keys scoped by workspace (security)",
|
||||
"Workspace membership validation for subscriptions and uploads (security)",
|
||||
"Active tasks auto-cancelled when issue status changes to cancelled",
|
||||
"Agent task stall when process hangs on stdout",
|
||||
"Daemon trigger prompt now embeds the actual triggering comment content",
|
||||
"Login and dashboard redirect stability improvements",
|
||||
],
|
||||
},
|
||||
{
|
||||
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,72 @@ export const zh: LandingDict = {
|
||||
fixes: "问题修复",
|
||||
},
|
||||
entries: [
|
||||
{
|
||||
version: "0.1.33",
|
||||
date: "2026-04-14",
|
||||
title: "Gemini CLI 与 Agent 环境变量",
|
||||
changes: [],
|
||||
features: [
|
||||
"Google Gemini CLI 作为新的 Agent 运行时,支持实时日志流",
|
||||
"Agent 自定义环境变量(router/proxy 模式),新增专用设置标签页",
|
||||
"Issue 右键菜单新增「设置父 Issue」和「添加子 Issue」",
|
||||
"CLI `--parent` 更新父 Issue,`--content-stdin` 管道输入评论内容",
|
||||
"子 Issue 自动继承父级项目",
|
||||
],
|
||||
improvements: [
|
||||
"编辑器气泡菜单和链接预览重写",
|
||||
"OpenClaw 后端 P0+P1 优化(多行 JSON、增量解析)",
|
||||
"自部署 WebSocket URL 自动适配局域网访问",
|
||||
],
|
||||
fixes: [
|
||||
"S3 上传路径按工作区隔离(安全)",
|
||||
"订阅和上传新增工作区成员身份校验(安全)",
|
||||
"Issue 状态改为已取消时自动终止进行中的任务",
|
||||
"Agent 进程 stdout 挂起导致任务卡住",
|
||||
"Daemon 触发提示现在嵌入实际的触发评论内容",
|
||||
"登录和仪表盘跳转稳定性改进",
|
||||
],
|
||||
},
|
||||
{
|
||||
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",
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
export function proxy(request: NextRequest) {
|
||||
const loggedIn = request.cookies.has("multica_logged_in");
|
||||
if (loggedIn) {
|
||||
return NextResponse.redirect(new URL("/issues", request.url));
|
||||
}
|
||||
export function proxy(_request: NextRequest) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
|
||||
@@ -54,6 +54,7 @@ export const mockAgents: Agent[] = [
|
||||
status: "idle",
|
||||
runtime_mode: "cloud",
|
||||
runtime_config: {},
|
||||
custom_env: {},
|
||||
visibility: "workspace",
|
||||
max_concurrent_tasks: 3,
|
||||
owner_id: null,
|
||||
@@ -75,14 +76,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";
|
||||
|
||||
@@ -62,6 +62,7 @@ services:
|
||||
args:
|
||||
REMOTE_API_URL: http://backend:8080
|
||||
NEXT_PUBLIC_GOOGLE_CLIENT_ID: ${NEXT_PUBLIC_GOOGLE_CLIENT_ID:-}
|
||||
NEXT_PUBLIC_WS_URL: ${NEXT_PUBLIC_WS_URL:-}
|
||||
depends_on:
|
||||
- backend
|
||||
ports:
|
||||
|
||||
13
e2e/env.ts
Normal file
13
e2e/env.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { existsSync } from "fs";
|
||||
import { resolve } from "path";
|
||||
import { config } from "dotenv";
|
||||
|
||||
const envCandidates = [".env.worktree", ".env"];
|
||||
|
||||
for (const filename of envCandidates) {
|
||||
const path = resolve(process.cwd(), filename);
|
||||
if (existsSync(path)) {
|
||||
config({ path });
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
* Uses raw fetch so E2E tests have zero build-time coupling to the web app.
|
||||
*/
|
||||
|
||||
import "./env";
|
||||
import pg from "pg";
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? `http://localhost:${process.env.PORT ?? "8080"}`;
|
||||
@@ -21,39 +22,43 @@ export class TestApiClient {
|
||||
private createdIssueIds: string[] = [];
|
||||
|
||||
async login(email: string, name: string) {
|
||||
// Step 1: Send verification code
|
||||
const sendRes = await fetch(`${API_BASE}/auth/send-code`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
if (!sendRes.ok) {
|
||||
// Rate limited — code already sent recently, read it from DB
|
||||
if (sendRes.status !== 429) {
|
||||
throw new Error(`send-code failed: ${sendRes.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Read code from database
|
||||
const client = new pg.Client(DATABASE_URL);
|
||||
await client.connect();
|
||||
try {
|
||||
// Keep each E2E login isolated so previous test runs do not trip the
|
||||
// per-email send-code rate limit.
|
||||
await client.query("DELETE FROM verification_code WHERE email = $1", [email]);
|
||||
|
||||
// Step 1: Send verification code
|
||||
const sendRes = await fetch(`${API_BASE}/auth/send-code`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
if (!sendRes.ok) {
|
||||
throw new Error(`send-code failed: ${sendRes.status}`);
|
||||
}
|
||||
|
||||
// Step 2: Read code from database
|
||||
const result = await client.query(
|
||||
"SELECT code FROM verification_code WHERE email = $1 AND used = FALSE AND expires_at > now() ORDER BY created_at DESC LIMIT 1",
|
||||
[email]
|
||||
[email],
|
||||
);
|
||||
if (result.rows.length === 0) {
|
||||
throw new Error(`No verification code found for ${email}`);
|
||||
}
|
||||
const code = result.rows[0].code;
|
||||
|
||||
// Step 3: Verify code to get JWT
|
||||
const verifyRes = await fetch(`${API_BASE}/auth/verify-code`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, code }),
|
||||
body: JSON.stringify({ email, code: result.rows[0].code }),
|
||||
});
|
||||
if (!verifyRes.ok) {
|
||||
throw new Error(`verify-code failed: ${verifyRes.status}`);
|
||||
}
|
||||
const data = await verifyRes.json();
|
||||
|
||||
this.token = data.token;
|
||||
|
||||
// Update user name if needed
|
||||
@@ -64,6 +69,8 @@ export class TestApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
await client.query("DELETE FROM verification_code WHERE email = $1", [email]);
|
||||
|
||||
return data;
|
||||
} finally {
|
||||
await client.end();
|
||||
|
||||
@@ -11,11 +11,14 @@ test.describe("Issues", () => {
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
await api.cleanup();
|
||||
if (api) {
|
||||
await api.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("issues page loads with board view", async ({ page }) => {
|
||||
await expect(page.locator("text=All Issues")).toBeVisible();
|
||||
await api.createIssue("E2E Board View " + Date.now());
|
||||
await page.reload();
|
||||
|
||||
// Board columns should be visible
|
||||
await expect(page.locator("text=Backlog")).toBeVisible();
|
||||
@@ -23,29 +26,36 @@ test.describe("Issues", () => {
|
||||
await expect(page.locator("text=In Progress")).toBeVisible();
|
||||
});
|
||||
|
||||
test("can switch between board and list view", async ({ page }) => {
|
||||
await expect(page.locator("text=All Issues")).toBeVisible();
|
||||
test("can switch from board to list view", async ({ page }) => {
|
||||
const title = "E2E List Switch " + Date.now();
|
||||
await api.createIssue(title);
|
||||
await page.reload();
|
||||
await expect(page.locator("text=Backlog")).toBeVisible();
|
||||
|
||||
// Switch to list view
|
||||
await page.click("text=List");
|
||||
await expect(page.locator("text=All Issues")).toBeVisible();
|
||||
|
||||
// Switch back to board view
|
||||
await page.click("text=Board");
|
||||
await expect(page.locator("text=Backlog")).toBeVisible();
|
||||
await expect(page.getByText(title)).toBeVisible();
|
||||
});
|
||||
|
||||
test("can create a new issue", async ({ page }) => {
|
||||
await page.click("text=New Issue");
|
||||
const newIssueButton = page.getByRole("button", { name: "New Issue" });
|
||||
await expect(newIssueButton).toBeVisible();
|
||||
await newIssueButton.click();
|
||||
|
||||
const title = "E2E Created " + Date.now();
|
||||
await page.fill('input[placeholder="Issue title..."]', title);
|
||||
await page.click("text=Create");
|
||||
const titleInput = page.getByRole("textbox", { name: "Issue title" });
|
||||
await expect(titleInput).toBeVisible();
|
||||
await titleInput.fill(title);
|
||||
await page.getByRole("button", { name: "Create Issue" }).click();
|
||||
|
||||
// New issue should appear on the page
|
||||
await expect(page.locator(`text=${title}`).first()).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
await expect(page.getByText("Issue created")).toBeVisible({ timeout: 10000 });
|
||||
await expect(
|
||||
page.getByRole("region", { name: /Notifications/ }).getByText(title),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "View issue" }).click();
|
||||
await page.waitForURL(/\/issues\/[\w-]+/);
|
||||
await expect(page.locator("text=Properties")).toBeVisible();
|
||||
});
|
||||
|
||||
test("can navigate to issue detail page", async ({ page }) => {
|
||||
@@ -54,7 +64,6 @@ test.describe("Issues", () => {
|
||||
|
||||
// Reload to see the new issue
|
||||
await page.reload();
|
||||
await expect(page.locator("text=All Issues")).toBeVisible();
|
||||
|
||||
// Navigate to the issue detail
|
||||
const issueLink = page.locator(`a[href="/issues/${issue.id}"]`);
|
||||
@@ -71,18 +80,15 @@ test.describe("Issues", () => {
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("can cancel issue creation", async ({ page }) => {
|
||||
await page.click("text=New Issue");
|
||||
test("can dismiss issue creation", async ({ page }) => {
|
||||
await page.getByRole("button", { name: "New Issue" }).click();
|
||||
|
||||
await expect(
|
||||
page.locator('input[placeholder="Issue title..."]'),
|
||||
).toBeVisible();
|
||||
const titleInput = page.getByRole("textbox", { name: "Issue title" });
|
||||
await expect(titleInput).toBeVisible();
|
||||
|
||||
await page.click("text=Cancel");
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await expect(
|
||||
page.locator('input[placeholder="Issue title..."]'),
|
||||
).not.toBeVisible();
|
||||
await expect(page.locator("text=New Issue")).toBeVisible();
|
||||
await expect(titleInput).not.toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "New Issue" })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
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",
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -41,6 +41,8 @@ import type {
|
||||
Attachment,
|
||||
ChatSession,
|
||||
ChatMessage,
|
||||
ChatPendingTask,
|
||||
PendingChatTasksResponse,
|
||||
SendChatMessageResponse,
|
||||
Project,
|
||||
CreateProjectRequest,
|
||||
@@ -52,6 +54,7 @@ import type {
|
||||
ReorderPinsRequest,
|
||||
} from "../types";
|
||||
import { type Logger, noopLogger } from "../logger";
|
||||
import { createRequestId } from "../utils";
|
||||
|
||||
export interface ApiClientOptions {
|
||||
logger?: Logger;
|
||||
@@ -63,6 +66,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 +91,10 @@ export class ApiClient {
|
||||
this.logger = options?.logger ?? noopLogger;
|
||||
}
|
||||
|
||||
getBaseUrl(): string {
|
||||
return this.baseUrl;
|
||||
}
|
||||
|
||||
setToken(token: string | null) {
|
||||
this.token = token;
|
||||
}
|
||||
@@ -84,10 +103,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 +137,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 +161,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 +196,14 @@ export class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async logout(): Promise<void> {
|
||||
await this.fetch("/auth/logout", { method: "POST" });
|
||||
}
|
||||
|
||||
async issueCliToken(): Promise<{ token: string }> {
|
||||
return this.fetch("/api/cli-token", { method: "POST" });
|
||||
}
|
||||
|
||||
async getMe(): Promise<User> {
|
||||
return this.fetch("/api/me");
|
||||
}
|
||||
@@ -234,6 +271,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 +473,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 +651,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 });
|
||||
|
||||
@@ -664,6 +705,18 @@ export class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async getPendingChatTask(sessionId: string): Promise<ChatPendingTask> {
|
||||
return this.fetch(`/api/chat/sessions/${sessionId}/pending-task`);
|
||||
}
|
||||
|
||||
async listPendingChatTasks(): Promise<PendingChatTasksResponse> {
|
||||
return this.fetch(`/api/chat/pending-tasks`);
|
||||
}
|
||||
|
||||
async markChatSessionRead(sessionId: string): Promise<void> {
|
||||
await this.fetch(`/api/chat/sessions/${sessionId}/read`, { method: "POST" });
|
||||
}
|
||||
|
||||
async cancelTaskById(taskId: string): Promise<void> {
|
||||
await this.fetch(`/api/tasks/${taskId}/cancel`, { method: "POST" });
|
||||
}
|
||||
|
||||
@@ -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, DRAFT_NEW_SESSION } from "./store";
|
||||
export type { ChatStoreOptions, ChatState, ChatTimelineItem } from "./store";
|
||||
|
||||
import type { createChatStore as CreateChatStoreFn } from "./store";
|
||||
|
||||
@@ -2,14 +2,67 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "../api";
|
||||
import { useWorkspaceId } from "../hooks";
|
||||
import { chatKeys } from "./queries";
|
||||
import { createLogger } from "../logger";
|
||||
import type { ChatSession } from "../types";
|
||||
|
||||
const logger = createLogger("chat.mut");
|
||||
|
||||
export function useCreateChatSession() {
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: { agent_id: string; title?: string }) =>
|
||||
api.createChatSession(data),
|
||||
mutationFn: (data: { agent_id: string; title?: string }) => {
|
||||
logger.info("createChatSession.start", { agent_id: data.agent_id, titleLength: data.title?.length ?? 0 });
|
||||
return api.createChatSession(data);
|
||||
},
|
||||
onSuccess: (session) => {
|
||||
logger.info("createChatSession.success", { sessionId: session.id, agentId: session.agent_id });
|
||||
},
|
||||
onError: (err) => {
|
||||
logger.error("createChatSession.error", err);
|
||||
},
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({ queryKey: chatKeys.sessions(wsId) });
|
||||
qc.invalidateQueries({ queryKey: chatKeys.allSessions(wsId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the session's unread state server-side. Optimistically flips
|
||||
* has_unread to false in the cached lists so the FAB badge drops
|
||||
* immediately. The server broadcasts chat:session_read so other devices
|
||||
* also sync.
|
||||
*/
|
||||
export function useMarkChatSessionRead() {
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (sessionId: string) => {
|
||||
logger.info("markChatSessionRead.start", { sessionId });
|
||||
return api.markChatSessionRead(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));
|
||||
|
||||
const clear = (old?: ChatSession[]) =>
|
||||
old?.map((s) => (s.id === sessionId ? { ...s, has_unread: false } : s));
|
||||
qc.setQueryData<ChatSession[]>(chatKeys.sessions(wsId), clear);
|
||||
qc.setQueryData<ChatSession[]>(chatKeys.allSessions(wsId), clear);
|
||||
|
||||
return { prevSessions, prevAll };
|
||||
},
|
||||
onError: (err, sessionId, ctx) => {
|
||||
logger.error("markChatSessionRead.error.rollback", { sessionId, err });
|
||||
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) });
|
||||
@@ -22,8 +75,37 @@ export function useArchiveChatSession() {
|
||||
const wsId = useWorkspaceId();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (sessionId: string) => api.archiveChatSession(sessionId),
|
||||
onSettled: () => {
|
||||
mutationFn: (sessionId: string) => {
|
||||
logger.info("archiveChatSession.start", { sessionId });
|
||||
return 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,
|
||||
),
|
||||
);
|
||||
|
||||
logger.debug("archiveChatSession.optimistic", { sessionId });
|
||||
return { prevSessions, prevAll };
|
||||
},
|
||||
onError: (err, sessionId, ctx) => {
|
||||
logger.error("archiveChatSession.error.rollback", { sessionId, err });
|
||||
if (ctx?.prevSessions) qc.setQueryData(chatKeys.sessions(wsId), ctx.prevSessions);
|
||||
if (ctx?.prevAll) qc.setQueryData(chatKeys.allSessions(wsId), ctx.prevAll);
|
||||
},
|
||||
onSettled: (_data, _err, sessionId) => {
|
||||
logger.debug("archiveChatSession.settled", { sessionId });
|
||||
qc.invalidateQueries({ queryKey: chatKeys.sessions(wsId) });
|
||||
qc.invalidateQueries({ queryKey: chatKeys.allSessions(wsId) });
|
||||
},
|
||||
|
||||
@@ -14,6 +14,11 @@ export const chatKeys = {
|
||||
allSessions: (wsId: string) => [...chatKeys.all(wsId), "sessions", "all"] as const,
|
||||
session: (wsId: string, id: string) => [...chatKeys.all(wsId), "session", id] as const,
|
||||
messages: (sessionId: string) => ["chat", "messages", sessionId] as const,
|
||||
pendingTask: (sessionId: string) => ["chat", "pending-task", sessionId] as const,
|
||||
/** Aggregate of in-flight chat tasks for the current user — FAB reads this. */
|
||||
pendingTasks: (wsId: string) => [...chatKeys.all(wsId), "pending-tasks"] as const,
|
||||
/** Per-task execution messages — shared with issue agent cards. */
|
||||
taskMessages: (taskId: string) => ["task-messages", taskId] as const,
|
||||
};
|
||||
|
||||
export function chatSessionsOptions(wsId: string) {
|
||||
@@ -49,3 +54,44 @@ export function chatMessagesOptions(sessionId: string) {
|
||||
staleTime: Infinity,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Pending task for a chat session — the "is something still running?" signal.
|
||||
* Refetched via WS invalidation in useRealtimeSync when chat:message / chat:done
|
||||
* / task:completed / task:failed arrive.
|
||||
*/
|
||||
export function pendingChatTaskOptions(sessionId: string) {
|
||||
return queryOptions({
|
||||
queryKey: chatKeys.pendingTask(sessionId),
|
||||
queryFn: () => api.getPendingChatTask(sessionId),
|
||||
enabled: !!sessionId,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Timeline for a single task — rendered by both the live chat view (while a
|
||||
* task is running) and AssistantMessage (for completed tasks). WS
|
||||
* `task:message` events seed this cache in real time via useRealtimeSync.
|
||||
*/
|
||||
export function taskMessagesOptions(taskId: string) {
|
||||
return queryOptions({
|
||||
queryKey: chatKeys.taskMessages(taskId),
|
||||
queryFn: () => api.listTaskMessages(taskId),
|
||||
enabled: !!taskId,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate of in-flight chat tasks for the current user in this workspace.
|
||||
* Drives the FAB "running" indicator while the chat window is minimised —
|
||||
* no per-session query is active then, so we need this roll-up.
|
||||
*/
|
||||
export function pendingChatTasksOptions(wsId: string) {
|
||||
return queryOptions({
|
||||
queryKey: chatKeys.pendingTasks(wsId),
|
||||
queryFn: () => api.listPendingChatTasks(),
|
||||
staleTime: Infinity,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,10 +1,54 @@
|
||||
import { create } from "zustand";
|
||||
import type { StorageAdapter } from "../types";
|
||||
import { getCurrentWorkspaceId, registerForWorkspaceRehydration } from "../platform/workspace-storage";
|
||||
import { createLogger } from "../logger";
|
||||
|
||||
const logger = createLogger("chat.store");
|
||||
|
||||
const AGENT_STORAGE_KEY = "multica:chat:selectedAgentId";
|
||||
const SESSION_STORAGE_KEY = "multica:chat:activeSessionId";
|
||||
/** Drafts are stored as one JSON blob per workspace: { [sessionId]: text }. */
|
||||
const DRAFTS_KEY = "multica:chat:drafts";
|
||||
/** Placeholder sessionId for a chat that hasn't been created yet. */
|
||||
export const DRAFT_NEW_SESSION = "__new__";
|
||||
const CHAT_WIDTH_KEY = "multica:chat:width";
|
||||
const CHAT_HEIGHT_KEY = "multica:chat:height";
|
||||
const CHAT_EXPANDED_KEY = "multica:chat:expanded";
|
||||
|
||||
function readDrafts(storage: StorageAdapter, key: string): Record<string, string> {
|
||||
const raw = storage.getItem(key);
|
||||
if (!raw) return {};
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return typeof parsed === "object" && parsed !== null ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function writeDrafts(storage: StorageAdapter, key: string, drafts: Record<string, string>) {
|
||||
// Prune empty entries so the blob doesn't grow unbounded.
|
||||
const pruned: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(drafts)) {
|
||||
if (v) pruned[k] = v;
|
||||
}
|
||||
if (Object.keys(pruned).length === 0) {
|
||||
storage.removeItem(key);
|
||||
} else {
|
||||
storage.setItem(key, JSON.stringify(pruned));
|
||||
}
|
||||
}
|
||||
|
||||
export const CHAT_MIN_W = 360;
|
||||
export const CHAT_MIN_H = 480;
|
||||
export const CHAT_DEFAULT_W = 420;
|
||||
export const CHAT_DEFAULT_H = 600;
|
||||
|
||||
/**
|
||||
* Kept as a public type because existing consumers (chat-message-list,
|
||||
* views/chat types) import it. Items themselves no longer live in the
|
||||
* store — they flow through the React Query cache keyed by task id.
|
||||
*/
|
||||
export interface ChatTimelineItem {
|
||||
seq: number;
|
||||
type: "tool_use" | "tool_result" | "thinking" | "text" | "error";
|
||||
@@ -16,21 +60,26 @@ export interface ChatTimelineItem {
|
||||
|
||||
export interface ChatState {
|
||||
isOpen: boolean;
|
||||
isFullscreen: boolean;
|
||||
activeSessionId: string | null;
|
||||
pendingTaskId: string | null;
|
||||
selectedAgentId: string | null;
|
||||
showHistory: boolean;
|
||||
timelineItems: ChatTimelineItem[];
|
||||
/** Drafts per session: sessionId (or DRAFT_NEW_SESSION) → markdown text. */
|
||||
inputDrafts: Record<string, 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;
|
||||
/** sessionId accepts a real session UUID or DRAFT_NEW_SESSION. */
|
||||
setInputDraft: (sessionId: string, draft: string) => void;
|
||||
clearInputDraft: (sessionId: string) => void;
|
||||
/** Persist raw size and auto-exit expanded mode. */
|
||||
setChatSize: (width: number, height: number) => void;
|
||||
setExpanded: (expanded: boolean) => void;
|
||||
}
|
||||
|
||||
export interface ChatStoreOptions {
|
||||
@@ -45,23 +94,26 @@ export function createChatStore(options: ChatStoreOptions) {
|
||||
return wsId ? `${base}:${wsId}` : base;
|
||||
};
|
||||
|
||||
const store = create<ChatState>((set) => ({
|
||||
const store = create<ChatState>((set, get) => ({
|
||||
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 })),
|
||||
inputDrafts: readDrafts(storage, wsKey(DRAFTS_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) => {
|
||||
logger.debug("setOpen", { from: get().isOpen, to: open });
|
||||
set({ isOpen: open });
|
||||
},
|
||||
toggle: () => {
|
||||
const next = !get().isOpen;
|
||||
logger.debug("toggle", { to: next });
|
||||
set({ isOpen: next });
|
||||
},
|
||||
setActiveSession: (id) => {
|
||||
logger.info("setActiveSession", { from: get().activeSessionId, to: id });
|
||||
if (id) {
|
||||
storage.setItem(wsKey(SESSION_STORAGE_KEY), id);
|
||||
} else {
|
||||
@@ -69,29 +121,68 @@ export function createChatStore(options: ChatStoreOptions) {
|
||||
}
|
||||
set({ activeSessionId: id });
|
||||
},
|
||||
setPendingTask: (taskId) => set({ pendingTaskId: taskId, timelineItems: [] }),
|
||||
setSelectedAgentId: (id) => {
|
||||
logger.info("setSelectedAgentId", { from: get().selectedAgentId, to: id });
|
||||
storage.setItem(wsKey(AGENT_STORAGE_KEY), id);
|
||||
set({ selectedAgentId: id });
|
||||
},
|
||||
setShowHistory: (show) => set({ showHistory: show }),
|
||||
addTimelineItem: (item) =>
|
||||
set((s) => {
|
||||
if (s.timelineItems.some((t) => t.seq === item.seq)) return s;
|
||||
return {
|
||||
timelineItems: [...s.timelineItems, item].sort(
|
||||
(a, b) => a.seq - b.seq,
|
||||
),
|
||||
};
|
||||
}),
|
||||
clearTimeline: () => set({ timelineItems: [] }),
|
||||
setShowHistory: (show) => {
|
||||
logger.debug("setShowHistory", { to: show });
|
||||
set({ showHistory: show });
|
||||
},
|
||||
setInputDraft: (sessionId, draft) => {
|
||||
// Debug level — onUpdate fires on every keystroke.
|
||||
logger.debug("setInputDraft", { sessionId, length: draft.length });
|
||||
const next = { ...get().inputDrafts, [sessionId]: draft };
|
||||
writeDrafts(storage, wsKey(DRAFTS_KEY), next);
|
||||
set({ inputDrafts: next });
|
||||
},
|
||||
clearInputDraft: (sessionId) => {
|
||||
const current = get().inputDrafts;
|
||||
if (!(sessionId in current)) {
|
||||
logger.debug("clearInputDraft skipped (no draft)", { sessionId });
|
||||
return;
|
||||
}
|
||||
logger.info("clearInputDraft", { sessionId });
|
||||
const next = { ...current };
|
||||
delete next[sessionId];
|
||||
writeDrafts(storage, wsKey(DRAFTS_KEY), next);
|
||||
set({ inputDrafts: next });
|
||||
},
|
||||
setChatSize: (w, h) => {
|
||||
logger.debug("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) => {
|
||||
logger.info("setExpanded", { to: expanded });
|
||||
if (expanded) {
|
||||
storage.setItem(wsKey(CHAT_EXPANDED_KEY), "true");
|
||||
} else {
|
||||
storage.removeItem(wsKey(CHAT_EXPANDED_KEY));
|
||||
}
|
||||
set({ isExpanded: expanded });
|
||||
},
|
||||
}));
|
||||
|
||||
registerForWorkspaceRehydration(() => {
|
||||
const nextSession = storage.getItem(wsKey(SESSION_STORAGE_KEY));
|
||||
const nextAgent = storage.getItem(wsKey(AGENT_STORAGE_KEY));
|
||||
const nextDrafts = readDrafts(storage, wsKey(DRAFTS_KEY));
|
||||
logger.info("workspace rehydration", {
|
||||
prevSession: store.getState().activeSessionId,
|
||||
nextSession,
|
||||
prevAgent: store.getState().selectedAgentId,
|
||||
nextAgent,
|
||||
draftCount: Object.keys(nextDrafts).length,
|
||||
});
|
||||
store.setState({
|
||||
activeSessionId: storage.getItem(wsKey(SESSION_STORAGE_KEY)),
|
||||
selectedAgentId: storage.getItem(wsKey(AGENT_STORAGE_KEY)),
|
||||
timelineItems: [],
|
||||
activeSessionId: nextSession,
|
||||
selectedAgentId: nextAgent,
|
||||
inputDrafts: nextDrafts,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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: () => {
|
||||
@@ -167,10 +168,20 @@ export function useUpdateIssue() {
|
||||
onSettled: (_data, _err, vars, ctx) => {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.detail(wsId, vars.id) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
|
||||
// Invalidate old parent's children cache
|
||||
if (ctx?.parentId) {
|
||||
qc.invalidateQueries({
|
||||
queryKey: issueKeys.children(wsId, ctx.parentId),
|
||||
});
|
||||
qc.invalidateQueries({ queryKey: issueKeys.childProgress(wsId) });
|
||||
}
|
||||
// Invalidate new parent's children cache when parent_issue_id changed
|
||||
const newParentId = vars.parent_issue_id;
|
||||
if (newParentId && newParentId !== ctx?.parentId) {
|
||||
qc.invalidateQueries({
|
||||
queryKey: issueKeys.children(wsId, newParentId),
|
||||
});
|
||||
qc.invalidateQueries({ queryKey: issueKeys.childProgress(wsId) });
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -205,6 +216,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 +290,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),
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { create } from "zustand";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
import type { IssueStatus } from "../../types";
|
||||
import {
|
||||
createWorkspaceAwareStorage,
|
||||
registerForWorkspaceRehydration,
|
||||
@@ -13,25 +12,22 @@ const MAX_RECENT_ISSUES = 20;
|
||||
|
||||
export interface RecentIssueEntry {
|
||||
id: string;
|
||||
identifier: string;
|
||||
title: string;
|
||||
status: IssueStatus;
|
||||
visitedAt: number;
|
||||
}
|
||||
|
||||
interface RecentIssuesState {
|
||||
items: RecentIssueEntry[];
|
||||
recordVisit: (entry: Omit<RecentIssueEntry, "visitedAt">) => void;
|
||||
recordVisit: (id: string) => void;
|
||||
}
|
||||
|
||||
export const useRecentIssuesStore = create<RecentIssuesState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
items: [],
|
||||
recordVisit: (entry) =>
|
||||
recordVisit: (id) =>
|
||||
set((state) => {
|
||||
const filtered = state.items.filter((i) => i.id !== entry.id);
|
||||
const updated: RecentIssueEntry = { ...entry, visitedAt: Date.now() };
|
||||
const filtered = state.items.filter((i) => i.id !== id);
|
||||
const updated: RecentIssueEntry = { id, visitedAt: Date.now() };
|
||||
return {
|
||||
items: [updated, ...filtered].slice(0, MAX_RECENT_ISSUES),
|
||||
};
|
||||
|
||||
@@ -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) });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,16 +29,19 @@ export function onIssueUpdated(
|
||||
wsId: string,
|
||||
issue: Partial<Issue> & { id: string },
|
||||
) {
|
||||
// Look up the parent before mutating list state, so we can also keep the
|
||||
// parent's children cache in sync (powers the sub-issues list shown on
|
||||
// the parent issue page).
|
||||
// Look up the OLD parent before mutating list state, so we can keep
|
||||
// the parent's children cache in sync (powers the sub-issues list
|
||||
// shown on the parent issue page).
|
||||
const listData = qc.getQueryData<ListIssuesResponse>(issueKeys.list(wsId));
|
||||
const detailData = qc.getQueryData<Issue>(issueKeys.detail(wsId, issue.id));
|
||||
const parentId =
|
||||
issue.parent_issue_id ??
|
||||
const oldParentId =
|
||||
detailData?.parent_issue_id ??
|
||||
listData?.issues.find((i) => i.id === issue.id)?.parent_issue_id ??
|
||||
null;
|
||||
// The NEW parent comes from the WS payload when parent_issue_id changed
|
||||
const newParentId = issue.parent_issue_id ?? null;
|
||||
const parentChanged =
|
||||
issue.parent_issue_id !== undefined && newParentId !== oldParentId;
|
||||
|
||||
qc.setQueryData<ListIssuesResponse>(issueKeys.list(wsId), (old) => {
|
||||
if (!old) return old;
|
||||
@@ -62,10 +66,25 @@ export function onIssueUpdated(
|
||||
qc.setQueryData<Issue>(issueKeys.detail(wsId, issue.id), (old) =>
|
||||
old ? { ...old, ...issue } : old,
|
||||
);
|
||||
if (parentId) {
|
||||
qc.setQueryData<Issue[]>(issueKeys.children(wsId, parentId), (old) =>
|
||||
old?.map((c) => (c.id === issue.id ? { ...c, ...issue } : c)),
|
||||
);
|
||||
|
||||
// Invalidate old parent's children (issue was removed from it)
|
||||
if (oldParentId) {
|
||||
if (parentChanged) {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.children(wsId, oldParentId) });
|
||||
} else {
|
||||
qc.setQueryData<Issue[]>(issueKeys.children(wsId, oldParentId), (old) =>
|
||||
old?.map((c) => (c.id === issue.id ? { ...c, ...issue } : c)),
|
||||
);
|
||||
}
|
||||
}
|
||||
// Invalidate new parent's children (issue was added to it)
|
||||
if (newParentId && parentChanged) {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.children(wsId, newParentId) });
|
||||
}
|
||||
if (oldParentId || newParentId) {
|
||||
if (issue.status !== undefined || issue.parent_issue_id !== undefined) {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.childProgress(wsId) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,5 +115,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>
|
||||
|
||||
@@ -17,6 +17,8 @@ describe("clearWorkspaceStorage", () => {
|
||||
expect(adapter.removeItem).toHaveBeenCalledWith("multica_my_issues_view:ws_123");
|
||||
expect(adapter.removeItem).toHaveBeenCalledWith("multica:chat:selectedAgentId:ws_123");
|
||||
expect(adapter.removeItem).toHaveBeenCalledWith("multica:chat:activeSessionId:ws_123");
|
||||
expect(adapter.removeItem).toHaveBeenCalledTimes(6);
|
||||
expect(adapter.removeItem).toHaveBeenCalledWith("multica:chat:drafts:ws_123");
|
||||
expect(adapter.removeItem).toHaveBeenCalledWith("multica:chat:expanded:ws_123");
|
||||
expect(adapter.removeItem).toHaveBeenCalledTimes(8);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,8 @@ const WORKSPACE_SCOPED_KEYS = [
|
||||
"multica_my_issues_view",
|
||||
"multica:chat:selectedAgentId",
|
||||
"multica:chat:activeSessionId",
|
||||
"multica:chat:drafts",
|
||||
"multica:chat:expanded",
|
||||
];
|
||||
|
||||
/** Remove all workspace-scoped storage entries for the given workspace. */
|
||||
|
||||
@@ -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,8 @@ 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 { chatKeys } from "../chat/queries";
|
||||
import type {
|
||||
MemberAddedPayload,
|
||||
WorkspaceDeletedPayload,
|
||||
@@ -39,8 +40,14 @@ import type {
|
||||
IssueReactionRemovedPayload,
|
||||
SubscriberAddedPayload,
|
||||
SubscriberRemovedPayload,
|
||||
TaskMessagePayload,
|
||||
TaskCompletedPayload,
|
||||
TaskFailedPayload,
|
||||
ChatDonePayload,
|
||||
} from "../types";
|
||||
|
||||
const chatWsLogger = createLogger("chat.ws");
|
||||
|
||||
const logger = createLogger("realtime-sync");
|
||||
|
||||
export interface RealtimeSyncStores {
|
||||
@@ -102,7 +109,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;
|
||||
@@ -132,6 +140,9 @@ export function useRealtimeSync(
|
||||
"issue_reaction:added", "issue_reaction:removed",
|
||||
"subscriber:added", "subscriber:removed",
|
||||
"daemon:heartbeat",
|
||||
// Chat / task events are handled explicitly below; do not double-invalidate.
|
||||
"chat:message", "chat:done", "chat:session_read",
|
||||
"task:message", "task:completed", "task:failed",
|
||||
]);
|
||||
|
||||
const unsubAny = ws.onAny((msg) => {
|
||||
@@ -250,7 +261,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 +275,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 +285,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",
|
||||
@@ -278,6 +293,103 @@ export function useRealtimeSync(
|
||||
}
|
||||
});
|
||||
|
||||
// --- Chat / task events (global, survives ChatWindow unmount) ---
|
||||
//
|
||||
// Single source of truth: the Query cache. No Zustand writes here — the
|
||||
// earlier mirror caused a race where the cache and store disagreed
|
||||
// during the invalidate → refetch window and the UI rendered duplicates.
|
||||
//
|
||||
// task:message is written directly into the task-messages cache so the
|
||||
// live timeline updates in place. chat:message / chat:done /
|
||||
// task:completed / task:failed invalidate messages + pending-task so the
|
||||
// DB remains authoritative.
|
||||
|
||||
const unsubTaskMessage = ws.on("task:message", (p) => {
|
||||
const payload = p as TaskMessagePayload;
|
||||
qc.setQueryData<TaskMessagePayload[]>(
|
||||
["task-messages", payload.task_id],
|
||||
(old = []) => {
|
||||
if (old.some((m) => m.seq === payload.seq)) return old;
|
||||
return [...old, payload].sort((a, b) => a.seq - b.seq);
|
||||
},
|
||||
);
|
||||
chatWsLogger.debug("task:message (global)", {
|
||||
task_id: payload.task_id,
|
||||
seq: payload.seq,
|
||||
type: payload.type,
|
||||
});
|
||||
});
|
||||
|
||||
// Helpers reused by chat lifecycle handlers.
|
||||
const invalidatePendingAggregate = () => {
|
||||
const id = workspaceStore.getState().workspace?.id;
|
||||
if (id) qc.invalidateQueries({ queryKey: chatKeys.pendingTasks(id) });
|
||||
};
|
||||
const invalidateSessionLists = () => {
|
||||
const id = workspaceStore.getState().workspace?.id;
|
||||
if (id) {
|
||||
qc.invalidateQueries({ queryKey: chatKeys.sessions(id) });
|
||||
qc.invalidateQueries({ queryKey: chatKeys.allSessions(id) });
|
||||
}
|
||||
};
|
||||
|
||||
const unsubChatMessage = ws.on("chat:message", (p) => {
|
||||
const payload = p as { chat_session_id: string };
|
||||
chatWsLogger.info("chat:message (global)", { chat_session_id: payload.chat_session_id });
|
||||
qc.invalidateQueries({ queryKey: chatKeys.messages(payload.chat_session_id) });
|
||||
qc.invalidateQueries({ queryKey: chatKeys.pendingTask(payload.chat_session_id) });
|
||||
invalidatePendingAggregate();
|
||||
});
|
||||
|
||||
const unsubChatDone = ws.on("chat:done", (p) => {
|
||||
const payload = p as ChatDonePayload;
|
||||
chatWsLogger.info("chat:done (global)", {
|
||||
task_id: payload.task_id,
|
||||
chat_session_id: payload.chat_session_id,
|
||||
});
|
||||
// Assistant message was just written and task flipped out of 'running'.
|
||||
// Clear pending-task cache immediately so the live-timeline-vs-assistant
|
||||
// race window collapses to zero — the subsequent refetch will confirm.
|
||||
qc.setQueryData(chatKeys.pendingTask(payload.chat_session_id), {});
|
||||
qc.invalidateQueries({ queryKey: chatKeys.messages(payload.chat_session_id) });
|
||||
qc.invalidateQueries({ queryKey: chatKeys.pendingTask(payload.chat_session_id) });
|
||||
invalidatePendingAggregate();
|
||||
// Assistant message just landed → has_unread may have flipped to true.
|
||||
invalidateSessionLists();
|
||||
});
|
||||
|
||||
const unsubTaskCompleted = ws.on("task:completed", (p) => {
|
||||
const payload = p as TaskCompletedPayload;
|
||||
if (!payload.chat_session_id) return; // issue tasks handled elsewhere
|
||||
chatWsLogger.info("task:completed (global, chat)", {
|
||||
task_id: payload.task_id,
|
||||
chat_session_id: payload.chat_session_id,
|
||||
});
|
||||
qc.setQueryData(chatKeys.pendingTask(payload.chat_session_id), {});
|
||||
qc.invalidateQueries({ queryKey: chatKeys.messages(payload.chat_session_id) });
|
||||
qc.invalidateQueries({ queryKey: chatKeys.pendingTask(payload.chat_session_id) });
|
||||
invalidatePendingAggregate();
|
||||
});
|
||||
|
||||
const unsubTaskFailed = ws.on("task:failed", (p) => {
|
||||
const payload = p as TaskFailedPayload;
|
||||
if (!payload.chat_session_id) return;
|
||||
chatWsLogger.warn("task:failed (global, chat)", {
|
||||
task_id: payload.task_id,
|
||||
chat_session_id: payload.chat_session_id,
|
||||
});
|
||||
// No new message; just flip the pending signal.
|
||||
qc.setQueryData(chatKeys.pendingTask(payload.chat_session_id), {});
|
||||
qc.invalidateQueries({ queryKey: chatKeys.pendingTask(payload.chat_session_id) });
|
||||
invalidatePendingAggregate();
|
||||
});
|
||||
|
||||
const unsubChatSessionRead = ws.on("chat:session_read", (p) => {
|
||||
const payload = p as { chat_session_id: string };
|
||||
chatWsLogger.info("chat:session_read (global)", payload);
|
||||
invalidateSessionLists();
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubAny();
|
||||
unsubIssueUpdated();
|
||||
@@ -297,6 +409,12 @@ export function useRealtimeSync(
|
||||
unsubWsDeleted();
|
||||
unsubMemberRemoved();
|
||||
unsubMemberAdded();
|
||||
unsubTaskMessage();
|
||||
unsubChatMessage();
|
||||
unsubChatDone();
|
||||
unsubTaskCompleted();
|
||||
unsubTaskFailed();
|
||||
unsubChatSessionRead();
|
||||
timers.forEach(clearTimeout);
|
||||
timers.clear();
|
||||
};
|
||||
|
||||
@@ -47,6 +47,7 @@ export interface Agent {
|
||||
avatar_url: string | null;
|
||||
runtime_mode: AgentRuntimeMode;
|
||||
runtime_config: Record<string, unknown>;
|
||||
custom_env: Record<string, string>;
|
||||
visibility: AgentVisibility;
|
||||
status: AgentStatus;
|
||||
max_concurrent_tasks: number;
|
||||
@@ -65,6 +66,7 @@ export interface CreateAgentRequest {
|
||||
avatar_url?: string;
|
||||
runtime_id: string;
|
||||
runtime_config?: Record<string, unknown>;
|
||||
custom_env?: Record<string, string>;
|
||||
visibility?: AgentVisibility;
|
||||
max_concurrent_tasks?: number;
|
||||
}
|
||||
@@ -76,6 +78,7 @@ export interface UpdateAgentRequest {
|
||||
avatar_url?: string;
|
||||
runtime_id?: string;
|
||||
runtime_config?: Record<string, unknown>;
|
||||
custom_env?: Record<string, string>;
|
||||
visibility?: AgentVisibility;
|
||||
status?: AgentStatus;
|
||||
max_concurrent_tasks?: number;
|
||||
|
||||
@@ -5,10 +5,22 @@ export interface ChatSession {
|
||||
creator_id: string;
|
||||
title: string;
|
||||
status: "active" | "archived";
|
||||
/** True when the session has any unread assistant replies. List-only. */
|
||||
has_unread: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface PendingChatTaskItem {
|
||||
task_id: string;
|
||||
status: string;
|
||||
chat_session_id: string;
|
||||
}
|
||||
|
||||
export interface PendingChatTasksResponse {
|
||||
tasks: PendingChatTaskItem[];
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
chat_session_id: string;
|
||||
@@ -22,3 +34,12 @@ export interface SendChatMessageResponse {
|
||||
message_id: string;
|
||||
task_id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response from GET /api/chat/sessions/{id}/pending-task.
|
||||
* Both fields are absent when the session has no in-flight task.
|
||||
*/
|
||||
export interface ChatPendingTask {
|
||||
task_id?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ export type WSEventType =
|
||||
| "issue_reaction:removed"
|
||||
| "chat:message"
|
||||
| "chat:done"
|
||||
| "chat:session_read"
|
||||
| "project:created"
|
||||
| "project:updated"
|
||||
| "project:deleted"
|
||||
@@ -170,6 +171,7 @@ export interface ActivityCreatedPayload {
|
||||
export interface TaskMessagePayload {
|
||||
task_id: string;
|
||||
issue_id: string;
|
||||
chat_session_id?: string;
|
||||
seq: number;
|
||||
type: "text" | "thinking" | "tool_use" | "tool_result" | "error";
|
||||
tool?: string;
|
||||
@@ -182,6 +184,7 @@ export interface TaskCompletedPayload {
|
||||
task_id: string;
|
||||
agent_id: string;
|
||||
issue_id: string;
|
||||
chat_session_id?: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
@@ -189,6 +192,7 @@ export interface TaskFailedPayload {
|
||||
task_id: string;
|
||||
agent_id: string;
|
||||
issue_id: string;
|
||||
chat_session_id?: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
@@ -196,6 +200,7 @@ export interface TaskCancelledPayload {
|
||||
task_id: string;
|
||||
agent_id: string;
|
||||
issue_id: string;
|
||||
chat_session_id?: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
@@ -239,6 +244,10 @@ export interface ChatDonePayload {
|
||||
content?: string;
|
||||
}
|
||||
|
||||
export interface ChatSessionReadPayload {
|
||||
chat_session_id: string;
|
||||
}
|
||||
|
||||
export interface ProjectCreatedPayload {
|
||||
project: Project;
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ export type { IssueSubscriber } from "./subscriber";
|
||||
export type * from "./events";
|
||||
export type * from "./api";
|
||||
export type { Attachment } from "./attachment";
|
||||
export type { ChatSession, ChatMessage, SendChatMessageResponse } from "./chat";
|
||||
export type { ChatSession, ChatMessage, ChatPendingTask, PendingChatTaskItem, PendingChatTasksResponse, SendChatMessageResponse } from "./chat";
|
||||
export type { StorageAdapter } from "./storage";
|
||||
export type { Project, ProjectStatus, ProjectPriority, CreateProjectRequest, UpdateProjectRequest, ListProjectsResponse } from "./project";
|
||||
export type { PinnedItem, PinnedItemType, CreatePinRequest, ReorderPinsRequest } from "./pin";
|
||||
|
||||
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 };
|
||||
@@ -115,7 +115,11 @@ function createComponents(
|
||||
const id = mentionMatch[2]
|
||||
|
||||
if (renderMention) {
|
||||
return <>{renderMention({ type, id })}</>
|
||||
// Let the custom renderer opt out for types it doesn't handle
|
||||
// by returning null/undefined — we then fall through to the
|
||||
// default styled span so nothing ever disappears silently.
|
||||
const rendered = renderMention({ type, id })
|
||||
if (rendered) return <>{rendered}</>
|
||||
}
|
||||
|
||||
// Fallback: render as a simple styled span
|
||||
|
||||
@@ -25,6 +25,24 @@
|
||||
animation: entrance-spin 0.6s ease-out forwards;
|
||||
}
|
||||
|
||||
/* Chat FAB: gentle color + border tint while a chat task is running.
|
||||
* Keeps the ring at the same thickness — only hue shifts towards brand
|
||||
* at half-cycle, no outer glow. */
|
||||
@keyframes chat-impulse {
|
||||
0%, 100% {
|
||||
color: var(--muted-foreground);
|
||||
box-shadow: 0 0 0 1px color-mix(in oklab, var(--foreground) 10%, transparent);
|
||||
}
|
||||
50% {
|
||||
color: var(--brand);
|
||||
box-shadow: 0 0 0 1px color-mix(in oklab, var(--brand) 40%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-chat-impulse {
|
||||
animation: chat-impulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Sidebar: open triggers (dropdown/popover) get active background */
|
||||
[data-sidebar="menu-button"][data-popup-open] {
|
||||
background-color: var(--sidebar-accent);
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
AlertCircle,
|
||||
MoreHorizontal,
|
||||
Settings,
|
||||
KeyRound,
|
||||
} from "lucide-react";
|
||||
import type { Agent, RuntimeDevice } from "@multica/core/types";
|
||||
import {
|
||||
@@ -34,17 +35,19 @@ import { InstructionsTab } from "./tabs/instructions-tab";
|
||||
import { SkillsTab } from "./tabs/skills-tab";
|
||||
import { TasksTab } from "./tabs/tasks-tab";
|
||||
import { SettingsTab } from "./tabs/settings-tab";
|
||||
import { EnvTab } from "./tabs/env-tab";
|
||||
|
||||
function getRuntimeDevice(agent: Agent, runtimes: RuntimeDevice[]): RuntimeDevice | undefined {
|
||||
return runtimes.find((runtime) => runtime.id === agent.runtime_id);
|
||||
}
|
||||
|
||||
type DetailTab = "instructions" | "skills" | "tasks" | "settings";
|
||||
type DetailTab = "instructions" | "skills" | "tasks" | "env" | "settings";
|
||||
|
||||
const detailTabs: { id: DetailTab; label: string; icon: typeof FileText }[] = [
|
||||
{ id: "instructions", label: "Instructions", icon: FileText },
|
||||
{ id: "skills", label: "Skills", icon: BookOpenText },
|
||||
{ id: "tasks", label: "Tasks", icon: ListTodo },
|
||||
{ id: "env", label: "Environment", icon: KeyRound },
|
||||
{ id: "settings", label: "Settings", icon: Settings },
|
||||
];
|
||||
|
||||
@@ -158,6 +161,12 @@ export function AgentDetail({
|
||||
<SkillsTab agent={agent} />
|
||||
)}
|
||||
{activeTab === "tasks" && <TasksTab agent={agent} />}
|
||||
{activeTab === "env" && (
|
||||
<EnvTab
|
||||
agent={agent}
|
||||
onSave={(updates) => onUpdate(agent.id, updates)}
|
||||
/>
|
||||
)}
|
||||
{activeTab === "settings" && (
|
||||
<SettingsTab
|
||||
agent={agent}
|
||||
|
||||
191
packages/views/agents/components/tabs/env-tab.tsx
Normal file
191
packages/views/agents/components/tabs/env-tab.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Loader2,
|
||||
Save,
|
||||
Plus,
|
||||
Trash2,
|
||||
Eye,
|
||||
EyeOff,
|
||||
} from "lucide-react";
|
||||
import type { Agent } from "@multica/core/types";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { Input } from "@multica/ui/components/ui/input";
|
||||
import { Label } from "@multica/ui/components/ui/label";
|
||||
import { toast } from "sonner";
|
||||
|
||||
let nextEnvId = 0;
|
||||
|
||||
interface EnvEntry {
|
||||
id: number;
|
||||
key: string;
|
||||
value: string;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
function envMapToEntries(env: Record<string, string>): EnvEntry[] {
|
||||
return Object.entries(env).map(([key, value]) => ({
|
||||
id: nextEnvId++,
|
||||
key,
|
||||
value,
|
||||
visible: false,
|
||||
}));
|
||||
}
|
||||
|
||||
function entriesToEnvMap(entries: EnvEntry[]): Record<string, string> {
|
||||
const map: Record<string, string> = {};
|
||||
for (const entry of entries) {
|
||||
const key = entry.key.trim();
|
||||
if (key) {
|
||||
map[key] = entry.value;
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
export function EnvTab({
|
||||
agent,
|
||||
onSave,
|
||||
}: {
|
||||
agent: Agent;
|
||||
onSave: (updates: Partial<Agent>) => Promise<void>;
|
||||
}) {
|
||||
const [envEntries, setEnvEntries] = useState<EnvEntry[]>(
|
||||
envMapToEntries(agent.custom_env ?? {}),
|
||||
);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const currentEnvMap = entriesToEnvMap(envEntries);
|
||||
const originalEnvMap = agent.custom_env ?? {};
|
||||
const dirty =
|
||||
JSON.stringify(currentEnvMap) !== JSON.stringify(originalEnvMap);
|
||||
|
||||
const addEnvEntry = () => {
|
||||
setEnvEntries([
|
||||
...envEntries,
|
||||
{ id: nextEnvId++, key: "", value: "", visible: true },
|
||||
]);
|
||||
};
|
||||
|
||||
const removeEnvEntry = (index: number) => {
|
||||
setEnvEntries(envEntries.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const updateEnvEntry = (
|
||||
index: number,
|
||||
field: "key" | "value",
|
||||
val: string,
|
||||
) => {
|
||||
setEnvEntries(
|
||||
envEntries.map((entry, i) =>
|
||||
i === index ? { ...entry, [field]: val } : entry,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const toggleEnvVisibility = (index: number) => {
|
||||
setEnvEntries(
|
||||
envEntries.map((entry, i) =>
|
||||
i === index ? { ...entry, visible: !entry.visible } : entry,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
const keys = envEntries.filter((e) => e.key.trim()).map((e) => e.key.trim());
|
||||
const uniqueKeys = new Set(keys);
|
||||
if (uniqueKeys.size < keys.length) {
|
||||
toast.error("Duplicate environment variable keys");
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSave({ custom_env: currentEnvMap });
|
||||
toast.success("Environment variables saved");
|
||||
} catch {
|
||||
toast.error("Failed to save environment variables");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-lg space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Environment Variables
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Injected into the agent process at launch (e.g. ANTHROPIC_API_KEY,
|
||||
ANTHROPIC_BASE_URL)
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addEnvEntry}
|
||||
className="h-7 gap-1 text-xs"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
{envEntries.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{envEntries.map((entry, index) => (
|
||||
<div key={entry.id} className="flex items-center gap-2">
|
||||
<Input
|
||||
value={entry.key}
|
||||
onChange={(e) => updateEnvEntry(index, "key", e.target.value)}
|
||||
placeholder="KEY"
|
||||
className="w-[40%] font-mono text-xs"
|
||||
/>
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
type={entry.visible ? "text" : "password"}
|
||||
value={entry.value}
|
||||
onChange={(e) =>
|
||||
updateEnvEntry(index, "value", e.target.value)
|
||||
}
|
||||
placeholder="value"
|
||||
className="pr-8 font-mono text-xs"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleEnvVisibility(index)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{entry.visible ? (
|
||||
<EyeOff className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeEnvEntry(index)}
|
||||
className="shrink-0 text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button onClick={handleSave} disabled={!dirty || saving} size="sm">
|
||||
{saving ? (
|
||||
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
|
||||
) : (
|
||||
<Save className="h-3.5 w-3.5 mr-1.5" />
|
||||
)}
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -72,6 +72,7 @@ export function SettingsTab({
|
||||
toast.error("Name is required");
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSave({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Plus, FileText, Trash2 } from "lucide-react";
|
||||
import { Plus, FileText, Trash2, Info } from "lucide-react";
|
||||
import type { Agent } from "@multica/core/types";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -65,7 +65,7 @@ export function SkillsTab({
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">Skills</h3>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Reusable skills assigned to this agent. Manage skills on the Skills page.
|
||||
Workspace skills assigned to this agent.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
@@ -79,12 +79,19 @@ export function SkillsTab({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-2 rounded-md border border-info/20 bg-info/5 px-3 py-2.5">
|
||||
<Info className="h-3.5 w-3.5 shrink-0 text-info mt-0.5" />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Local runtime skills (from your CLI's skills directory) are always available automatically — no need to add them here.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{agent.skills.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed py-12">
|
||||
<FileText className="h-8 w-8 text-muted-foreground/40" />
|
||||
<p className="mt-3 text-sm text-muted-foreground">No skills assigned</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Add skills from the workspace to this agent.
|
||||
Add workspace skills to share team knowledge with this agent. Local skills are already used automatically.
|
||||
</p>
|
||||
{availableSkills.length > 0 && (
|
||||
<Button
|
||||
|
||||
@@ -13,6 +13,15 @@ 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 mockApiIssueCliToken = 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(
|
||||
@@ -50,6 +59,7 @@ vi.mock("@multica/core/api", () => ({
|
||||
verifyCode: mockApiVerifyCode,
|
||||
setToken: mockApiSetToken,
|
||||
getMe: mockApiGetMe,
|
||||
issueCliToken: mockApiIssueCliToken,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -80,7 +90,8 @@ describe("LoginPage", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
vi.clearAllMocks();
|
||||
// Default: no existing session
|
||||
// Default: no existing session (getMe rejects when no auth)
|
||||
mockApiGetMe.mockRejectedValue(new Error("unauthorized"));
|
||||
localStorage.clear();
|
||||
// Reset window.location for tests that change it
|
||||
Object.defineProperty(window, "location", {
|
||||
@@ -293,7 +304,7 @@ describe("LoginPage", () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// After transitioning to code step, cooldown is 10s
|
||||
// After transitioning to code step, cooldown is 60s
|
||||
const resendBtn = screen.getByRole("button", { name: /resend in/i });
|
||||
expect(resendBtn).toBeDisabled();
|
||||
});
|
||||
@@ -329,9 +340,9 @@ describe("LoginPage", () => {
|
||||
// sendCode was called once for the initial send
|
||||
expect(mockSendCode).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Advance past the 10s cooldown one second at a time so React can
|
||||
// Advance past the 60s cooldown one second at a time so React can
|
||||
// process each setCooldown state update between ticks.
|
||||
for (let i = 0; i < 11; i++) {
|
||||
for (let i = 0; i < 61; i++) {
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1_000);
|
||||
});
|
||||
@@ -377,11 +388,14 @@ describe("LoginPage", () => {
|
||||
|
||||
it("shows cli_confirm step when existing session + cliCallback", async () => {
|
||||
localStorage.setItem("multica_token", "existing-jwt");
|
||||
mockApiGetMe.mockResolvedValueOnce({
|
||||
id: "u-1",
|
||||
email: "user@example.com",
|
||||
name: "Test User",
|
||||
});
|
||||
// Cookie attempt fails first, then localStorage fallback succeeds
|
||||
mockApiGetMe
|
||||
.mockRejectedValueOnce(new Error("no cookie"))
|
||||
.mockResolvedValueOnce({
|
||||
id: "u-1",
|
||||
email: "user@example.com",
|
||||
name: "Test User",
|
||||
});
|
||||
|
||||
render(
|
||||
<LoginPage
|
||||
@@ -406,11 +420,14 @@ describe("LoginPage", () => {
|
||||
|
||||
it("CLI authorize button redirects to callback URL", async () => {
|
||||
localStorage.setItem("multica_token", "existing-jwt");
|
||||
mockApiGetMe.mockResolvedValueOnce({
|
||||
id: "u-1",
|
||||
email: "user@example.com",
|
||||
name: "Test User",
|
||||
});
|
||||
// Cookie attempt fails, localStorage fallback succeeds
|
||||
mockApiGetMe
|
||||
.mockRejectedValueOnce(new Error("no cookie"))
|
||||
.mockResolvedValueOnce({
|
||||
id: "u-1",
|
||||
email: "user@example.com",
|
||||
name: "Test User",
|
||||
});
|
||||
const onTokenObtained = vi.fn();
|
||||
|
||||
render(
|
||||
@@ -438,11 +455,14 @@ describe("LoginPage", () => {
|
||||
|
||||
it("'Use a different account' returns to email step", async () => {
|
||||
localStorage.setItem("multica_token", "existing-jwt");
|
||||
mockApiGetMe.mockResolvedValueOnce({
|
||||
id: "u-1",
|
||||
email: "user@example.com",
|
||||
name: "Test User",
|
||||
});
|
||||
// Cookie attempt fails, localStorage fallback succeeds
|
||||
mockApiGetMe
|
||||
.mockRejectedValueOnce(new Error("no cookie"))
|
||||
.mockResolvedValueOnce({
|
||||
id: "u-1",
|
||||
email: "user@example.com",
|
||||
name: "Test User",
|
||||
});
|
||||
|
||||
render(
|
||||
<LoginPage
|
||||
@@ -467,6 +487,65 @@ describe("LoginPage", () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// CLI callback — cookie-based session (no localStorage token)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it("detects cookie-based session and shows cli_confirm when no localStorage token", async () => {
|
||||
// No localStorage token — getMe succeeds via HttpOnly cookie
|
||||
mockApiGetMe.mockResolvedValueOnce({
|
||||
id: "u-1",
|
||||
email: "cookie@example.com",
|
||||
name: "Cookie User",
|
||||
});
|
||||
|
||||
render(
|
||||
<LoginPage
|
||||
onSuccess={onSuccess}
|
||||
cliCallback={{ url: "http://localhost:9876/callback", state: "abc" }}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/authorize cli/i)).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText(/cookie@example.com/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("CLI authorize with cookie session calls issueCliToken and redirects", async () => {
|
||||
// No localStorage token — getMe succeeds via cookie
|
||||
mockApiGetMe.mockResolvedValueOnce({
|
||||
id: "u-1",
|
||||
email: "cookie@example.com",
|
||||
name: "Cookie User",
|
||||
});
|
||||
mockApiIssueCliToken.mockResolvedValueOnce({ token: "fresh-jwt" });
|
||||
const onTokenObtained = vi.fn();
|
||||
|
||||
render(
|
||||
<LoginPage
|
||||
onSuccess={onSuccess}
|
||||
onTokenObtained={onTokenObtained}
|
||||
cliCallback={{ url: "http://localhost:9876/callback", state: "abc" }}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/authorize cli/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const user = userEvent.setup();
|
||||
await user.click(screen.getByRole("button", { name: /^authorize$/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockApiIssueCliToken).toHaveBeenCalled();
|
||||
expect(onTokenObtained).toHaveBeenCalled();
|
||||
expect(window.location.href).toContain(
|
||||
"http://localhost:9876/callback?token=fresh-jwt&state=abc",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// CLI callback — code verification redirects
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -647,12 +726,34 @@ describe("validateCliCallback", () => {
|
||||
expect(validateCliCallback("http://127.0.0.1:8080/cb")).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts 10.x.x.x private IPs", () => {
|
||||
expect(validateCliCallback("http://10.0.0.5:9876/callback")).toBe(true);
|
||||
expect(validateCliCallback("http://10.255.255.255:1234/cb")).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts 172.16-31.x.x private IPs", () => {
|
||||
expect(validateCliCallback("http://172.16.0.1:9876/callback")).toBe(true);
|
||||
expect(validateCliCallback("http://172.31.255.255:1234/cb")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects 172.x outside 16-31 range", () => {
|
||||
expect(validateCliCallback("http://172.15.0.1:9876/callback")).toBe(false);
|
||||
expect(validateCliCallback("http://172.32.0.1:9876/callback")).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts 192.168.x.x private IPs", () => {
|
||||
expect(validateCliCallback("http://192.168.1.131:41117/callback")).toBe(true);
|
||||
expect(validateCliCallback("http://192.168.0.1:8080/cb")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects https:// URLs", () => {
|
||||
expect(validateCliCallback("https://localhost:9876/callback")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects non-localhost hosts", () => {
|
||||
it("rejects public IPs and domains", () => {
|
||||
expect(validateCliCallback("http://evil.com:9876/callback")).toBe(false);
|
||||
expect(validateCliCallback("http://8.8.8.8:9876/callback")).toBe(false);
|
||||
expect(validateCliCallback("http://192.169.1.1:9876/callback")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects invalid URLs", () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, type ReactNode } from "react";
|
||||
import { useState, useEffect, useCallback, useRef, 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;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -62,14 +68,22 @@ function redirectToCliCallback(url: string, token: string, state: string) {
|
||||
window.location.href = `${url}${separator}token=${encodeURIComponent(token)}&state=${encodeURIComponent(state)}`;
|
||||
}
|
||||
|
||||
/** Validate that a CLI callback URL points to localhost over HTTP. */
|
||||
/**
|
||||
* Validate that a CLI callback URL points to a safe host over HTTP.
|
||||
* Allows localhost and private/LAN IPs (RFC 1918) to support self-hosted setups
|
||||
* on local VMs while blocking arbitrary public hosts.
|
||||
*/
|
||||
export function validateCliCallback(cliCallback: string): boolean {
|
||||
try {
|
||||
const cbUrl = new URL(cliCallback);
|
||||
if (cbUrl.protocol !== "http:") return false;
|
||||
if (cbUrl.hostname !== "localhost" && cbUrl.hostname !== "127.0.0.1")
|
||||
return false;
|
||||
return true;
|
||||
const h = cbUrl.hostname;
|
||||
if (h === "localhost" || h === "127.0.0.1") return true;
|
||||
// Allow RFC 1918 private IPs: 10.x.x.x, 172.16-31.x.x, 192.168.x.x
|
||||
if (/^10\./.test(h)) return true;
|
||||
if (/^172\.(1[6-9]|2\d|3[01])\./.test(h)) return true;
|
||||
if (/^192\.168\./.test(h)) return true;
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
@@ -86,7 +100,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("");
|
||||
@@ -94,23 +110,43 @@ export function LoginPage({
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [cooldown, setCooldown] = useState(0);
|
||||
const [existingUser, setExistingUser] = useState<User | null>(null);
|
||||
// Tracks how the existing session was detected so handleCliAuthorize
|
||||
// uses the matching token source (cookie → issueCliToken, localStorage → direct).
|
||||
const authSourceRef = useRef<"cookie" | "localStorage">("cookie");
|
||||
|
||||
// Check for existing session when CLI callback is present
|
||||
// Check for existing session when CLI callback is present.
|
||||
// Prioritises cookie auth (= current browser session) to avoid authorising
|
||||
// the CLI with a stale or mismatched localStorage token.
|
||||
useEffect(() => {
|
||||
if (!cliCallback) return;
|
||||
const token = localStorage.getItem("multica_token");
|
||||
if (!token) return;
|
||||
|
||||
api.setToken(token);
|
||||
// Ensure no stale bearer token interferes — we want to test the cookie first.
|
||||
api.setToken(null);
|
||||
|
||||
api
|
||||
.getMe()
|
||||
.then((user) => {
|
||||
authSourceRef.current = "cookie";
|
||||
setExistingUser(user);
|
||||
setStep("cli_confirm");
|
||||
})
|
||||
.catch(() => {
|
||||
api.setToken(null);
|
||||
localStorage.removeItem("multica_token");
|
||||
// Cookie auth failed — fall back to localStorage token
|
||||
const token = localStorage.getItem("multica_token");
|
||||
if (!token) return;
|
||||
|
||||
api.setToken(token);
|
||||
api
|
||||
.getMe()
|
||||
.then((user) => {
|
||||
authSourceRef.current = "localStorage";
|
||||
setExistingUser(user);
|
||||
setStep("cli_confirm");
|
||||
})
|
||||
.catch(() => {
|
||||
api.setToken(null);
|
||||
localStorage.removeItem("multica_token");
|
||||
});
|
||||
});
|
||||
}, [cliCallback]);
|
||||
|
||||
@@ -134,7 +170,7 @@ export function LoginPage({
|
||||
await useAuthStore.getState().sendCode(email);
|
||||
setStep("code");
|
||||
setCode("");
|
||||
setCooldown(10);
|
||||
setCooldown(60);
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error
|
||||
@@ -167,6 +203,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 +215,7 @@ export function LoginPage({
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[email, onSuccess, cliCallback, lastWorkspaceId, onTokenObtained],
|
||||
[email, onSuccess, cliCallback, lastWorkspaceId, onTokenObtained, qc],
|
||||
);
|
||||
|
||||
const handleResend = async () => {
|
||||
@@ -186,7 +223,7 @@ export function LoginPage({
|
||||
setError("");
|
||||
try {
|
||||
await useAuthStore.getState().sendCode(email);
|
||||
setCooldown(10);
|
||||
setCooldown(60);
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Failed to resend code",
|
||||
@@ -194,16 +231,39 @@ export function LoginPage({
|
||||
}
|
||||
};
|
||||
|
||||
const handleCliAuthorize = () => {
|
||||
const handleCliAuthorize = async () => {
|
||||
if (!cliCallback) return;
|
||||
const token = localStorage.getItem("multica_token");
|
||||
if (!token) return;
|
||||
setLoading(true);
|
||||
onTokenObtained?.();
|
||||
redirectToCliCallback(cliCallback.url, token, cliCallback.state);
|
||||
|
||||
try {
|
||||
let token: string;
|
||||
|
||||
if (authSourceRef.current === "localStorage") {
|
||||
// Session was detected via localStorage — reuse that token directly.
|
||||
const stored = localStorage.getItem("multica_token");
|
||||
if (!stored) throw new Error("token missing");
|
||||
token = stored;
|
||||
} else {
|
||||
// Session was detected via cookie — obtain a bearer token from the server.
|
||||
const res = await api.issueCliToken();
|
||||
token = res.token;
|
||||
}
|
||||
|
||||
onTokenObtained?.();
|
||||
redirectToCliCallback(cliCallback.url, token, cliCallback.state);
|
||||
} catch {
|
||||
setError("Failed to authorize CLI. Please log in again.");
|
||||
setExistingUser(null);
|
||||
setStep("email");
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGoogleLogin = () => {
|
||||
if (onGoogleLogin) {
|
||||
onGoogleLogin();
|
||||
return;
|
||||
}
|
||||
if (!google) return;
|
||||
const params = new URLSearchParams({
|
||||
client_id: google.clientId,
|
||||
@@ -213,6 +273,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 +432,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">
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user